Compare commits

...
Author SHA1 Message Date
Chris Lu feef8ed40e refactor 2026-02-26 01:14:59 -08:00
Chris Lu 8e04f961f0 parameterize expected ec shard count 2026-02-26 01:01:10 -08:00
Chris Lu 5400fdca3a address comments 2026-02-26 00:58:29 -08:00
Chris Lu d3d4e354bf Create plan_test.go 2026-02-26 00:40:49 -08:00
Chris Lu df6f325574 ec repair: fix detection metadata 2026-02-26 00:31:58 -08:00
Chris Lu 0a6a76b71e test: share ec repair helpers 2026-02-26 00:31:53 -08:00
Chris LuGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
93eff8fa45 Update weed/plugin/worker/ec_repair_handler.go
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-26 00:26:40 -08:00
Chris Lu cc53bb17ca ci: run ec repair plugin worker tests 2026-02-26 00:01:15 -08:00
Chris Lu e1f2e7e357 test: add ec repair plugin worker coverage 2026-02-26 00:01:08 -08:00
Chris Lu 6e715f14d5 test: extend fake volume server for ec repair 2026-02-26 00:01:02 -08:00
Chris Lu b09f4cc3e7 ec repair: add plugin worker handler 2026-02-26 00:00:57 -08:00
Chris Lu a8fab5503c ec repair: add shard analysis helpers 2026-02-26 00:00:52 -08:00
Chris LuandGitHub 453310b057 Add plugin worker integration tests for erasure coding (#8450)
* test: add plugin worker integration harness

* test: add erasure coding detection integration tests

* test: add erasure coding execution integration tests

* ci: add plugin worker integration workflow

* test: extend fake volume server for vacuum and balance

* test: expand erasure coding detection topologies

* test: add large erasure coding detection topology

* test: add vacuum plugin worker integration tests

* test: add volume balance plugin worker integration tests

* ci: run plugin worker tests per worker

* fixes

* erasure coding: stop after placement failures

* erasure coding: record hasMore when early stopping

* erasure coding: relax large topology expectations
2026-02-25 22:11:41 -08:00
9b6bbf7d45 Remove volumePreallocate option from docker containers (#8451)
Some filesystems, such as XFS, may over-allocate disk spaces when using
volume preallocation. Remove this option from the default docker entrypoint
scripts to allow volumes to use only the necessary disk space.

Fixes: https://github.com/seaweedfs/seaweedfs/issues/6465#issuecomment-3964174718

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 22:11:15 -08:00
Chris LuandGitHub d2b92938ee Make EC detection context aware (#8449)
* Make EC detection context aware

* Update register.go

* Speed up EC detection planning

* Add tests for EC detection planner

* optimizations

detection.go: extracted ParseCollectionFilter (exported) and feed it into the detection loop so both detection and tracing share the same parsing/whitelisting logic; the detection loop now iterates on a sorted list of volume IDs, checks the context at every iteration, and only sets hasMore when there are still unprocessed groups after hitting maxResults, keeping runtime bounded while still scheduling planned tasks before returning the results.
erasure_coding_handler.go: dropped the duplicated inline filter parsing in emitErasureCodingDetectionDecisionTrace and now reuse erasurecodingtask.ParseCollectionFilter, and the summary suffix logic now only accounts for the hasMore case that can actually happen.
detection_test.go: updated the helper topology builder to use master_pb.VolumeInformationMessage (matching the current protobuf types) and tightened the cancellation/max-results tests so they reliably exercise the detection logic (cancel before calling Detection, and provide enough disks so one result is produced before the limit).

* use working directory

* fix compilation

* fix compilation

* rename

* go vet

* fix getenv

* address comments, fix error
2026-02-25 18:02:35 -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>
7f6e58b791 Fix SFTP file upload failures with JWT filer tokens (#8448)
* Fix SFTP file upload failures with JWT filer tokens (issue #8425)

When JWT authentication is enabled for filer operations via jwt.filer_signing.*
configuration, SFTP server file upload requests were rejected because they lacked
JWT authorization headers.

Changes:
- Added JWT signing key and expiration fields to SftpServer struct
- Modified putFile() to generate and include JWT tokens in upload requests
- Enhanced SFTPServiceOptions with JWT configuration fields
- Updated SFTP command startup to load and pass JWT config to service

This allows SFTP uploads to authenticate with JWT-enabled filers, consistent
with how other SeaweedFS components (S3 API, file browser) handle filer auth.

Fixes #8425

* Apply suggestion from @gemini-code-assist[bot]

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-25 14:30:21 -08:00
Chris Lu a92e9baddf Add integration test for multipart operations inheriting s3:PutObject permissions
TestS3MultipartOperationsInheritPutObjectPermissions verifies that multipart
upload operations (CreateMultipartUpload, UploadPart, ListParts,
CompleteMultipartUpload, AbortMultipartUpload, ListMultipartUploads) work
correctly when a user has only s3:PutObject permission granted.

This test validates the behavior where multipart operations are implicitly
granted when s3:PutObject is authorized, as multipart upload is an
implementation detail of putting objects in S3.
2026-02-25 13:38:14 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Chris Lu
52be2edcb1 build(deps): bump github.com/parquet-go/parquet-go from 0.26.4 to 0.27.0 (#8410)
* build(deps): bump github.com/parquet-go/parquet-go from 0.26.4 to 0.27.0

Bumps [github.com/parquet-go/parquet-go](https://github.com/parquet-go/parquet-go) from 0.26.4 to 0.27.0.
- [Release notes](https://github.com/parquet-go/parquet-go/releases)
- [Changelog](https://github.com/parquet-go/parquet-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/parquet-go/parquet-go/compare/v0.26.4...v0.27.0)

---
updated-dependencies:
- dependency-name: github.com/parquet-go/parquet-go
  dependency-version: 0.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix parser

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
2026-02-25 13:15:36 -08:00
Chris LuGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
b9fa05153a Allow multipart upload operations when s3:PutObject is authorized (#8445)
* Allow multipart upload operations when s3:PutObject is authorized

Multipart upload is an implementation detail of putting objects, not a separate
permission. When a policy grants s3:PutObject, it should implicitly allow:
- s3:CreateMultipartUpload
- s3:UploadPart
- s3:CompleteMultipartUpload
- s3:AbortMultipartUpload
- s3:ListParts

This fixes a compatibility issue where clients like PyArrow that use multipart
uploads by default would fail even though the role had s3:PutObject permission.
The session policy intersection still applies - both the identity-based policy
AND session policy must allow s3:PutObject for multipart operations to work.

Implementation:
- Added constants for S3 multipart action strings
- Added multipartActionSet to efficiently check if action is multipart-related
- Updated MatchesAction method to implicitly grant multipart when PutObject allowed

* Update weed/s3api/policy_engine/types.go

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

* Add s3:ListMultipartUploads to multipart action set

Include s3:ListMultipartUploads in the multipartActionSet so that listing
multipart uploads is implicitly granted when s3:PutObject is authorized.
ListMultipartUploads is a critical part of the multipart upload workflow,
allowing clients to query in-progress uploads before completing them.

Changes:
- Added s3ListMultipartUploads constant definition
- Included s3ListMultipartUploads in multipartActionSet initialization
- Existing references to multipartActionSet automatically now cover ListMultipartUploads

All policy engine tests pass (0.351s execution time)

* Refactor: reuse multipart action constants from s3_constants package

Remove duplicate constant definitions from policy_engine/types.go and import
the canonical definitions from s3api/s3_constants/s3_actions.go instead.
This eliminates duplication and ensures a single source of truth for
multipart action strings:

- ACTION_CREATE_MULTIPART_UPLOAD
- ACTION_UPLOAD_PART
- ACTION_COMPLETE_MULTIPART
- ACTION_ABORT_MULTIPART
- ACTION_LIST_PARTS
- ACTION_LIST_MULTIPART_UPLOADS

All policy engine tests pass (0.350s execution time)

* Fix S3_ACTION_LIST_MULTIPART_UPLOADS constant value

Move S3_ACTION_LIST_MULTIPART_UPLOADS from bucket operations to multipart
operations section and change value from 's3:ListBucketMultipartUploads' to
's3:ListMultipartUploads' to match the action strings used in policy_engine
and s3_actions.go.

This ensures consistent action naming across all S3 constant definitions.

* refactor names

* Fix S3 action constant mismatches and MatchesAction early return bug

Fix two critical issues in policy engine:

1. S3Actions map had incorrect multipart action mappings:
   - 'ListMultipartUploads' was 's3:ListMultipartUploads' (should be 's3:ListBucketMultipartUploads')
   - 'ListParts' was 's3:ListParts' (should be 's3:ListMultipartUploadParts')
   These mismatches caused authorization checks to fail for list operations

2. CompiledStatement.MatchesAction() had early return bug:
   - Previously returned true immediately upon first direct action match
   - This prevented scanning remaining matchers for s3:PutObject permission
   - Now scans ALL matchers before returning, tracking both direct match and PutObject grant
   - Ensures multipart operations inherit s3:PutObject authorization even when
     explicitly requested action doesn't match (e.g., s3:ListMultipartUploadParts)

Changes:
- Track matchedAction flag to defer
Fix two critical issues in policy engine:

1. S3Actions map had incorrect multipart action mappings:
   - 'ListMultipartUploads' was 's3:ListMultipartUplPer
1. S3Actions map had incorrect multiparAll   - 'ListMultipartUploads(0.334s execution time)

* Refactor S3Actions map to use s3_constants

Replace hardcoded action strings in the S3Actions map with references to
canonical S3_ACTION_* constants from s3_constants/s3_action_strings.go.

Benefits:
- Single source of truth for S3 action values
- Eliminates string duplication across codebase
- Ensures consistency between policy engine and middleware
- Reduces maintenance burden when action strings need updates

All policy engine tests pass (0.334s execution time)

* Remove unused S3Actions map

The S3Actions map in types.go was never referenced anywhere in the codebase.
All action mappings are handled by GetActionMappings() in integration.go instead.
This removes 42 lines of dead code.

* Fix test: reload configuration function must also reload IAM state

TestEmbeddedIamAttachUserPolicyRefreshesIAM was failing because the test's
reloadConfigurationFunc only updated mockConfig but didn't reload the actual IAM
state. When AttachUserPolicy calls refreshIAMConfiguration(), it would use the
test's incomplete reload function instead of the real LoadS3ApiConfigurationFromCredentialManager().

Fixed by making the test's reloadConfigurationFunc also call
e.iam.LoadS3ApiConfigurationFromCredentialManager() so lookupByIdentityName()
sees the updated policy attachments.

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-25 12:31:04 -08:00
Chris Lu d5e71eb0d8 Revert "s3api: preserve Host header port in signature verification (#8434)"
This reverts commit 98d89ffad7.
2026-02-25 12:28:44 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f82ee08972 build(deps): bump github.com/cloudflare/circl from 1.6.1 to 1.6.3 (#8446)
Bumps [github.com/cloudflare/circl](https://github.com/cloudflare/circl) from 1.6.1 to 1.6.3.
- [Release notes](https://github.com/cloudflare/circl/releases)
- [Commits](https://github.com/cloudflare/circl/compare/v1.6.1...v1.6.3)

---
updated-dependencies:
- dependency-name: github.com/cloudflare/circl
  dependency-version: 1.6.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 12:23:15 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
96078bc87e build(deps): bump github.com/cloudflare/circl from 1.6.1 to 1.6.3 in /test/kafka (#8447)
build(deps): bump github.com/cloudflare/circl in /test/kafka

Bumps [github.com/cloudflare/circl](https://github.com/cloudflare/circl) from 1.6.1 to 1.6.3.
- [Release notes](https://github.com/cloudflare/circl/releases)
- [Commits](https://github.com/cloudflare/circl/compare/v1.6.1...v1.6.3)

---
updated-dependencies:
- dependency-name: github.com/cloudflare/circl
  dependency-version: 1.6.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 12:23:02 -08:00
Chris LuandGitHub 8c0c7248b3 Refresh IAM config after policy attachments (#8439)
* Refresh IAM cache after policy attachments

* error handling
2026-02-25 10:30:05 -08:00
Chris Lu e3decd2e3b go fmt 2026-02-25 10:25:44 -08:00
Chris Lu 7296f51f48 Merge branch 'master' of https://github.com/seaweedfs/seaweedfs 2026-02-25 10:25:31 -08:00
Chris Lu a3cb7fa8cc go fmt 2026-02-25 10:25:23 -08:00
0910252e31 feat: add statfile remote storage (#8443)
* feat: add statfile; add error for remote storage misses

* feat: statfile implementations for storage providers

* test: add unit tests for StatFile method across providers

Add comprehensive unit tests for the StatFile implementation covering:
- S3: interface compliance and error constant accessibility
- Azure: interface compliance, error constants, and field population
- GCS: interface compliance, error constants, error detection, and field population

Also fix variable shadowing issue in S3 and Azure StatFile implementations where
named return parameters were being shadowed by local variable declarations.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: address StatFile review feedback

- Use errors.New for ErrRemoteObjectNotFound sentinel
- Fix S3 HeadObject 404 detection to use awserr.Error code check
- Remove hollow field-population tests that tested nothing
- Remove redundant stdlib error detection tests
- Trim verbose doc comment on ErrRemoteObjectNotFound

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: address second round of StatFile review feedback

- Rename interface assertion tests to TestXxxRemoteStorageClientImplementsInterface
- Delegate readFileRemoteEntry to StatFile in all three providers
- Revert S3 404 detection to RequestFailure.StatusCode() check
- Fix double-slash in GCS error message format string
- Add storage type prefix to S3 error message for consistency

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: comments

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 10:24:06 -08:00
Chris LuandGitHub b565a0cc86 Adds volume.merge command with deduplication and disk-based backend (#8441)
* Enhance volume.merge command with deduplication and disk-based backend

* Fix copyVolume function call with correct argument order and missing bool parameter

* Revert "Fix copyVolume function call with correct argument order and missing bool parameter"

This reverts commit 7b4a190643.

* Fix critical issues: per-replica writable tracking, tail goroutine cancellation via done channel, and debug logging for allocation failures

* Optimize memory usage with watermark approach for duplicate detection

* Fix critical issues: swap copyVolume arguments, increase idle timeout, remove file double-close, use glog for logging

* Replace temporary file with in-memory buffer for needle blob serialization

* test(volume.merge): Add comprehensive unit and integration tests

Add 7 unit tests covering:
- Ordering by timestamp
- Cross-stream duplicate deduplication
- Empty stream handling
- Complex multi-stream deduplication
- Single stream passthrough
- Large needle ID support
- LastModified fallback when timestamp unavailable

Add 2 integration validation tests:
- TestMergeWorkflowValidation: Documents 9-stage merge workflow
- TestMergeEdgeCaseHandling: Validates 10 edge case handling

All tests passing (9/9)

* fix(volume.merge): Use time window for deduplication to handle clock skew

The same needle ID can have different timestamps on different servers due to
clock skew and replication lag. Needles with the same ID within a 5-second
time window are now treated as duplicates (same write with timestamp variance).

Key changes:
- Add mergeDeduplicationWindowNs constant (5 seconds)
- Replace exact timestamp matching with time window comparison
- Use windowInitialized flag to properly detect window transitions
- Add TestMergeNeedleStreamsTimeWindowDeduplication test

This ensures that replicated writes with slight timestamp differences are
properly deduplicated during merge, while separate updates to the same file
ID (outside the window) are preserved.

All tests passing (10/10)

* test: Add volume.merge integration tests with 5 comprehensive test cases

* test: integration tests for volume.merge command

* Fix integration tests: use TripleVolumeCluster for volume.merge testing

- Created new TripleVolumeCluster framework (cluster_triple.go) with 3 volume servers
- Rebuilt weed binary with volume.merge command compiled in
- Updated all 5 integration tests to use TripleVolumeCluster instead of DualVolumeCluster
- Tests now properly allocate volumes on 2 servers and let merge allocate on 3rd
- All 5 integration tests now pass:
  - TestVolumeMergeBasic
  - TestVolumeMergeReadonly
  - TestVolumeMergeRestore
  - TestVolumeMergeTailNeedles
  - TestVolumeMergeDivergentReplicas

* Refactor test framework: use parameterized server count instead of hardcoded

- Renamed TripleVolumeCluster to MultiVolumeCluster with serverCount parameter
- Replaced hardcoded volumePort0/1/2 with slices for flexible server count
- Updated StartTripleVolumeCluster as backward-compatible wrapper calling StartMultiVolumeCluster(t, profile, 3)
- Made directory creation, port allocation, and server startup loop-based
- Updated accessor methods (VolumeAdminAddress, VolumeGRPCAddress, etc.) to support any server count
- All 5 integration tests continue to pass with new parameterized cluster framework
- Enables future testing with 2, 4, 5+ volume servers by calling StartMultiVolumeCluster directly

* Consolidate cluster frameworks: StartDualVolumeCluster now uses MultiVolumeCluster

- Made DualVolumeCluster a type alias for MultiVolumeCluster
- Updated StartDualVolumeCluster to call StartMultiVolumeCluster(t, profile, 2)
- Removed duplicate code from cluster_dual.go (now just 17 lines)
- All existing tests using StartDualVolumeCluster continue to work without changes
- Backward compatible: existing code continues to use the old function signatures
- Added wrapper functions in cluster_multi.go for StartTripleVolumeCluster
- Enables unified cluster management across all test suites

* Address PR review comments: improve error handling and clean up code

- Replace parse error swallow with proper error return
- Log cleanup and restoration errors instead of silently discarding them
- Remove unused offset field from memoryBackendFile struct
- Fix WriteAt buffer truncation bug to preserve trailing bytes
- All unit tests passing (10/10)
- Code compiles successfully

* Fix PR review findings: test improvements and code quality

- Add timeout to runWeedShell to prevent hanging
- Add server 1 readonly status verification in tests
- Assert merge fails when replicas writable (not just log output)
- Replace sleep with polling for writable restoration check
- Fix WriteAt stale data snapshot bug in memoryBackendFile
- Fix startVolume error logging to show current server log
- Fix volumePubPorts double assignment in port allocation
- Rename test to reflect behavior: DoesNotDeduplicateAcrossWindows
- Fix misleading dedup window comment

Unit tests: 10/10 passing
Binary: Compiles successfully

* Fix test assumption: merge command marks volumes readonly automatically

TestVolumeMergeReadonly was expecting merge to fail on writable volumes, but the
merge command is designed to mark volumes readonly as part of its operation. Fixed
test to verify merge succeeds on writable volumes and properly restores writable
state afterward. Removed redundant Test 2 code that duplicated the new behavior.

* fmt

* Fix deduplication logic to correctly handle same-stream vs cross-stream duplicates

The dedup map previously used only NeedleId as key, causing same-stream
overwrites to be incorrectly skipped as duplicates. Changed to track which
stream first processed each needle ID in the current window:

- Cross-stream duplicates (same ID from different streams, within window) are skipped
- Same-stream duplicates (overwrites from same stream) are kept
- Map now stores: needleId -> streamIndex of first occurrence in window

Added TestMergeNeedleStreamsSameStreamDuplicates to verify same-stream
overwrites are preserved while cross-stream duplicates are skipped.

All unit tests passing (11/11)
Binary compiles successfully
2026-02-25 10:12:09 -08:00
Chris LuandGitHub da4edb5fe6 Fix live volume move tail timestamp (#8440)
* Improve move tail timestamp

* Add move tail timestamp integration test

* Simulate traffic during move
2026-02-24 20:07:26 -08:00
Chris LuandGitHub 27e763222a Fix inline user policy retrieval (#8437)
* Fix IAM inline user policy retrieval

* fmt

* Persist inline user policies to avoid loss on server restart

- Use s3ApiConfig.PutPolicies/GetPolicies for persistent storage instead of non-persistent global map
- Remove unused global policyDocuments map
- Update PutUserPolicy to store policies in persistent storage
- Update GetUserPolicy to read from persistent storage
- Update DeleteUserPolicy to clean up persistent storage
- Add mock IamS3ApiConfig for testing
- Improve test to verify policy statements are not merged or lost

* Fix inline policy key collision and action aggregation

* Improve error handling and optimize inline policy management

- GetUserPolicy: Propagate GetPolicies errors instead of silently falling through
- DeleteUserPolicy: Return error immediately on GetPolicies failure
- computeAggregatedActionsForUser: Add optional Policies parameter for I/O optimization
- PutUserPolicy: Reuse fetched policies to avoid redundant GetPolicies call
- Improve logging with clearer messages about best-effort aggregation
- Update test to use exact action string matching instead of substring checks

All 15 tests pass with no regressions.

* Add per-user policy index for O(1) lookup performance

- Extend Policies struct with InlinePolicies map[userName]map[policyName]
- Add getOrCreateUserPolicies() helper for safe user map management
- Update computeAggregatedActionsForUser to use direct user map access
- Update PutUserPolicy, GetUserPolicy, DeleteUserPolicy for new structure
- Performance: O(1) user lookups instead of O(all_policies) iteration
- Eliminates string prefix matching loop
- All tests pass; backward compatible with managed policies

* Fix DeleteUserPolicy to validate user existence before storage modification

Refactor DeleteUserPolicy handler to check user existence early:
- First iterate s3cfg.Identities to verify user exists
- Return NoSuchEntity error immediately if user not found
- Only then proceed with GetPolicies and policy deletion
- Capture reference to found identity for direct update

This ensures consistency: if user doesn't exist, storage is not modified.
Previously the code would delete from storage first and check identity
afterwards, potentially leaving orphaned policies.

Benefits:
- Fail-fast validation before storage operations
- No orphaned policies in storage if validation fails
- Atomic from logical perspective
- Direct identity reference eliminates redundant loop
- All error paths preserved and tested

All 15 tests pass; no functional changes to behavior.

* Fix GetUserPolicy to return NoSuchEntity when inline policy not found

When InlinePolicies[userName] exists but does not contain policyName,
the handler now immediately returns NoSuchEntity error instead of
falling through to the reconstruction logic.

Changes:
- Add else clause after userPolicies[policyName] lookup
- Return IamError(NoSuchEntityException, "policy not found") immediately
- Prevents incorrect fallback to reconstructing ident.Actions
- Ensures explicit error when policy explicitly doesn't exist

This improves error semantics:
- Policy exists in stored inline policies → return error (not reconstruct)
- Policy doesn't exist in stored inline policies → try reconstruction (backward compat)
- Storage error → return service failure error

All 15 tests pass; no behavioral changes to existing error or success paths.
2026-02-24 18:01:17 -08:00
427c975ff3 fix(plugin/worker): make VacuumHandler report MaxExecutionConcurrency from worker startup flag (#8435)
* fix(plugin/worker): make VacuumHandler report MaxExecutionConcurrency from worker startup flag

Previously, MaxExecutionConcurrency was hardcoded to 2 in VacuumHandler.Capability().
The scheduler's schedulerWorkerExecutionLimit() takes the minimum of the UI-configured
PerWorkerExecutionConcurrency and the worker-reported capability limit, so the hardcoded
value silently capped each worker to 2 concurrent vacuum executions regardless of the
--max-execute flag passed at worker startup.

Pass maxExecutionConcurrency into NewVacuumHandler() and wire it through
buildPluginWorkerHandler/buildPluginWorkerHandlers so the capability reflects the actual
worker configuration. The default falls back to 2 when the value is unset or zero.

* Update weed/command/worker_runtime.go

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

---------

Co-authored-by: Anton Ustyugov <anton@devops>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-24 15:13:00 -08:00
Chris Lu ce4940b441 fix filer link on dashboard 2026-02-24 14:21:27 -08:00
b4c7d42a06 fix(admin): release mutex before disk I/O in maintenance queue; remove per-request LoadAllTaskStates (#8433)
* fix(admin): release mutex before disk I/O in maintenance queue

saveTaskState performs synchronous BoltDB writes. Calling it while
holding mq.mutex.Lock() in AddTask, GetNextTask, and CompleteTask
blocks all readers (GetTasks via RLock) for the full disk write
duration on every task state change.

During a maintenance scan AddTasksFromResults calls AddTask for every
volume — potentially hundreds of times — meaning the write lock is
held almost continuously. The HTTP handler for /maintenance calls
GetTasks which blocks on RLock, exceeding the 30s timeout and
returning 408 to the browser.

Fix: update in-memory state (mq.tasks, mq.pendingTasks) under the
lock as before, then unlock before calling saveTaskState. In-memory
state is the authoritative source; persistence is crash-recovery only
and does not require lock protection during the write.

* fix(admin): add mutex to ConfigPersistence to synchronize tasks/ filesystem ops

saveTaskState is now called outside mq.mutex, meaning SaveTaskState,
LoadAllTaskStates, DeleteTaskState, and CleanupCompletedTasks can be
invoked concurrently from multiple goroutines. ConfigPersistence had no
internal synchronization, creating races on the tasks/ directory:

- concurrent os.WriteFile + os.ReadFile on the same .pb file could
  yield a partial read and unmarshal error
- LoadAllTaskStates (ReadDir + per-file ReadFile) could see a
  directory entry for a file being written or deleted concurrently
- CleanupCompletedTasks (LoadAllTaskStates + DeleteTaskState) could
  race with SaveTaskState on the same file

Fix: add tasksMu sync.Mutex to ConfigPersistence, acquired at the top
of SaveTaskState, LoadTaskState, LoadAllTaskStates, DeleteTaskState,
and CleanupCompletedTasks. Extract private Locked helpers so that
CleanupCompletedTasks (which holds tasksMu) can call them internally
without deadlocking.

---------

Co-authored-by: Anton Ustyugov <anton@devops>
2026-02-24 13:41:41 -08:00
Chris Lu cba69f4593 Update layout_templ.go 2026-02-24 13:22:12 -08:00
Chris Lu 91f59e73e5 close ports 2026-02-24 13:20:21 -08:00
Chris LuandGitHub 98d89ffad7 s3api: preserve Host header port in signature verification (#8434)
Avoid stripping default ports (80/443) from the Host header in extractHostHeader.
This fixes SignatureDoesNotMatch errors when SeaweedFS is accessed via a proxy
(like Kong Ingress) that explicitly includes the port in the Host header or
X-Forwarded-Host, which S3 clients sign.

Also cleaned up unused variables and logic after refactoring.
2026-02-24 13:09:40 -08:00
Peter DoddandGitHub f4af1cc0ba feat(helm): annotations for service account (#8429) 2026-02-24 07:35:13 -08:00
Xiao WeiandGitHub 9fa95dd2c6 fix: unload leveldb not take effect (#8431) 2026-02-24 07:32:13 -08:00
Plamen NikolovandGitHub ff84ef880d fix(s3api): make ListObjectsV1 namespaced and prevent marker-echo pagination loops (#8409)
* fix(s3api): make ListObjectsV1 namespaced and stop marker-echo pagination loops

* test(s3api): harden marker-echo coverage and align V1 encoding tag

* test(s3api): cover encoded marker matching and trim redundant setup

* refactor(s3api): tighten V1 list helper visibility and test mock docs
2026-02-23 23:45:08 -08:00
Chris LuandGitHub 2d65d7f499 Embed role policies in AssumeRole STS tokens (#8421)
* Embed role policies in AssumeRole STS tokens

* Log STS policy lookup failures

* Use IAMManager provider

* Guard policy embedding role lookup
2026-02-23 22:59:53 -08:00
Chris LuandGitHub 3f58e3bf8f Use master shard sizes for EC volumes (#8423)
* Use master shard sizes for EC volumes

* Remove EC volume shard size fallback

* Remove unused EC dash imports
2026-02-23 21:59:09 -08:00
Justin CichraandGitHub 016391530b fix: plural clientType on ListExistingPeerUpdates (#8422) 2026-02-23 20:19:12 -08:00
Chris LuandGitHub 8d59ef41d5 Admin UI: replace gin with mux (#8420)
* Replace admin gin router with mux

* Update layout_templ.go

* Harden admin handlers

* Add login CSRF handling

* Fix filer copy naming conflict

* address comments

* address comments
2026-02-23 19:11:17 -08:00
Chris LuandGitHub e596542295 Move SQL engine and PostgreSQL server to their own binaries (#8417)
* Drop SQL engine and PostgreSQL server

* Split SQL tooling into weed-db and weed-sql

* move

* fix building
2026-02-23 16:27:08 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
61db4d0966 build(deps): bump github.com/rclone/rclone from 1.72.1 to 1.73.1 (#8416)
Bumps [github.com/rclone/rclone](https://github.com/rclone/rclone) from 1.72.1 to 1.73.1.
- [Release notes](https://github.com/rclone/rclone/releases)
- [Changelog](https://github.com/rclone/rclone/blob/master/RELEASE.md)
- [Commits](https://github.com/rclone/rclone/compare/v1.72.1...v1.73.1)

---
updated-dependencies:
- dependency-name: github.com/rclone/rclone
  dependency-version: 1.73.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 13:42:12 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
74de694447 build(deps): bump modernc.org/sqlite from 1.44.3 to 1.46.1 (#8415)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.44.3 to 1.46.1.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.44.3...v1.46.1)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.46.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 13:42:05 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c5e8e4f049 build(deps): bump actions/dependency-review-action from 4.8.2 to 4.8.3 (#8414)
Bumps [actions/dependency-review-action](https://github.com/actions/dependency-review-action) from 4.8.2 to 4.8.3.
- [Release notes](https://github.com/actions/dependency-review-action/releases)
- [Commits](https://github.com/actions/dependency-review-action/compare/3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261...05fe4576374b728f0c523d6a13d64c25081e0803)

---
updated-dependencies:
- dependency-name: actions/dependency-review-action
  dependency-version: 4.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 13:41:57 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c96b0913ed build(deps): bump golang.org/x/image from 0.35.0 to 0.36.0 (#8413)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.35.0 to 0.36.0.
- [Commits](https://github.com/golang/image/compare/v0.35.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 13:41:49 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b033823611 build(deps): bump helm/kind-action from 1.13.0 to 1.14.0 (#8412)
Bumps [helm/kind-action](https://github.com/helm/kind-action) from 1.13.0 to 1.14.0.
- [Release notes](https://github.com/helm/kind-action/releases)
- [Commits](https://github.com/helm/kind-action/compare/v1.13.0...v1.14.0)

---
updated-dependencies:
- dependency-name: helm/kind-action
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 13:41:40 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3044b51e7c build(deps): bump github.com/pierrec/lz4/v4 from 4.1.22 to 4.1.25 (#8411)
Bumps [github.com/pierrec/lz4/v4](https://github.com/pierrec/lz4) from 4.1.22 to 4.1.25.
- [Release notes](https://github.com/pierrec/lz4/releases)
- [Commits](https://github.com/pierrec/lz4/compare/v4.1.22...v4.1.25)

---
updated-dependencies:
- dependency-name: github.com/pierrec/lz4/v4
  dependency-version: 4.1.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 13:41:25 -08:00
131 changed files with 10313 additions and 2965 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ jobs:
- name: 'Checkout Repository'
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
- name: 'Dependency Review'
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261
uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803
+1 -1
View File
@@ -119,7 +119,7 @@ jobs:
echo "✅ All template rendering tests passed!"
- name: Create kind cluster
uses: helm/kind-action@v1.13.0
uses: helm/kind-action@v1.14.0
- name: Run chart-testing (install)
run: ct install --target-branch ${{ github.event.repository.default_branch }} --all --chart-dirs k8s/charts
+41
View File
@@ -0,0 +1,41 @@
name: "Plugin Worker Integration Tests"
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
permissions:
contents: read
jobs:
plugin-worker:
name: "Plugin Worker: ${{ matrix.worker }}"
runs-on: ubuntu-22.04
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- worker: erasure_coding
path: test/plugin_workers/erasure_coding
- worker: ec_repair
path: test/plugin_workers/ec_repair
- worker: vacuum
path: test/plugin_workers/vacuum
- worker: volume_balance
path: test/plugin_workers/volume_balance
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v6
with:
go-version: ^1.26
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v6
- name: Run plugin worker tests
run: go test -v ./${{ matrix.path }}
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: test admin-generate admin-build admin-clean admin-dev admin-run admin-test admin-fmt admin-help
.PHONY: test admin-generate admin-build admin-clean admin-dev admin-run admin-test admin-fmt admin-help weed-commands
BINARY = weed
ADMIN_DIR = weed/admin
@@ -11,6 +11,9 @@ all: install
install: admin-generate
cd weed; go install
weed-commands:
cd weed && $(MAKE) weed-db weed-sql
warp_install:
go install github.com/minio/warp@v0.7.6
+116 -101
View File
@@ -1,10 +1,11 @@
package command
package main
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"os"
"os/signal"
"strings"
@@ -13,43 +14,12 @@ import (
"github.com/seaweedfs/seaweedfs/weed/server/postgres"
"github.com/seaweedfs/seaweedfs/weed/util"
flag "github.com/seaweedfs/seaweedfs/weed/util/fla9"
)
var (
dbOptions DBOptions
)
const usageLine = "weed-db -port=5432 -master=<master_server>"
type DBOptions struct {
host *string
port *int
masterAddr *string
authMethod *string
users *string
database *string
maxConns *int
idleTimeout *string
tlsCert *string
tlsKey *string
}
func init() {
cmdDB.Run = runDB // break init cycle
dbOptions.host = cmdDB.Flag.String("host", "localhost", "Database server host")
dbOptions.port = cmdDB.Flag.Int("port", 5432, "Database server port")
dbOptions.masterAddr = cmdDB.Flag.String("master", "localhost:9333", "SeaweedFS master server address")
dbOptions.authMethod = cmdDB.Flag.String("auth", "trust", "Authentication method: trust, password, md5")
dbOptions.users = cmdDB.Flag.String("users", "", "User credentials for auth (JSON format '{\"user1\":\"pass1\",\"user2\":\"pass2\"}' or file '@/path/to/users.json')")
dbOptions.database = cmdDB.Flag.String("database", "default", "Default database name")
dbOptions.maxConns = cmdDB.Flag.Int("max-connections", 100, "Maximum concurrent connections per server")
dbOptions.idleTimeout = cmdDB.Flag.String("idle-timeout", "1h", "Connection idle timeout")
dbOptions.tlsCert = cmdDB.Flag.String("tls-cert", "", "TLS certificate file path")
dbOptions.tlsKey = cmdDB.Flag.String("tls-key", "", "TLS private key file path")
}
var cmdDB = &Command{
UsageLine: "db -port=5432 -master=<master_server>",
Short: "start a PostgreSQL-compatible database server for SQL queries",
Long: `Start a PostgreSQL wire protocol compatible database server that provides SQL query access to SeaweedFS.
const longHelp = `Start a PostgreSQL wire protocol compatible database server that provides SQL query access to SeaweedFS.
This database server enables any PostgreSQL client, tool, or application to connect to SeaweedFS
and execute SQL queries against MQ topics. It implements the PostgreSQL wire protocol for maximum
@@ -58,25 +28,25 @@ compatibility with the existing PostgreSQL ecosystem.
Examples:
# Start database server on default port 5432
weed db
weed-db
# Start with MD5 authentication using JSON format (recommended)
weed db -auth=md5 -users='{"admin":"secret","readonly":"view123"}'
weed-db -auth=md5 -users='{"admin":"secret","readonly":"view123"}'
# Start with complex passwords using JSON format
weed db -auth=md5 -users='{"admin":"pass;with;semicolons","user":"password:with:colons"}'
weed-db -auth=md5 -users='{"admin":"pass;with;semicolons","user":"password:with:colons"}'
# Start with credentials from JSON file (most secure)
weed db -auth=md5 -users="@/etc/seaweedfs/users.json"
weed-db -auth=md5 -users="@/etc/seaweedfs/users.json"
# Start with custom port and master
weed db -port=5433 -master=master1:9333
weed-db -port=5433 -master=master1:9333
# Allow connections from any host
weed db -host=0.0.0.0 -port=5432
weed-db -host=0.0.0.0 -port=5432
# Start with TLS encryption
weed db -tls-cert=server.crt -tls-key=server.key
weed-db -tls-cert=server.crt -tls-key=server.key
Client Connection Examples:
@@ -95,7 +65,7 @@ Programming Language Examples:
# Python (psycopg2)
import psycopg2
conn = psycopg2.connect(
host="localhost", port=5432,
host="localhost", port=5432,
user="seaweedfs", database="default"
)
@@ -116,7 +86,7 @@ Supported SQL Operations:
- SELECT queries on MQ topics
- DESCRIBE/DESC table_name commands
- EXPLAIN query execution plans
- SHOW DATABASES/TABLES commands
- SHOW DATABASES/TABLES commands
- Aggregation functions (COUNT, SUM, AVG, MIN, MAX)
- WHERE clauses with filtering
- System columns (_timestamp_ns, _key, _source)
@@ -149,50 +119,95 @@ Performance Features:
- PostgreSQL wire protocol
- Query result streaming
`,
`
type Options struct {
Host string
Port int
MasterAddr string
AuthMethod string
Users string
Database string
MaxConns int
IdleTimeout string
TLSCert string
TLSKey string
}
func runDB(cmd *Command, args []string) bool {
// Run executes the weed-db CLI.
func Run(args []string) int {
fs := flag.NewFlagSet("weed-db", flag.ContinueOnError)
usageWriter := io.Writer(os.Stderr)
fs.SetOutput(usageWriter)
var opts Options
fs.StringVar(&opts.Host, "host", "localhost", "Database server host")
fs.IntVar(&opts.Port, "port", 5432, "Database server port")
fs.StringVar(&opts.MasterAddr, "master", "localhost:9333", "SeaweedFS master server address")
fs.StringVar(&opts.AuthMethod, "auth", "trust", "Authentication method: trust, password, md5")
fs.StringVar(&opts.Users, "users", "", "User credentials for auth (JSON format '{\"user1\":\"pass1\",\"user2\":\"pass2\"}' or file '@/path/to/users.json')")
fs.StringVar(&opts.Database, "database", "default", "Default database name")
fs.IntVar(&opts.MaxConns, "max-connections", 100, "Maximum concurrent connections per server")
fs.StringVar(&opts.IdleTimeout, "idle-timeout", "1h", "Connection idle timeout")
fs.StringVar(&opts.TLSCert, "tls-cert", "", "TLS certificate file path")
fs.StringVar(&opts.TLSKey, "tls-key", "", "TLS private key file path")
fs.Usage = func() {
fmt.Fprintf(usageWriter, "Usage: %s\n\n%s\n", usageLine, longHelp)
fmt.Fprintln(usageWriter, "Default Parameters:")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return 2
}
if !runWithOptions(&opts) {
return 1
}
return 0
}
func runWithOptions(opts *Options) bool {
util.LoadConfiguration("security", false)
// Validate options
if *dbOptions.masterAddr == "" {
// Validate options.
if opts.MasterAddr == "" {
fmt.Fprintf(os.Stderr, "Error: master address is required\n")
return false
}
// Parse authentication method
authMethod, err := parseAuthMethod(*dbOptions.authMethod)
// Parse authentication method.
authMethod, err := parseAuthMethod(opts.AuthMethod)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
return false
}
// Parse user credentials
users, err := parseUsers(*dbOptions.users, authMethod)
// Parse user credentials.
users, err := parseUsers(opts.Users, authMethod)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
return false
}
// Parse idle timeout
idleTimeout, err := time.ParseDuration(*dbOptions.idleTimeout)
// Parse idle timeout.
idleTimeout, err := time.ParseDuration(opts.IdleTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing idle timeout: %v\n", err)
return false
}
// Validate port number
if err := validatePortNumber(*dbOptions.port); err != nil {
// Validate port number.
if err := validatePortNumber(opts.Port); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
return false
}
// Setup TLS if requested
// Setup TLS if requested.
var tlsConfig *tls.Config
if *dbOptions.tlsCert != "" && *dbOptions.tlsKey != "" {
cert, err := tls.LoadX509KeyPair(*dbOptions.tlsCert, *dbOptions.tlsKey)
if opts.TLSCert != "" && opts.TLSKey != "" {
cert, err := tls.LoadX509KeyPair(opts.TLSCert, opts.TLSKey)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading TLS certificates: %v\n", err)
return false
@@ -202,34 +217,34 @@ func runDB(cmd *Command, args []string) bool {
}
}
// Create server configuration
// Create server configuration.
config := &postgres.PostgreSQLServerConfig{
Host: *dbOptions.host,
Port: *dbOptions.port,
Host: opts.Host,
Port: opts.Port,
AuthMethod: authMethod,
Users: users,
Database: *dbOptions.database,
MaxConns: *dbOptions.maxConns,
Database: opts.Database,
MaxConns: opts.MaxConns,
IdleTimeout: idleTimeout,
TLSConfig: tlsConfig,
}
// Create database server
dbServer, err := postgres.NewPostgreSQLServer(config, *dbOptions.masterAddr)
// Create database server.
dbServer, err := postgres.NewPostgreSQLServer(config, opts.MasterAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating database server: %v\n", err)
return false
}
// Print startup information
// Print startup information.
fmt.Printf("Starting SeaweedFS Database Server...\n")
fmt.Printf("Host: %s\n", *dbOptions.host)
fmt.Printf("Port: %d\n", *dbOptions.port)
fmt.Printf("Master: %s\n", *dbOptions.masterAddr)
fmt.Printf("Database: %s\n", *dbOptions.database)
fmt.Printf("Auth Method: %s\n", *dbOptions.authMethod)
fmt.Printf("Max Connections: %d\n", *dbOptions.maxConns)
fmt.Printf("Idle Timeout: %s\n", *dbOptions.idleTimeout)
fmt.Printf("Host: %s\n", opts.Host)
fmt.Printf("Port: %d\n", opts.Port)
fmt.Printf("Master: %s\n", opts.MasterAddr)
fmt.Printf("Database: %s\n", opts.Database)
fmt.Printf("Auth Method: %s\n", opts.AuthMethod)
fmt.Printf("Max Connections: %d\n", opts.MaxConns)
fmt.Printf("Idle Timeout: %s\n", opts.IdleTimeout)
if tlsConfig != nil {
fmt.Printf("TLS: Enabled\n")
} else {
@@ -240,15 +255,15 @@ func runDB(cmd *Command, args []string) bool {
}
fmt.Printf("\nDatabase Connection Examples:\n")
fmt.Printf(" psql -h %s -p %d -U seaweedfs -d %s\n", *dbOptions.host, *dbOptions.port, *dbOptions.database)
fmt.Printf(" psql -h %s -p %d -U seaweedfs -d %s\n", opts.Host, opts.Port, opts.Database)
if len(users) > 0 {
// Show first user as example
// Show first user as example.
for username := range users {
fmt.Printf(" psql -h %s -p %d -U %s -d %s\n", *dbOptions.host, *dbOptions.port, username, *dbOptions.database)
fmt.Printf(" psql -h %s -p %d -U %s -d %s\n", opts.Host, opts.Port, username, opts.Database)
break
}
}
fmt.Printf(" postgresql://%s:%d/%s\n", *dbOptions.host, *dbOptions.port, *dbOptions.database)
fmt.Printf(" postgresql://%s:%d/%s\n", opts.Host, opts.Port, opts.Database)
fmt.Printf("\nSupported Operations:\n")
fmt.Printf(" - SELECT queries on MQ topics\n")
@@ -261,26 +276,26 @@ func runDB(cmd *Command, args []string) bool {
fmt.Printf("\nReady for database connections!\n\n")
// Start the server
// Start the server.
err = dbServer.Start()
if err != nil {
fmt.Fprintf(os.Stderr, "Error starting database server: %v\n", err)
return false
}
// Set up signal handling for graceful shutdown
// Set up signal handling for graceful shutdown.
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Wait for shutdown signal
// Wait for shutdown signal.
<-sigChan
fmt.Printf("\nReceived shutdown signal, stopping database server...\n")
// Create context with timeout for graceful shutdown
// Create context with timeout for graceful shutdown.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Stop the server with timeout
// Stop the server with timeout.
done := make(chan error, 1)
go func() {
done <- dbServer.Stop()
@@ -301,7 +316,7 @@ func runDB(cmd *Command, args []string) bool {
return true
}
// parseAuthMethod parses the authentication method string
// parseAuthMethod parses the authentication method string.
func parseAuthMethod(method string) (postgres.AuthMethod, error) {
switch strings.ToLower(method) {
case "trust":
@@ -315,7 +330,7 @@ func parseAuthMethod(method string) (postgres.AuthMethod, error) {
}
}
// parseUsers parses the user credentials string with support for secure formats only
// parseUsers parses the user credentials string with support for secure formats only.
// Supported formats:
// 1. JSON format: {"username":"password","username2":"password2"}
// 2. File format: /path/to/users.json or @/path/to/users.json
@@ -323,41 +338,41 @@ func parseUsers(usersStr string, authMethod postgres.AuthMethod) (map[string]str
users := make(map[string]string)
if usersStr == "" {
// No users specified
// No users specified.
if authMethod != postgres.AuthTrust {
return nil, fmt.Errorf("users must be specified when auth method is not 'trust'")
}
return users, nil
}
// Trim whitespace
// Trim whitespace.
usersStr = strings.TrimSpace(usersStr)
// Determine format and parse accordingly
// Determine format and parse accordingly.
if strings.HasPrefix(usersStr, "{") && strings.HasSuffix(usersStr, "}") {
// JSON format
// JSON format.
return parseUsersJSON(usersStr, authMethod)
}
// Check if it's a file path (with or without @ prefix) before declaring invalid format
// Check if it's a file path (with or without @ prefix) before declaring invalid format.
filePath := strings.TrimPrefix(usersStr, "@")
if _, err := os.Stat(filePath); err == nil {
// File format
return parseUsersFile(usersStr, authMethod) // Pass original string to preserve @ handling
// File format.
return parseUsersFile(usersStr, authMethod) // Pass original string to preserve @ handling.
}
// Invalid format
// Invalid format.
return nil, fmt.Errorf("invalid user credentials format. Use JSON format '{\"user\":\"pass\"}' or file format '@/path/to/users.json' or 'path/to/users.json'. Legacy semicolon-separated format is no longer supported")
}
// parseUsersJSON parses user credentials from JSON format
// parseUsersJSON parses user credentials from JSON format.
func parseUsersJSON(jsonStr string, authMethod postgres.AuthMethod) (map[string]string, error) {
var users map[string]string
if err := json.Unmarshal([]byte(jsonStr), &users); err != nil {
return nil, fmt.Errorf("invalid JSON format for users: %v", err)
}
// Validate users
// Validate users.
for username, password := range users {
if username == "" {
return nil, fmt.Errorf("empty username in JSON user specification")
@@ -370,12 +385,12 @@ func parseUsersJSON(jsonStr string, authMethod postgres.AuthMethod) (map[string]
return users, nil
}
// parseUsersFile parses user credentials from a JSON file
// parseUsersFile parses user credentials from a JSON file.
func parseUsersFile(filePath string, authMethod postgres.AuthMethod) (map[string]string, error) {
// Remove @ prefix if present
// Remove @ prefix if present.
filePath = strings.TrimPrefix(filePath, "@")
// Read file content
// Read file content.
content, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read users file '%s': %v", filePath, err)
@@ -383,16 +398,16 @@ func parseUsersFile(filePath string, authMethod postgres.AuthMethod) (map[string
contentStr := strings.TrimSpace(string(content))
// File must contain JSON format
// File must contain JSON format.
if !strings.HasPrefix(contentStr, "{") || !strings.HasSuffix(contentStr, "}") {
return nil, fmt.Errorf("users file '%s' must contain JSON format: {\"user\":\"pass\"}. Legacy formats are no longer supported", filePath)
}
// Parse as JSON
// Parse as JSON.
return parseUsersJSON(contentStr, authMethod)
}
// validatePortNumber validates that the port number is reasonable
// validatePortNumber validates that the port number is reasonable.
func validatePortNumber(port int) error {
if port < 1 || port > 65535 {
return fmt.Errorf("port number must be between 1 and 65535, got %d", port)
+7
View File
@@ -0,0 +1,7 @@
package main
import "os"
func main() {
os.Exit(Run(os.Args[1:]))
}
+7
View File
@@ -0,0 +1,7 @@
package main
import "os"
func main() {
os.Exit(Run(os.Args[1:]))
}
+134 -107
View File
@@ -1,4 +1,4 @@
package command
package main
import (
"context"
@@ -13,28 +13,24 @@ import (
"github.com/peterh/liner"
"github.com/seaweedfs/seaweedfs/weed/query/engine"
flag "github.com/seaweedfs/seaweedfs/weed/util/fla9"
"github.com/seaweedfs/seaweedfs/weed/util/grace"
"github.com/seaweedfs/seaweedfs/weed/util/sqlutil"
)
func init() {
cmdSql.Run = runSql
}
const usageLine = "weed-sql [-master=localhost:9333] [-interactive] [-file=query.sql] [-output=table|json|csv] [-database=dbname] [-query=\"SQL\"]"
var cmdSql = &Command{
UsageLine: "sql [-master=localhost:9333] [-interactive] [-file=query.sql] [-output=table|json|csv] [-database=dbname] [-query=\"SQL\"]",
Short: "advanced SQL query interface for SeaweedFS MQ topics with multiple execution modes",
Long: `Enhanced SQL interface for SeaweedFS Message Queue topics with multiple execution modes.
const longHelp = `Enhanced SQL interface for SeaweedFS Message Queue topics with multiple execution modes.
Execution Modes:
- Interactive shell (default): weed sql -interactive
- Single query: weed sql -query "SELECT * FROM user_events"
- Batch from file: weed sql -file queries.sql
- Context switching: weed sql -database analytics -interactive
- Interactive shell (default): weed-sql -interactive
- Single query: weed-sql -query "SELECT * FROM user_events"
- Batch from file: weed-sql -file queries.sql
- Context switching: weed-sql -database analytics -interactive
Output Formats:
- table: ASCII table format (default for interactive)
- json: JSON format (default for non-interactive)
- json: JSON format (default for non-interactive)
- csv: Comma-separated values
Features:
@@ -45,24 +41,23 @@ Features:
- Database context switching
Examples:
weed sql -interactive
weed sql -query "SHOW DATABASES" -output json
weed sql -file batch_queries.sql -output csv
weed sql -database analytics -query "SELECT COUNT(*) FROM metrics"
weed sql -master broker1:9333 -interactive
`,
weed-sql -interactive
weed-sql -query "SHOW DATABASES" -output json
weed-sql -file batch_queries.sql -output csv
weed-sql -database analytics -query "SELECT COUNT(*) FROM metrics"
weed-sql -master broker1:9333 -interactive
`
type Options struct {
Master string
Interactive bool
File string
Output string
Database string
Query string
}
var (
sqlMaster = cmdSql.Flag.String("master", "localhost:9333", "SeaweedFS master server HTTP address")
sqlInteractive = cmdSql.Flag.Bool("interactive", false, "start interactive shell mode")
sqlFile = cmdSql.Flag.String("file", "", "execute SQL queries from file")
sqlOutput = cmdSql.Flag.String("output", "", "output format: table, json, csv (auto-detected if not specified)")
sqlDatabase = cmdSql.Flag.String("database", "", "default database context")
sqlQuery = cmdSql.Flag.String("query", "", "execute single SQL query")
)
// OutputFormat represents different output formatting options
// OutputFormat represents different output formatting options.
type OutputFormat string
const (
@@ -71,50 +66,82 @@ const (
OutputCSV OutputFormat = "csv"
)
// SQLContext holds the execution context for SQL operations
// SQLContext holds the execution context for SQL operations.
type SQLContext struct {
engine *engine.SQLEngine
currentDatabase string
outputFormat OutputFormat
interactive bool
master string
}
func runSql(command *Command, args []string) bool {
// Initialize SQL engine with master address for service discovery
sqlEngine := engine.NewSQLEngine(*sqlMaster)
// Run executes the weed-sql CLI.
func Run(args []string) int {
fs := flag.NewFlagSet("weed-sql", flag.ContinueOnError)
usageWriter := io.Writer(os.Stderr)
fs.SetOutput(usageWriter)
// Determine execution mode and output format
interactive := *sqlInteractive || (*sqlQuery == "" && *sqlFile == "")
outputFormat := determineOutputFormat(*sqlOutput, interactive)
var opts Options
fs.StringVar(&opts.Master, "master", "localhost:9333", "SeaweedFS master server HTTP address")
fs.BoolVar(&opts.Interactive, "interactive", false, "start interactive shell mode")
fs.StringVar(&opts.File, "file", "", "execute SQL queries from file")
fs.StringVar(&opts.Output, "output", "", "output format: table, json, csv (auto-detected if not specified)")
fs.StringVar(&opts.Database, "database", "", "default database context")
fs.StringVar(&opts.Query, "query", "", "execute single SQL query")
// Create SQL context
fs.Usage = func() {
fmt.Fprintf(usageWriter, "Usage: %s\n\n%s\n", usageLine, longHelp)
fmt.Fprintln(usageWriter, "Default Parameters:")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return 2
}
if !runWithOptions(&opts) {
return 1
}
return 0
}
func runWithOptions(opts *Options) bool {
// Initialize SQL engine with master address for service discovery.
sqlEngine := engine.NewSQLEngine(opts.Master)
// Determine execution mode and output format.
interactive := opts.Interactive || (opts.Query == "" && opts.File == "")
outputFormat := determineOutputFormat(opts.Output, interactive)
// Create SQL context.
ctx := &SQLContext{
engine: sqlEngine,
currentDatabase: *sqlDatabase,
currentDatabase: opts.Database,
outputFormat: outputFormat,
interactive: interactive,
master: opts.Master,
}
// Set current database in SQL engine if specified via command line
if *sqlDatabase != "" {
ctx.engine.GetCatalog().SetCurrentDatabase(*sqlDatabase)
// Set current database in SQL engine if specified via command line.
if opts.Database != "" {
ctx.engine.GetCatalog().SetCurrentDatabase(opts.Database)
}
// Execute based on mode
// Execute based on mode.
switch {
case *sqlQuery != "":
// Single query mode
return executeSingleQuery(ctx, *sqlQuery)
case *sqlFile != "":
// Batch file mode
return executeFileQueries(ctx, *sqlFile)
case opts.Query != "":
// Single query mode.
return executeSingleQuery(ctx, opts.Query)
case opts.File != "":
// Batch file mode.
return executeFileQueries(ctx, opts.File)
default:
// Interactive mode
// Interactive mode.
return runInteractiveShell(ctx)
}
}
// determineOutputFormat selects the appropriate output format
// determineOutputFormat selects the appropriate output format.
func determineOutputFormat(specified string, interactive bool) OutputFormat {
switch strings.ToLower(specified) {
case "table":
@@ -124,7 +151,7 @@ func determineOutputFormat(specified string, interactive bool) OutputFormat {
case "csv":
return OutputCSV
default:
// Auto-detect based on mode
// Auto-detect based on mode.
if interactive {
return OutputTable
}
@@ -132,18 +159,18 @@ func determineOutputFormat(specified string, interactive bool) OutputFormat {
}
}
// executeSingleQuery executes a single query and outputs the result
// executeSingleQuery executes a single query and outputs the result.
func executeSingleQuery(ctx *SQLContext, query string) bool {
if ctx.outputFormat != OutputTable {
// Suppress banner for non-interactive output
// Suppress banner for non-interactive output.
return executeAndDisplay(ctx, query, false)
}
fmt.Printf("Executing query against %s...\n", *sqlMaster)
fmt.Printf("Executing query against %s...\n", ctx.master)
return executeAndDisplay(ctx, query, true)
}
// executeFileQueries processes SQL queries from a file
// executeFileQueries processes SQL queries from a file.
func executeFileQueries(ctx *SQLContext, filename string) bool {
content, err := os.ReadFile(filename)
if err != nil {
@@ -152,10 +179,10 @@ func executeFileQueries(ctx *SQLContext, filename string) bool {
}
if ctx.outputFormat == OutputTable && ctx.interactive {
fmt.Printf("Executing queries from %s against %s...\n", filename, *sqlMaster)
fmt.Printf("Executing queries from %s against %s...\n", filename, ctx.master)
}
// Split file content into individual queries (robust approach)
// Split file content into individual queries (robust approach).
queries := sqlutil.SplitStatements(string(content))
for i, query := range queries {
@@ -176,11 +203,11 @@ func executeFileQueries(ctx *SQLContext, filename string) bool {
return true
}
// runInteractiveShell starts the enhanced interactive shell with readline support
// runInteractiveShell starts the enhanced interactive shell with readline support.
func runInteractiveShell(ctx *SQLContext) bool {
fmt.Println("SeaweedFS Enhanced SQL Interface")
fmt.Println("Type 'help;' for help, 'exit;' to quit")
fmt.Printf("Connected to master: %s\n", *sqlMaster)
fmt.Printf("Connected to master: %s\n", ctx.master)
if ctx.currentDatabase != "" {
fmt.Printf("Current database: %s\n", ctx.currentDatabase)
}
@@ -188,24 +215,24 @@ func runInteractiveShell(ctx *SQLContext) bool {
fmt.Println("Use up/down arrows for command history")
fmt.Println()
// Initialize liner for readline functionality
// Initialize liner for readline functionality.
line := liner.NewLiner()
defer line.Close()
// Handle Ctrl+C gracefully
// Handle Ctrl+C gracefully.
line.SetCtrlCAborts(true)
grace.OnInterrupt(func() {
line.Close()
})
// Load command history
// Load command history.
historyPath := path.Join(os.TempDir(), "weed-sql-history")
if f, err := os.Open(historyPath); err == nil {
line.ReadHistory(f)
f.Close()
}
// Save history on exit
// Save history on exit.
defer func() {
if f, err := os.Create(historyPath); err == nil {
line.WriteHistory(f)
@@ -216,7 +243,7 @@ func runInteractiveShell(ctx *SQLContext) bool {
var queryBuffer strings.Builder
for {
// Show prompt with current database context
// Show prompt with current database context.
var prompt string
if queryBuffer.Len() == 0 {
if ctx.currentDatabase != "" {
@@ -225,10 +252,10 @@ func runInteractiveShell(ctx *SQLContext) bool {
prompt = "seaweedfs> "
}
} else {
prompt = " -> " // Continuation prompt
prompt = " -> " // Continuation prompt.
}
// Read line with readline support
// Read line with readline support.
input, err := line.Prompt(prompt)
if err != nil {
if err == liner.ErrPromptAborted {
@@ -244,30 +271,30 @@ func runInteractiveShell(ctx *SQLContext) bool {
lineStr := strings.TrimSpace(input)
// Handle empty lines
// Handle empty lines.
if lineStr == "" {
continue
}
// Accumulate lines in query buffer
// Accumulate lines in query buffer.
if queryBuffer.Len() > 0 {
queryBuffer.WriteString(" ")
}
queryBuffer.WriteString(lineStr)
// Check if we have a complete statement (ends with semicolon or special command)
// Check if we have a complete statement (ends with semicolon or special command).
fullQuery := strings.TrimSpace(queryBuffer.String())
isComplete := strings.HasSuffix(lineStr, ";") ||
isSpecialCommand(fullQuery)
if !isComplete {
continue // Continue reading more lines
continue // Continue reading more lines.
}
// Add completed command to history
// Add completed command to history.
line.AppendHistory(fullQuery)
// Handle special commands (with or without semicolon)
// Handle special commands (with or without semicolon).
cleanQuery := strings.TrimSuffix(fullQuery, ";")
cleanQuery = strings.TrimSpace(cleanQuery)
@@ -282,19 +309,19 @@ func runInteractiveShell(ctx *SQLContext) bool {
continue
}
// Handle database switching - use proper SQL parser instead of manual parsing
// Handle database switching - use proper SQL parser instead of manual parsing.
if strings.HasPrefix(strings.ToUpper(cleanQuery), "USE ") {
// Execute USE statement through the SQL engine for proper parsing
// Execute USE statement through the SQL engine for proper parsing.
result, err := ctx.engine.ExecuteSQL(context.Background(), cleanQuery)
if err != nil {
fmt.Printf("Error: %v\n\n", err)
} else if result.Error != nil {
fmt.Printf("Error: %v\n\n", result.Error)
} else {
// Extract the database name from the result message for CLI context
// Extract the database name from the result message for CLI context.
if len(result.Rows) > 0 && len(result.Rows[0]) > 0 {
message := result.Rows[0][0].ToString()
// Extract database name from "Database changed to: dbname"
// Extract database name from "Database changed to: dbname".
if strings.HasPrefix(message, "Database changed to: ") {
ctx.currentDatabase = strings.TrimPrefix(message, "Database changed to: ")
}
@@ -305,7 +332,7 @@ func runInteractiveShell(ctx *SQLContext) bool {
continue
}
// Handle output format switching
// Handle output format switching.
if strings.HasPrefix(strings.ToUpper(cleanQuery), "\\FORMAT ") {
format := strings.TrimSpace(strings.TrimPrefix(strings.ToUpper(cleanQuery), "\\FORMAT "))
switch format {
@@ -325,22 +352,22 @@ func runInteractiveShell(ctx *SQLContext) bool {
continue
}
// Execute SQL query (without semicolon)
// Execute SQL query (without semicolon).
executeAndDisplay(ctx, cleanQuery, true)
// Reset buffer for next query
// Reset buffer for next query.
queryBuffer.Reset()
}
return true
}
// isSpecialCommand checks if a command is a special command that doesn't require semicolon
// isSpecialCommand checks if a command is a special command that doesn't require semicolon.
func isSpecialCommand(query string) bool {
cleanQuery := strings.TrimSuffix(strings.TrimSpace(query), ";")
cleanQuery = strings.ToLower(cleanQuery)
// Special commands that work with or without semicolon
// Special commands that work with or without semicolon.
specialCommands := []string{
"exit", "quit", "\\q", "help",
}
@@ -351,7 +378,7 @@ func isSpecialCommand(query string) bool {
}
}
// Commands that are exactly specific commands (not just prefixes)
// Commands that are exactly specific commands (not just prefixes).
parts := strings.Fields(strings.ToUpper(cleanQuery))
if len(parts) == 0 {
return false
@@ -360,11 +387,11 @@ func isSpecialCommand(query string) bool {
strings.HasPrefix(strings.ToUpper(cleanQuery), "\\FORMAT ")
}
// executeAndDisplay executes a query and displays the result in the specified format
// executeAndDisplay executes a query and displays the result in the specified format.
func executeAndDisplay(ctx *SQLContext, query string, showTiming bool) bool {
startTime := time.Now()
// Execute the query
// Execute the query.
execCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -397,7 +424,7 @@ func executeAndDisplay(ctx *SQLContext, query string, showTiming bool) bool {
return false
}
// Display results in the specified format
// Display results in the specified format.
switch ctx.outputFormat {
case OutputTable:
displayTableResult(result)
@@ -407,8 +434,8 @@ func executeAndDisplay(ctx *SQLContext, query string, showTiming bool) bool {
displayCSVResult(result)
}
// Show execution time for interactive/table mode
// Only show timing if there are columns or if result is truly empty
// Show execution time for interactive/table mode.
// Only show timing if there are columns or if result is truly empty.
if showTiming && ctx.outputFormat == OutputTable && (len(result.Columns) > 0 || len(result.Rows) == 0) {
elapsed := time.Since(startTime)
fmt.Printf("\n(%d rows in set, %.3f sec)\n\n", len(result.Rows), elapsed.Seconds())
@@ -417,20 +444,20 @@ func executeAndDisplay(ctx *SQLContext, query string, showTiming bool) bool {
return true
}
// displayTableResult formats and displays query results in ASCII table format
// displayTableResult formats and displays query results in ASCII table format.
func displayTableResult(result *engine.QueryResult) {
if len(result.Columns) == 0 {
fmt.Println("Empty result set")
return
}
// Calculate column widths for formatting
// Calculate column widths for formatting.
colWidths := make([]int, len(result.Columns))
for i, col := range result.Columns {
colWidths[i] = len(col)
}
// Check data for wider columns
// Check data for wider columns.
for _, row := range result.Rows {
for i, val := range row {
if i < len(colWidths) {
@@ -442,28 +469,28 @@ func displayTableResult(result *engine.QueryResult) {
}
}
// Print header separator
// Print header separator.
fmt.Print("+")
for _, width := range colWidths {
fmt.Print(strings.Repeat("-", width+2) + "+")
}
fmt.Println()
// Print column headers
// Print column headers.
fmt.Print("|")
for i, col := range result.Columns {
fmt.Printf(" %-*s |", colWidths[i], col)
}
fmt.Println()
// Print separator
// Print separator.
fmt.Print("+")
for _, width := range colWidths {
fmt.Print(strings.Repeat("-", width+2) + "+")
}
fmt.Println()
// Print data rows
// Print data rows.
for _, row := range result.Rows {
fmt.Print("|")
for i, val := range row {
@@ -474,7 +501,7 @@ func displayTableResult(result *engine.QueryResult) {
fmt.Println()
}
// Print bottom separator
// Print bottom separator.
fmt.Print("+")
for _, width := range colWidths {
fmt.Print(strings.Repeat("-", width+2) + "+")
@@ -482,16 +509,16 @@ func displayTableResult(result *engine.QueryResult) {
fmt.Println()
}
// displayJSONResult outputs query results in JSON format
// displayJSONResult outputs query results in JSON format.
func displayJSONResult(result *engine.QueryResult) {
// Convert result to JSON-friendly format
// Convert result to JSON-friendly format.
jsonResult := map[string]interface{}{
"columns": result.Columns,
"rows": make([]map[string]interface{}, len(result.Rows)),
"count": len(result.Rows),
}
// Convert rows to JSON objects
// Convert rows to JSON objects.
for i, row := range result.Rows {
rowObj := make(map[string]interface{})
for j, val := range row {
@@ -502,7 +529,7 @@ func displayJSONResult(result *engine.QueryResult) {
jsonResult["rows"].([]map[string]interface{})[i] = rowObj
}
// Marshal and print JSON
// Marshal and print JSON.
jsonBytes, err := json.MarshalIndent(jsonResult, "", " ")
if err != nil {
fmt.Printf("Error formatting JSON: %v\n", err)
@@ -512,11 +539,11 @@ func displayJSONResult(result *engine.QueryResult) {
fmt.Println(string(jsonBytes))
}
// displayCSVResult outputs query results in CSV format
// displayCSVResult outputs query results in CSV format.
func displayCSVResult(result *engine.QueryResult) {
// Handle execution plan results specially to avoid CSV quoting issues
// Handle execution plan results specially to avoid CSV quoting issues.
if len(result.Columns) == 1 && result.Columns[0] == "Query Execution Plan" {
// For execution plans, output directly without CSV encoding to avoid quotes
// For execution plans, output directly without CSV encoding to avoid quotes.
for _, row := range result.Rows {
if len(row) > 0 {
fmt.Println(row[0].ToString())
@@ -525,17 +552,17 @@ func displayCSVResult(result *engine.QueryResult) {
return
}
// Standard CSV output for regular query results
// Standard CSV output for regular query results.
writer := csv.NewWriter(os.Stdout)
defer writer.Flush()
// Write headers
// Write headers.
if err := writer.Write(result.Columns); err != nil {
fmt.Printf("Error writing CSV headers: %v\n", err)
return
}
// Write data rows
// Write data rows.
for _, row := range result.Rows {
csvRow := make([]string, len(row))
for i, val := range row {
@@ -553,7 +580,7 @@ func showEnhancedHelp() {
METADATA OPERATIONS:
SHOW DATABASES; - List all MQ namespaces
SHOW TABLES; - List all topics in current namespace
SHOW TABLES; - List all topics in current namespace
SHOW TABLES FROM database; - List topics in specific namespace
DESCRIBE table_name; - Show table schema
@@ -581,7 +608,7 @@ SPECIAL COMMANDS:
EXTENDED WHERE OPERATORS:
=, <, >, <=, >= - Comparison operators
!=, <> - Not equal operators
!=, <> - Not equal operators
LIKE 'pattern%' - Pattern matching (% = any chars, _ = single char)
IN (value1, value2, ...) - Multi-value matching
AND, OR - Logical operators
+3 -3
View File
@@ -58,7 +58,7 @@ isArgPassed() {
case "$1" in
'master')
ARGS="-mdir=/data -volumePreallocate -volumeSizeLimitMB=1024"
ARGS="-mdir=/data -volumeSizeLimitMB=1024"
shift
exec /usr/bin/weed -logtostderr=true master $ARGS $@
;;
@@ -73,9 +73,9 @@ case "$1" in
;;
'server')
ARGS="-dir=/data -volume.max=0 -master.volumePreallocate -master.volumeSizeLimitMB=1024"
ARGS="-dir=/data -volume.max=0 -master.volumeSizeLimitMB=1024"
if isArgPassed "-volume.max" "$@"; then
ARGS="-dir=/data -master.volumePreallocate -master.volumeSizeLimitMB=1024"
ARGS="-dir=/data -master.volumeSizeLimitMB=1024"
fi
shift
exec /usr/bin/weed -logtostderr=true server $ARGS $@
+3 -3
View File
@@ -33,7 +33,7 @@ isArgPassed() {
case "$1" in
'master')
ARGS="-mdir=/data -volumePreallocate -volumeSizeLimitMB=1024"
ARGS="-mdir=/data -volumeSizeLimitMB=1024"
shift
exec /usr/bin/weed -logtostderr=true master $ARGS "$@"
;;
@@ -48,9 +48,9 @@ case "$1" in
;;
'server')
ARGS="-dir=/data -volume.max=0 -master.volumePreallocate -master.volumeSizeLimitMB=1024"
ARGS="-dir=/data -volume.max=0 -master.volumeSizeLimitMB=1024"
if isArgPassed "-volume.max" "$@"; then
ARGS="-dir=/data -master.volumePreallocate -master.volumeSizeLimitMB=1024"
ARGS="-dir=/data -master.volumeSizeLimitMB=1024"
fi
shift
exec /usr/bin/weed -logtostderr=true server $ARGS "$@"
+19 -24
View File
@@ -94,7 +94,7 @@ require (
gocloud.dev/pubsub/rabbitpubsub v0.44.0
golang.org/x/crypto v0.48.0
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
golang.org/x/image v0.35.0
golang.org/x/image v0.36.0
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sys v0.41.0
@@ -109,7 +109,7 @@ require (
modernc.org/b v1.0.0 // indirect
modernc.org/mathutil v1.7.1
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.44.3
modernc.org/sqlite v1.46.1
modernc.org/strutil v1.2.1
)
@@ -131,8 +131,6 @@ require (
github.com/cognusion/imaging v1.0.2
github.com/fluent/fluent-logger-golang v1.10.1
github.com/getsentry/sentry-go v0.42.0
github.com/gin-contrib/sessions v1.0.4
github.com/gin-gonic/gin v1.11.0
github.com/go-ldap/ldap/v3 v3.4.12
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/flatbuffers/go v0.0.0-20230108230133-3b8644d32c50
@@ -145,10 +143,10 @@ require (
github.com/mattn/go-sqlite3 v1.14.34
github.com/minio/crc64nvme v1.1.1
github.com/orcaman/concurrent-map/v2 v2.0.1
github.com/parquet-go/parquet-go v0.26.4
github.com/parquet-go/parquet-go v0.28.0
github.com/pkg/sftp v1.13.10
github.com/rabbitmq/amqp091-go v1.10.0
github.com/rclone/rclone v1.72.1
github.com/rclone/rclone v1.73.1
github.com/rdleal/intervalst v1.5.0
github.com/redis/go-redis/v9 v9.18.0
github.com/schollz/progressbar/v3 v3.19.0
@@ -180,6 +178,7 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.37 // indirect
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 // indirect
github.com/anchore/go-lzo v0.1.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
@@ -212,6 +211,8 @@ require (
github.com/docker/docker v28.5.0+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dromara/dongle v1.0.1 // indirect
github.com/gin-gonic/gin v1.11.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
@@ -226,6 +227,7 @@ require (
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
github.com/internxt/rclone-adapter v0.0.0-20260213125353-6f59c89fcb7c // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -257,8 +259,9 @@ require (
github.com/pierrre/geohash v1.0.0 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/pterm/pterm v0.12.81 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.0 // indirect
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 // indirect
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sasha-s/go-deadlock v0.3.1 // indirect
@@ -267,8 +270,9 @@ require (
github.com/substrait-io/substrait v0.69.0 // indirect
github.com/substrait-io/substrait-go/v4 v4.4.0 // indirect
github.com/substrait-io/substrait-protobuf/go v0.71.0 // indirect
github.com/twpayne/go-geom v1.4.1 // indirect
github.com/twpayne/go-kml v1.5.2 // indirect
github.com/twpayne/go-geom v1.6.1 // indirect
github.com/twpayne/go-kml/v3 v3.2.1 // indirect
github.com/tyler-smith/go-bip39 v1.1.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
@@ -279,6 +283,7 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.36.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
go.uber.org/mock v0.5.2 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.32.0 // indirect
@@ -341,15 +346,12 @@ require (
github.com/bradenaw/juniper v0.15.3 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/buengese/sgzip v0.1.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/calebcase/tmpfile v1.0.3 // indirect
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.13.0 // indirect
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc // indirect
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect
github.com/colinmarc/hdfs/v2 v2.4.0 // indirect
github.com/creasty/defaults v1.8.0 // indirect
@@ -369,8 +371,7 @@ require (
github.com/flynn/noise v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-chi/chi/v5 v5.2.3 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@@ -389,10 +390,9 @@ require (
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/gorilla/sessions v1.4.0
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
@@ -402,8 +402,6 @@ require (
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/golang-lru v0.6.0 // indirect
github.com/henrybear327/Proton-API-Bridge v1.0.0 // indirect
github.com/henrybear327/go-proton-api v1.0.0 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
@@ -445,7 +443,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22
github.com/pierrec/lz4/v4 v4.1.25
github.com/pingcap/errors v0.11.5-0.20211224045212-9687c2b0f87c // indirect
github.com/pingcap/failpoint v0.0.0-20220801062533-2eaa32854a6c // indirect
github.com/pingcap/kvproto v0.0.0-20230403051650-e166ae588106 // indirect
@@ -461,7 +459,7 @@ require (
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/seaweedfs/cockroachdb-parser v0.0.0-20251021184156-909763b17138
github.com/seaweedfs/cockroachdb-parser v0.0.0-20260225204133-2f342c5ea564
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
@@ -476,9 +474,7 @@ require (
github.com/tinylib/msgp v1.5.0 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/twmb/murmur3 v1.1.8 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/unknwon/goconfig v1.0.0 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
@@ -504,7 +500,6 @@ require (
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 // indirect
+40 -63
View File
@@ -573,7 +573,6 @@ github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBAD
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 h1:sxgSqOB9CDToiaVFpxuvb5wGgGqWa3lCShcm5o0n3bE=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3/go.mod h1:XdED8i399lEVblYHTZM8eXaP07gv4Z58IL6ueMlVlrg=
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
@@ -590,12 +589,14 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Codefor/geohash v0.0.0-20140723084247-1b41c28e3a9d h1:iG9B49Q218F/XxXNRM7k/vWf7MKmLIS8AcJV9cGN4nA=
github.com/Codefor/geohash v0.0.0-20140723084247-1b41c28e3a9d/go.mod h1:RVnhzAX71far8Kc3TQeA0k/dcaEKUnTDSOyet/JCmGI=
github.com/DATA-DOG/go-sqlmock v1.3.2 h1:2L2f5t3kKnCLxnClDD/PrDfExFFa1wjESgxHG/B1ibo=
github.com/DATA-DOG/go-sqlmock v1.3.2/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e h1:rd4bOvKmDIx0WeTv9Qz+hghsgyjikFiPrseXHlKepO0=
github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e/go.mod h1:blbwPQh4DTlCZEfk1BLU4oMIhLda2U+A840Uag9DsZw=
github.com/FilenCloudDienste/filen-sdk-go v0.0.37 h1:W8S9TrAyZ4//3PXsU6+Bi+fe/6uIL986GyS7PVzIDL4=
github.com/FilenCloudDienste/filen-sdk-go v0.0.37/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
github.com/Files-com/files-sdk-go/v3 v3.2.264 h1:lMHTplAYI9FtmCo/QOcpRxmPA5REVAct1r2riQmDQKw=
github.com/Files-com/files-sdk-go/v3 v3.2.264/go.mod h1:wGqkOzRu/ClJibvDgcfuJNAqI2nLhe8g91tPlDKRCdE=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
@@ -620,19 +621,13 @@ github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/
github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE=
github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4=
github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY=
github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd h1:nzE1YQBdx1bq9IlZinHa+HVffy+NmVRoKr+wHN8fpLE=
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd/go.mod h1:C8yoIfvESpM3GD07OCHU7fqI7lhwyZ2Td1rbNbTAhnc=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
@@ -673,6 +668,10 @@ github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=
github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -806,7 +805,6 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/calebcase/tmpfile v1.0.3 h1:BZrOWZ79gJqQ3XbAQlihYZf/YCV0H4KPIdM5K5oMpJo=
github.com/calebcase/tmpfile v1.0.3/go.mod h1:UAUc01aHeC+pudPagY/lWvt2qS9ZO5Zzof6/tIUzqeI=
github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
@@ -834,8 +832,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudinary/cloudinary-go/v2 v2.13.0 h1:ugiQwb7DwpWQnete2AZkTh94MonZKmxD7hDGy1qTzDs=
github.com/cloudinary/cloudinary-go/v2 v2.13.0/go.mod h1:ireC4gqVetsjVhYlwjUJwKTbZuWjEIynbR9zQTlqsvo=
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc h1:t8YjNUCt1DimB4HCIXBztwWMhgxr5yG5/YaRl9Afdfg=
@@ -882,7 +880,6 @@ github.com/containerd/containerd/api v1.8.0 h1:hVTNJKR8fMc/2Tiw60ZRijntNMd1U+JVM
github.com/containerd/containerd/api v1.8.0/go.mod h1:dFv4lt6S20wTu/hMcP4350RL87qPWLVa/OHOwmmdnYc=
github.com/containerd/containerd/v2 v2.0.5 h1:2vg/TjUXnaohAxiHnthQg8K06L9I4gdYEMcOLiMc8BQ=
github.com/containerd/containerd/v2 v2.0.5/go.mod h1:Qqo0UN43i2fX1FLkrSTCg6zcHNfjN7gEnx3NPRZI+N0=
github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -953,15 +950,15 @@ github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZ
github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c h1:lzqkGL9b3znc+ZUgi7FlLnqjQhcXxkNM/quxIjBVMD0=
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8=
github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dromara/dongle v1.0.1 h1:si/7UP/EXxnFVZok1cNos70GiMGxInAYMilHQFP5dJs=
github.com/dromara/dongle v1.0.1/go.mod h1:ebFhTaDgxaDIKppycENTWlBsxz8mWCPWOLnsEgDpMv4=
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 h1:FT+t0UEDykcor4y3dMVKXIiWJETBpRgERYTGlmMd7HU=
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5/go.mod h1:rSS3kM9XMzSQ6pw91Qgd6yB5jdt70N4OdtrAf74As5M=
github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI=
@@ -989,6 +986,7 @@ github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHS
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emmansun/gmsm v0.15.5/go.mod h1:2m4jygryohSWkaSduFErgCwQKab5BNjURoFrn2DNwyU=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -1056,16 +1054,14 @@ github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNe
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68=
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
@@ -1274,7 +1270,6 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -1309,8 +1304,6 @@ github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
@@ -1390,18 +1383,12 @@ github.com/hashicorp/raft-boltdb/v2 v2.3.1 h1:ackhdCNPKblmOhjEU9+4lHSJYFkJd6Jqyv
github.com/hashicorp/raft-boltdb/v2 v2.3.1/go.mod h1:n4S+g43dXF1tqDT+yzcXHhXM6y7MrlUd3TTwGRcUvQE=
github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0=
github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM=
github.com/henrybear327/Proton-API-Bridge v1.0.0 h1:gjKAaWfKu++77WsZTHg6FUyPC5W0LTKWQciUm8PMZb0=
github.com/henrybear327/Proton-API-Bridge v1.0.0/go.mod h1:gunH16hf6U74W2b9CGDaWRadiLICsoJ6KRkSt53zLts=
github.com/henrybear327/go-proton-api v1.0.0 h1:zYi/IbjLwFAW7ltCeqXneUGJey0TN//Xo851a/BgLXw=
github.com/henrybear327/go-proton-api v1.0.0/go.mod h1:w63MZuzufKcIZ93pwRgiOtxMXYafI8H74D77AxytOBc=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.3.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/in-toto/in-toto-golang v0.5.0 h1:hb8bgwr0M2hGdDsLjkJ3ZqJ8JFLL/tgYdAxF/XEFBbY=
@@ -1410,6 +1397,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s=
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4=
github.com/internxt/rclone-adapter v0.0.0-20260213125353-6f59c89fcb7c h1:r+KtxPyrhsYeNbsfeqTfEM8xRdwgV6LuNhLZxpXecb4=
github.com/internxt/rclone-adapter v0.0.0-20260213125353-6f59c89fcb7c/go.mod h1:vdPya4AIcDjvng4ViaAzqjegJf0VHYpYHQguFx5xBp0=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -1497,7 +1486,6 @@ github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu
github.com/klauspost/reedsolomon v1.13.0 h1:E0Cmgf2kMuhZTj6eefnvpKC4/Q4jhCi9YIjcZjK4arc=
github.com/klauspost/reedsolomon v1.13.0/go.mod h1:ggJT9lc71Vu+cSOPBlxGvBN6TfAS77qB4fp8vJ05NSA=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 h1:CjEMN21Xkr9+zwPmZPaJJw+apzVbjGL5uK/6g9Q2jGU=
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988/go.mod h1:/agobYum3uo/8V6yPVnq+R82pyVGCeuWW5arT4Txn8A=
@@ -1524,8 +1512,6 @@ github.com/lanrat/extsort v1.4.2 h1:akbLIdo4PhNZtvjpaWnbXtGMmLtnGzXplkzfgl+XTTY=
github.com/lanrat/extsort v1.4.2/go.mod h1:hceP6kxKPKebjN1RVrDBXMXXECbaI41Y94tt6MDazc4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/linkedin/goavro/v2 v2.14.1 h1:/8VjDpd38PRsy02JS0jflAu7JZPfJcGTwqWgMkFS2iI=
@@ -1581,14 +1567,12 @@ github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA
github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4 h1:BpfhmLKZf+SjVanKKhCgf3bg+511DmU9eDQTen7LLbY=
github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/mmcloughlin/geohash v0.9.0 h1:FihR004p/aE1Sju6gcVq5OLDqGcMnpBY+8moBqIsVOs=
github.com/mmcloughlin/geohash v0.9.0/go.mod h1:oNZxQo5yWJh0eMQEP/8hwQuVx9Z9tjwFUqcTB1SmG0c=
github.com/moby/buildkit v0.20.1 h1:sT0ZXhhNo5rVbMcYfgttma3TdUHfO5JjFA0UAL8p9fY=
@@ -1667,13 +1651,10 @@ github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGm
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/runc v1.0.0-rc9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
@@ -1683,15 +1664,14 @@ github.com/oracle/oci-go-sdk/v65 v65.104.0 h1:l9awEvzWvxmYhy/97A0hZ87pa7BncYXmcO
github.com/oracle/oci-go-sdk/v65 v65.104.0/go.mod h1:oB8jFGVc/7/zJ+DbleE8MzGHjhs2ioCz5stRTdZdIcY=
github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c=
github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM=
github.com/ory/dockertest/v3 v3.6.0/go.mod h1:4ZOpj8qBUmh8fcBSVzkH2bws2s91JdGvHUqan4GHEuQ=
github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=
github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=
github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=
github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=
github.com/parquet-go/parquet-go v0.26.4 h1:zJ3l8ef5WJZE2m63pKwyEJ2BhyDlgS0PfOEhuCQQU2A=
github.com/parquet-go/parquet-go v0.26.4/go.mod h1:h9GcSt41Knf5qXI1tp1TfR8bDBUtvdUMzSKe26aZcHk=
github.com/parquet-go/parquet-go v0.28.0 h1:ECyksyv8T2pOrlLsN7aWJIoQakyk/HtxQ2lchgS4els=
github.com/parquet-go/parquet-go v0.28.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
@@ -1714,8 +1694,8 @@ github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2
github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pierrre/compare v1.0.2 h1:k4IUsHgh+dbcAOIWCfxVa/7G6STjADH2qmhomv+1quc=
github.com/pierrre/compare v1.0.2/go.mod h1:8UvyRHH+9HS8Pczdd2z5x/wvv67krDwVxoOndaIIDVU=
github.com/pierrre/geohash v1.0.0 h1:f/zfjdV4rVofTCz1FhP07T+EMQAvcMM2ioGZVt+zqjI=
@@ -1808,8 +1788,12 @@ github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc h1:zAsgcP8MhzAbhMnB1QQ2
github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc/go.mod h1:S8xSOnV3CgpNrWd0GQ/OoQfMtlg2uPRSuTzcSGrzwK8=
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/rclone/rclone v1.72.1 h1:Cc/NshKd3/TP3CC0cx9Jg9nTLG8YQ8yLYMTm6Z/LdHk=
github.com/rclone/rclone v1.72.1/go.mod h1:QjmSgz98CjQZZJhROIeYHYjpN5kN7rTA+jtChj/+3Do=
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 h1:4MI2alxM/Ye2gIRBlYf28JGWTipZ4Zz7yAziPKrttjs=
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11/go.mod h1:3HLX7dwZgvB7nt+Yl/xdzVPcargQ1yBmJEUg3n+jMKM=
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 h1:Lc+d3ISfQaMJKWZOE7z4ZSY4RVmdzbn1B0IM8xN18qM=
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc=
github.com/rclone/rclone v1.73.1 h1:FCGZUd7sXO8+nIpbuXGPkd+f23o/ZACefrHtRGDAiDM=
github.com/rclone/rclone v1.73.1/go.mod h1:T9HeSXv/SI9qJFkYLqsjY/5ljIFWJTVmTrG7H/4JrPY=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rdleal/intervalst v1.5.0 h1:SEB9bCFz5IqD1yhfH1Wv8IBnY/JQxDplwkxHjT6hamU=
@@ -1855,8 +1839,8 @@ github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71e
github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM=
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/seaweedfs/cockroachdb-parser v0.0.0-20251021184156-909763b17138 h1:bX1vBF7GQjPeFQsCAZ8gCQGS/nJQnekL7gZ4Qg/pF4E=
github.com/seaweedfs/cockroachdb-parser v0.0.0-20251021184156-909763b17138/go.mod h1:JSKCh6uCHBz91lQYFYHCyTrSVIPge4SUFVn28iwMNB0=
github.com/seaweedfs/cockroachdb-parser v0.0.0-20260225204133-2f342c5ea564 h1:TgxPraf1NmF6XTcUG53ULpLQrKvhtUJxQ3hyekxSDNQ=
github.com/seaweedfs/cockroachdb-parser v0.0.0-20260225204133-2f342c5ea564/go.mod h1:JSKCh6uCHBz91lQYFYHCyTrSVIPge4SUFVn28iwMNB0=
github.com/seaweedfs/go-fuse/v2 v2.9.1 h1:gnKmfrKreCRGJmekGz5WMnNZqXEf9s9+V2hdWQdvx88=
github.com/seaweedfs/go-fuse/v2 v2.9.1/go.mod h1:zABdmWEa6A0bwaBeEOBUeUkGIZlxUhcdv+V1Dcc/U/I=
github.com/seaweedfs/goexif v1.0.3 h1:ve/OjI7dxPW8X9YQsv3JuVMaxEyF9Rvfd04ouL+Bz30=
@@ -1876,7 +1860,6 @@ github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
@@ -1907,7 +1890,6 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
@@ -2005,12 +1987,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg=
github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
github.com/twpayne/go-geom v1.4.1 h1:LeivFqaGBRfyg0XJJ9pkudcptwhSSrYN9KZUW6HcgdA=
github.com/twpayne/go-geom v1.4.1/go.mod h1:k/zktXdL+qnA6OgKsdEGUTA17jbQ2ZPTUa3CCySuGpE=
github.com/twpayne/go-kml v1.5.2 h1:rFMw2/EwgkVssGS2MT6YfWSPZz6BgcJkLxQ53jnE8rQ=
github.com/twpayne/go-kml v1.5.2/go.mod h1:kz8jAiIz6FIdU2Zjce9qGlVtgFYES9vt7BTPBHf5jl4=
github.com/twpayne/go-polyline v1.0.0/go.mod h1:ICh24bcLYBX8CknfvNPKqoTbe+eg+MX1NPyJmSBo7pU=
github.com/twpayne/go-waypoint v0.0.0-20200706203930-b263a7f6e4e8/go.mod h1:qj5pHncxKhu9gxtZEYWypA/z097sxhFlbTyOyt9gcnU=
github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=
github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=
github.com/twpayne/go-kml/v3 v3.2.1 h1:xkTIJ7KMnHGKpHGf30e4XS3UT8o/5jD62hmdGJPf7Io=
github.com/twpayne/go-kml/v3 v3.2.1/go.mod h1:lPWoJR3nQAdePBy3SrnniLdBLVQX0hlxrcziCx9XgT0=
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
github.com/tylertreat/BoomFilters v0.0.0-20210315201527-1a82519a3e43 h1:QEePdg0ty2r0t1+qwfZmQ4OOl/MB2UXIeJSpIZv56lg=
github.com/tylertreat/BoomFilters v0.0.0-20210315201527-1a82519a3e43/go.mod h1:OYRfF6eb5wY9VRFkXJH8FFBi3plw2v+giaIu7P054pM=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
@@ -2214,13 +2196,13 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@@ -2260,8 +2242,8 @@ golang.org/x/image v0.0.0-20210607152325-775e3b0c77b9/go.mod h1:023OzeP/+EPmXeap
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.0.0-20220302094943-723b81ca9867/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -2312,7 +2294,6 @@ golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191003171128-d98b1b443823/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -2442,7 +2423,6 @@ golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200121082415-34d275377bf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -2589,7 +2569,6 @@ golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -2960,7 +2939,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
@@ -2970,7 +2948,6 @@ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -3047,8 +3024,8 @@ modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.1.0/go.mod h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
@@ -3,6 +3,10 @@ kind: ServiceAccount
metadata:
name: {{ include "seaweedfs.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
{{- with .Values.global.serviceAccountAnnotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
labels:
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+1
View File
@@ -20,6 +20,7 @@ global:
filerRead: false
# we will use this serviceAccountName for all ClusterRoles/ClusterRoleBindings
serviceAccountName: "seaweedfs"
serviceAccountAnnotations: {}
automountServiceAccountToken: true
certificates:
duration: 87600h
+1 -1
View File
@@ -148,7 +148,7 @@ func waitForServer(url string, timeout time.Duration) bool {
func testProtobufMarshaling() error {
// Test protobuf marshaling/unmarshaling
testData := &proto.TelemetryData{
ClusterId: "test-cluster-12345",
TopologyId: "test-cluster-12345",
Version: "test-3.45",
Os: "linux/amd64",
VolumeServerCount: 2,
+4 -2
View File
@@ -16,8 +16,9 @@ RUN go mod download
# Copy source code
COPY . .
# Build the weed binary with Kafka gateway support
# Build the weed binaries with Kafka gateway support
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o weed ./weed
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o weed-sql ./cmd/weed-sql
# Final stage
FROM alpine:latest
@@ -32,8 +33,9 @@ RUN addgroup -g 1000 seaweedfs && \
# Set working directory
WORKDIR /usr/bin
# Copy binary from builder
# Copy binaries from builder
COPY --from=builder /app/weed .
COPY --from=builder /app/weed-sql .
# Create data directory
RUN mkdir -p /data && chown seaweedfs:seaweedfs /data
+86 -69
View File
@@ -8,7 +8,7 @@ require (
github.com/seaweedfs/seaweedfs v0.0.0-00010101000000-000000000000
github.com/segmentio/kafka-go v0.4.49
github.com/stretchr/testify v1.11.1
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.78.0
)
replace github.com/seaweedfs/seaweedfs => ../../
@@ -17,14 +17,15 @@ require (
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.2 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/Files-com/files-sdk-go/v3 v3.2.218 // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.37 // indirect
github.com/Files-com/files-sdk-go/v3 v3.2.264 // indirect
github.com/IBM/go-sdk-core/v5 v5.21.0 // indirect
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
@@ -35,51 +36,58 @@ require (
github.com/ProtonMail/go-srp v0.0.7 // indirect
github.com/ProtonMail/gopenpgp/v2 v2.9.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 // indirect
github.com/abbot/go-http-auth v0.4.0 // indirect
github.com/anchore/go-lzo v0.1.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go v1.55.8 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.6 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.7 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/bradenaw/juniper v0.15.3 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/buengese/sgzip v0.1.1 // indirect
github.com/bufbuild/protocompile v0.14.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/calebcase/tmpfile v1.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.12.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.13.0 // indirect
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc // indirect
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
github.com/cognusion/imaging v1.0.2 // indirect
github.com/colinmarc/hdfs/v2 v2.4.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/creasty/defaults v1.8.0 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/diskfs/go-diskfs v1.7.0 // indirect
github.com/dromara/dongle v1.0.1 // indirect
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eapache/go-resiliency v1.7.0 // indirect
@@ -91,25 +99,26 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/flynn/noise v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/go-chi/chi/v5 v5.2.2 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/errors v0.22.2 // indirect
github.com/go-openapi/strfmt v0.23.0 // indirect
github.com/go-openapi/errors v0.22.4 // indirect
github.com/go-openapi/strfmt v0.25.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/btree v1.1.3 // indirect
@@ -124,78 +133,83 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/henrybear327/Proton-API-Bridge v1.0.0 // indirect
github.com/henrybear327/go-proton-api v1.0.0 // indirect
github.com/internxt/rclone-adapter v0.0.0-20260213125353-6f59c89fcb7c // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jhump/protoreflect v1.17.0 // indirect
github.com/jhump/protoreflect v1.18.0 // indirect
github.com/jhump/protoreflect/v2 v2.0.0-beta.1 // indirect
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
github.com/karlseguin/ccache/v2 v2.0.8 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/reedsolomon v1.12.6 // indirect
github.com/klauspost/reedsolomon v1.13.0 // indirect
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 // indirect
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lanrat/extsort v1.4.0 // indirect
github.com/lanrat/extsort v1.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lpar/date v1.0.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncw/swift/v2 v2.0.4 // indirect
github.com/ncw/swift/v2 v2.0.5 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/oracle/oci-go-sdk/v65 v65.98.0 // indirect
github.com/oracle/oci-go-sdk/v65 v65.104.0 // indirect
github.com/orcaman/concurrent-map/v2 v2.0.1 // indirect
github.com/panjf2000/ants/v2 v2.11.3 // indirect
github.com/parquet-go/parquet-go v0.26.3 // indirect
github.com/parquet-go/bitpack v1.0.0 // indirect
github.com/parquet-go/jsonlite v1.0.0 // indirect
github.com/parquet-go/parquet-go v0.26.4 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
github.com/peterh/liner v1.2.2 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/sftp v1.13.10 // indirect
github.com/pkg/xattr v0.4.12 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect
github.com/rclone/rclone v1.71.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 // indirect
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 // indirect
github.com/rclone/rclone v1.73.1 // indirect
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
github.com/rdleal/intervalst v1.5.0 // indirect
github.com/relvacode/iso8601 v1.6.0 // indirect
github.com/relvacode/iso8601 v1.7.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rfjakob/eme v1.1.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/samber/lo v1.51.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/seaweedfs/goexif v1.0.3 // indirect
github.com/shirou/gopsutil/v4 v4.25.11 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/shirou/gopsutil/v4 v4.26.1 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/smarty/assertions v1.16.0 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spacemonkeygo/monkit/v3 v3.0.24 // indirect
github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
@@ -203,15 +217,18 @@ require (
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/syndtr/goleveldb v1.0.1-0.20190318030020-c3a204f8e965 // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20250926104142-ccb8d3498e6c // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/tyler-smith/go-bip39 v1.1.0 // indirect
github.com/tylertreat/BoomFilters v0.0.0-20210315201527-1a82519a3e43 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/unknwon/goconfig v1.0.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/viant/ptrie v1.0.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
@@ -220,24 +237,24 @@ require (
github.com/zeebo/blake3 v0.2.4 // indirect
github.com/zeebo/errs v1.4.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.etcd.io/bbolt v1.4.2 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 // indirect
golang.org/x/image v0.34.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/image v0.36.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.258.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
@@ -250,7 +267,7 @@ require (
modernc.org/mathutil v1.7.1 // indirect
moul.io/http2curl/v2 v2.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
storj.io/common v0.0.0-20250808122759-804533d519c1 // indirect
storj.io/common v0.0.0-20251107171817-6221ae45072c // indirect
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 // indirect
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 // indirect
storj.io/infectious v0.0.2 // indirect
+216 -182
View File
@@ -13,8 +13,7 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI=
cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ=
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
@@ -38,8 +37,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
@@ -48,20 +47,22 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDo
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.2 h1:l3SabZmNuXCMCbQUIeR4W6/N4j8SeH/lwX+a6leZhHo=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.2/go.mod h1:k+mEZ4f1pVqZTRqtSDW2AhZ/3wT5qLpsUA75C/k7dtE=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 h1:sxgSqOB9CDToiaVFpxuvb5wGgGqWa3lCShcm5o0n3bE=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3/go.mod h1:XdED8i399lEVblYHTZM8eXaP07gv4Z58IL6ueMlVlrg=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Files-com/files-sdk-go/v3 v3.2.218 h1:tIvcbHXNY/bq+Sno6vajOJOxhe5XbU59Fa1ohOybK+s=
github.com/Files-com/files-sdk-go/v3 v3.2.218/go.mod h1:E0BaGQbcMUcql+AfubCR/iasWKBxX5UZPivnQGC2z0M=
github.com/FilenCloudDienste/filen-sdk-go v0.0.37 h1:W8S9TrAyZ4//3PXsU6+Bi+fe/6uIL986GyS7PVzIDL4=
github.com/FilenCloudDienste/filen-sdk-go v0.0.37/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
github.com/Files-com/files-sdk-go/v3 v3.2.264 h1:lMHTplAYI9FtmCo/QOcpRxmPA5REVAct1r2riQmDQKw=
github.com/Files-com/files-sdk-go/v3 v3.2.264/go.mod h1:wGqkOzRu/ClJibvDgcfuJNAqI2nLhe8g91tPlDKRCdE=
github.com/IBM/go-sdk-core/v5 v5.21.0 h1:DUnYhvC4SoC8T84rx5omnhY3+xcQg/Whyoa3mDPIMkk=
github.com/IBM/go-sdk-core/v5 v5.21.0/go.mod h1:Q3BYO6iDA2zweQPDGbNTtqft5tDcEpm6RTuqMlPcvbw=
github.com/IBM/sarama v1.46.0 h1:+YTM1fNd6WKMchlnLKRUB5Z0qD4M8YbvwIIPLvJD53s=
@@ -89,63 +90,69 @@ github.com/ProtonMail/gopenpgp/v2 v2.9.0 h1:ruLzBmwe4dR1hdnrsEJ/S7psSBmV15gFttFU
github.com/ProtonMail/gopenpgp/v2 v2.9.0/go.mod h1:IldDyh9Hv1ZCCYatTuuEt1XZJ0OPjxLpTarDfglih7s=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 h1:iLDOF0rdGTrol/q8OfPIIs5kLD8XvA2q75o6Uq/tgak=
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0/go.mod h1:DrEWcQJjz7t5iF2duaiyhg4jyoF0kxOD6LtECNGkZ/Q=
github.com/aalpar/deheap v0.0.0-20210914013432-0cc84d79dec3 h1:hhdWprfSpFbN7lz3W1gM40vOgvSh1WCSMxYD6gGB4Hs=
github.com/aalpar/deheap v0.0.0-20210914013432-0cc84d79dec3/go.mod h1:XaUnRxSCYgL3kkgX0QHIV0D+znljPIDImxlv2kbGv0Y=
github.com/abbot/go-http-auth v0.4.0 h1:QjmvZ5gSC7jm3Zg54DqWE/T5m1t2AfDu6QlXJT0EVT0=
github.com/abbot/go-http-auth v0.4.0/go.mod h1:Cz6ARTIzApMJDzh5bRMSUou6UMSp0IEXg9km/ci7TJM=
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc h1:LoL75er+LKDHDUfU5tRvFwxH0LjPpZN8OoG8Ll+liGU=
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc/go.mod h1:w648aMHEgFYS6xb0KVMMtZ2uMeemhiKCuD2vj6gY52A=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.4 h1:0SzCLoPRSK3qSydsaFQWugP+lOBCTPwfcBOm6222+UA=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.4/go.mod h1:JAet9FsBHjfdI+TnMBX4ModNNaQHAd3dc/Bk+cNsxeM=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.3/go.mod h1:br7KA6edAAqDGUYJ+zVVPAyMrPhnN+zdt17yTUT6FPw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4 h1:2fjfz3/G9BRvIKuNZ655GwzpklC2kEH0cowZQGO7uBg=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4/go.mod h1:Ymws824lvMypLFPwyyUXM52SXuGgxpu0+DISLfKvB+c=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 h1:NLYTEyZmVZo0Qh183sC8nC+ydJXOOeIL/qI/sS3PdLY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15/go.mod h1:Z803iB3B0bc8oJV8zH2PERLRfQUJ2n2BXISpsA4+O1M=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 h1:P1MU/SuhadGvg2jtviDXPEejU3jBNhoeeAlRadHzvHI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6/go.mod h1:5KYaMG6wmVKMFBSfWoyG/zH8pWwzQFnKgpoSRlXHKdQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 h1:wsSQ4SVz5YE1crz0Ap7VBZrV4nNqZt4CIBBT8mnwoNc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15/go.mod h1:I7sditnFGtYMIqPRU1QoHZAUrXkGp4SczmlLwrNPlD0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 h1:IrbE3B8O9pm3lsg96AXIN5MXX4pECEuExh/A0Du3AuI=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0/go.mod h1:/sJLzHtiiZvs6C1RbxS/anSAFwZD6oC6M/kotQzOiLw=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bradenaw/juniper v0.15.3 h1:RHIAMEDTpvmzV1wg1jMAHGOoI2oJUSPx3lxRldXnFGo=
github.com/bradenaw/juniper v0.15.3/go.mod h1:UX4FX57kVSaDp4TPqvSjkAAewmRFAfXf27BOs5z9dq8=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
@@ -154,11 +161,13 @@ github.com/buengese/sgzip v0.1.1 h1:ry+T8l1mlmiWEsDrH/YHZnCVWD2S3im1KLsyO+8ZmTU=
github.com/buengese/sgzip v0.1.1/go.mod h1:i5ZiXGF3fhV7gL1xaRRL1nDnmpNj0X061FQzOS8VMas=
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/calebcase/tmpfile v1.0.3 h1:BZrOWZ79gJqQ3XbAQlihYZf/YCV0H4KPIdM5K5oMpJo=
github.com/calebcase/tmpfile v1.0.3/go.mod h1:UAUc01aHeC+pudPagY/lWvt2qS9ZO5Zzof6/tIUzqeI=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -170,17 +179,21 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWR
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudinary/cloudinary-go/v2 v2.12.0 h1:uveBJeNpJztKDwFW/B+Wuklq584hQmQXlo+hGTSOGZ8=
github.com/cloudinary/cloudinary-go/v2 v2.12.0/go.mod h1:ireC4gqVetsjVhYlwjUJwKTbZuWjEIynbR9zQTlqsvo=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudinary/cloudinary-go/v2 v2.13.0 h1:ugiQwb7DwpWQnete2AZkTh94MonZKmxD7hDGy1qTzDs=
github.com/cloudinary/cloudinary-go/v2 v2.13.0/go.mod h1:ireC4gqVetsjVhYlwjUJwKTbZuWjEIynbR9zQTlqsvo=
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc h1:t8YjNUCt1DimB4HCIXBztwWMhgxr5yG5/YaRl9Afdfg=
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc/go.mod h1:CgWpFCFWzzEA5hVkhAc6DZZzGd3czx+BblvOzjmg6KA=
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc h1:0xCWmFKBmarCqqqLeM7jFBSw/Or81UEElFqO8MY+GDs=
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc/go.mod h1:uvR42Hb/t52HQd7x5/ZLzZEK8oihrFpgnodIJ1vte2E=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cognusion/imaging v1.0.2 h1:BQwBV8V8eF3+dwffp8Udl9xF1JKh5Z0z5JkJwAi98Mc=
github.com/cognusion/imaging v1.0.2/go.mod h1:mj7FvH7cT2dlFogQOSUQRtotBxJ4gFQ2ySMSmBm5dSk=
@@ -188,8 +201,8 @@ github.com/colinmarc/hdfs/v2 v2.4.0 h1:v6R8oBx/Wu9fHpdPoJJjpGSUxo8NhHIwrwsfhFvU9
github.com/colinmarc/hdfs/v2 v2.4.0/go.mod h1:0NAO+/3knbMx6+5pCv+Hcbaz4xn/Zzbn9+WIib2rKVI=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk=
github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo=
@@ -200,8 +213,14 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/diskfs/go-diskfs v1.7.0 h1:vonWmt5CMowXwUc79jWyGrf2DIMeoOjkLlMnQYGVOs8=
github.com/diskfs/go-diskfs v1.7.0/go.mod h1:LhQyXqOugWFRahYUSw47NyZJPezFzB9UELwhpszLP/k=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dromara/dongle v1.0.1 h1:si/7UP/EXxnFVZok1cNos70GiMGxInAYMilHQFP5dJs=
github.com/dromara/dongle v1.0.1/go.mod h1:ebFhTaDgxaDIKppycENTWlBsxz8mWCPWOLnsEgDpMv4=
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 h1:FT+t0UEDykcor4y3dMVKXIiWJETBpRgERYTGlmMd7HU=
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5/go.mod h1:rSS3kM9XMzSQ6pw91Qgd6yB5jdt70N4OdtrAf74As5M=
github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI=
@@ -216,10 +235,13 @@ github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg=
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/emmansun/gmsm v0.15.5/go.mod h1:2m4jygryohSWkaSduFErgCwQKab5BNjURoFrn2DNwyU=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -237,20 +259,22 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64=
github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68=
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -264,18 +288,20 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/errors v0.22.2 h1:rdxhzcBUazEcGccKqbY1Y7NS8FDcMyIRr0934jrYnZg=
github.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0=
github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c=
github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4=
github.com/go-openapi/errors v0.22.4 h1:oi2K9mHTOb5DPW2Zjdzs/NIvwi2N3fARKaTJLdNabaM=
github.com/go-openapi/errors v0.22.4/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk=
github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ=
github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
@@ -286,16 +312,14 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -355,15 +379,14 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
@@ -390,14 +413,14 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/henrybear327/Proton-API-Bridge v1.0.0 h1:gjKAaWfKu++77WsZTHg6FUyPC5W0LTKWQciUm8PMZb0=
github.com/henrybear327/Proton-API-Bridge v1.0.0/go.mod h1:gunH16hf6U74W2b9CGDaWRadiLICsoJ6KRkSt53zLts=
github.com/henrybear327/go-proton-api v1.0.0 h1:zYi/IbjLwFAW7ltCeqXneUGJey0TN//Xo851a/BgLXw=
github.com/henrybear327/go-proton-api v1.0.0/go.mod h1:w63MZuzufKcIZ93pwRgiOtxMXYafI8H74D77AxytOBc=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/internxt/rclone-adapter v0.0.0-20260213125353-6f59c89fcb7c h1:r+KtxPyrhsYeNbsfeqTfEM8xRdwgV6LuNhLZxpXecb4=
github.com/internxt/rclone-adapter v0.0.0-20260213125353-6f59c89fcb7c/go.mod h1:vdPya4AIcDjvng4ViaAzqjegJf0VHYpYHQguFx5xBp0=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -410,8 +433,10 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
github.com/jhump/protoreflect v1.18.0 h1:TOz0MSR/0JOZ5kECB/0ufGnC2jdsgZ123Rd/k4Z5/2w=
github.com/jhump/protoreflect v1.18.0/go.mod h1:ezWcltJIVF4zYdIFM+D/sHV4Oh5LNU08ORzCGfwvTz8=
github.com/jhump/protoreflect/v2 v2.0.0-beta.1 h1:Dw1rslK/VotaUGYsv53XVWITr+5RCPXfvvlGrM/+B6w=
github.com/jhump/protoreflect/v2 v2.0.0-beta.1/go.mod h1:D9LBEowZyv8/iSu97FU2zmXG3JxVTmNw21mu63niFzU=
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 h1:ZxO6Qr2GOXPdcW80Mcn3nemvilMPvpWqxrNfK2ZnNNs=
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3/go.mod h1:dvLUr/8Fs9a2OBrEnCC5duphbkz/k/mSy5OkXg3PAgI=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
@@ -436,12 +461,12 @@ github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRt
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/reedsolomon v1.12.6 h1:8pqE9aECQG/ZFitiUD1xK/E83zwosBAZtE3UbuZM8TQ=
github.com/klauspost/reedsolomon v1.12.6/go.mod h1:ggJT9lc71Vu+cSOPBlxGvBN6TfAS77qB4fp8vJ05NSA=
github.com/klauspost/reedsolomon v1.13.0 h1:E0Cmgf2kMuhZTj6eefnvpKC4/Q4jhCi9YIjcZjK4arc=
github.com/klauspost/reedsolomon v1.13.0/go.mod h1:ggJT9lc71Vu+cSOPBlxGvBN6TfAS77qB4fp8vJ05NSA=
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 h1:CjEMN21Xkr9+zwPmZPaJJw+apzVbjGL5uK/6g9Q2jGU=
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988/go.mod h1:/agobYum3uo/8V6yPVnq+R82pyVGCeuWW5arT4Txn8A=
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 h1:FHVoZMOVRA+6/y4yRlbiR3WvsrOcKBd/f64H7YiWR2U=
@@ -458,35 +483,37 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lanrat/extsort v1.4.0 h1:jysS/Tjnp7mBwJ6NG8SY+XYFi8HF3LujGbqY9jOWjco=
github.com/lanrat/extsort v1.4.0/go.mod h1:hceP6kxKPKebjN1RVrDBXMXXECbaI41Y94tt6MDazc4=
github.com/lanrat/extsort v1.4.2 h1:akbLIdo4PhNZtvjpaWnbXtGMmLtnGzXplkzfgl+XTTY=
github.com/lanrat/extsort v1.4.2/go.mod h1:hceP6kxKPKebjN1RVrDBXMXXECbaI41Y94tt6MDazc4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/linkedin/goavro/v2 v2.14.1 h1:/8VjDpd38PRsy02JS0jflAu7JZPfJcGTwqWgMkFS2iI=
github.com/linkedin/goavro/v2 v2.14.1/go.mod h1:KXx+erlq+RPlGSPmLF7xGo6SAbh8sCQ53x064+ioxhk=
github.com/lpar/date v1.0.0 h1:bq/zVqFTUmsxvd/CylidY4Udqpr9BOFrParoP6p0x/I=
github.com/lpar/date v1.0.0/go.mod h1:KjYe0dDyMQTgpqcUz4LEIeM5VZwhggjVx/V2dtc8NSo=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4 h1:BpfhmLKZf+SjVanKKhCgf3bg+511DmU9eDQTen7LLbY=
github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncw/swift/v2 v2.0.4 h1:hHWVFxn5/YaTWAASmn4qyq2p6OyP/Hm3vMLzkjEqR7w=
github.com/ncw/swift/v2 v2.0.4/go.mod h1:cbAO76/ZwcFrFlHdXPjaqWZ9R7Hdar7HpjRXBfbjigk=
github.com/ncw/swift/v2 v2.0.5 h1:9o5Gsd7bInAFEqsGPcaUdsboMbqf8lnNtxqWKFT9iz8=
github.com/ncw/swift/v2 v2.0.5/go.mod h1:cbAO76/ZwcFrFlHdXPjaqWZ9R7Hdar7HpjRXBfbjigk=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
@@ -500,15 +527,18 @@ github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGm
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/oracle/oci-go-sdk/v65 v65.98.0 h1:ZKsy97KezSiYSN1Fml4hcwjpO+wq01rjBkPqIiUejVc=
github.com/oracle/oci-go-sdk/v65 v65.98.0/go.mod h1:RGiXfpDDmRRlLtqlStTzeBjjdUNXyqm3KXKyLCm3A/Q=
github.com/oracle/oci-go-sdk/v65 v65.104.0 h1:l9awEvzWvxmYhy/97A0hZ87pa7BncYXmcO/S8+rvgK0=
github.com/oracle/oci-go-sdk/v65 v65.104.0/go.mod h1:oB8jFGVc/7/zJ+DbleE8MzGHjhs2ioCz5stRTdZdIcY=
github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c=
github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM=
github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
github.com/parquet-go/parquet-go v0.25.1 h1:l7jJwNM0xrk0cnIIptWMtnSnuxRkwq53S+Po3KG8Xgo=
github.com/parquet-go/parquet-go v0.25.1/go.mod h1:AXBuotO1XiBtcqJb/FKFyjBG4aqa3aQAAWF3ZPzCanY=
github.com/parquet-go/parquet-go v0.26.3/go.mod h1:h9GcSt41Knf5qXI1tp1TfR8bDBUtvdUMzSKe26aZcHk=
github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=
github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=
github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=
github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=
github.com/parquet-go/parquet-go v0.26.4 h1:zJ3l8ef5WJZE2m63pKwyEJ2BhyDlgS0PfOEhuCQQU2A=
github.com/parquet-go/parquet-go v0.26.4/go.mod h1:h9GcSt41Knf5qXI1tp1TfR8bDBUtvdUMzSKe26aZcHk=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@@ -517,8 +547,10 @@ github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 h1:XeOYlK9W1uC
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14/go.mod h1:jVblp62SafmidSkvWrXyxAme3gaTfEtWwRPGz5cpvHg=
github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw=
github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28=
@@ -533,13 +565,15 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 h1:Y258uzXU/potCYnQd1r6wlAnoMB68BiCkCcCnKx1SH8=
@@ -548,21 +582,22 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/rclone/rclone v1.71.2 h1:3Jk5xNPFrZhVABRuN/OPvApuZQddpE2tkhYMuEn1Ud4=
github.com/rclone/rclone v1.71.2/go.mod h1:dCK9FzPDlpkbQJ9M7MmWsmv3X5nibfWe+ogJXu6gSgM=
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 h1:4MI2alxM/Ye2gIRBlYf28JGWTipZ4Zz7yAziPKrttjs=
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11/go.mod h1:3HLX7dwZgvB7nt+Yl/xdzVPcargQ1yBmJEUg3n+jMKM=
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 h1:Lc+d3ISfQaMJKWZOE7z4ZSY4RVmdzbn1B0IM8xN18qM=
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc=
github.com/rclone/rclone v1.73.1 h1:FCGZUd7sXO8+nIpbuXGPkd+f23o/ZACefrHtRGDAiDM=
github.com/rclone/rclone v1.73.1/go.mod h1:T9HeSXv/SI9qJFkYLqsjY/5ljIFWJTVmTrG7H/4JrPY=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rdleal/intervalst v1.5.0 h1:SEB9bCFz5IqD1yhfH1Wv8IBnY/JQxDplwkxHjT6hamU=
github.com/rdleal/intervalst v1.5.0/go.mod h1:xO89Z6BC+LQDH+IPQQw/OESt5UADgFD41tYMUINGpxQ=
github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU=
github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
github.com/relvacode/iso8601 v1.7.0 h1:BXy+V60stMP6cpswc+a93Mq3e65PfXCgDFfhvNNGrdo=
github.com/relvacode/iso8601 v1.7.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4=
github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
@@ -570,18 +605,18 @@ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDj
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI=
github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/seaweedfs/goexif v1.0.3 h1:ve/OjI7dxPW8X9YQsv3JuVMaxEyF9Rvfd04ouL+Bz30=
github.com/seaweedfs/goexif v1.0.3/go.mod h1:Oni780Z236sXpIQzk1XoJlTwqrJ02smEin9zQeff7Fk=
github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk=
github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY=
github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY=
@@ -590,17 +625,18 @@ github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sS
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/snabb/httpreaderat v1.0.1 h1:whlb+vuZmyjqVop8x1EKOg05l2NE4z9lsMMXjmSUCnY=
github.com/snabb/httpreaderat v1.0.1/go.mod h1:lpbGrKDWF37yvRbtRvQsbesS6Ty5c83t8ztannPoMsA=
github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spacemonkeygo/monkit/v3 v3.0.24 h1:cKixJ+evHnfJhWNyIZjBy5hoW8LTWmrJXPo18tzLNrk=
github.com/spacemonkeygo/monkit/v3 v3.0.24/go.mod h1:XkZYGzknZwkD0AKUnZaSXhRiVTLCkq7CWVa3IsE72gA=
github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368 h1:GyYC5Ntqk/yy9lEIGE7chdIvt4zP44taycwd9YDSGdc=
github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368/go.mod h1:XkZYGzknZwkD0AKUnZaSXhRiVTLCkq7CWVa3IsE72gA=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
@@ -622,15 +658,14 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/syndtr/goleveldb v1.0.1-0.20190318030020-c3a204f8e965 h1:1oFLiOyVl+W7bnBzGhf7BbIv9loSFQcieWWYIjLqcAw=
github.com/syndtr/goleveldb v1.0.1-0.20190318030020-c3a204f8e965/go.mod h1:9OrXJhf154huy1nPWmuSrkgjPUtUNhA+Zmy+6AESzuA=
github.com/t3rm1n4l/go-mega v0.0.0-20250926104142-ccb8d3498e6c h1:BLopNCyqewbE8+BtlIp/Juzu8AJGxz0gHdGADnsblVc=
github.com/t3rm1n4l/go-mega v0.0.0-20250926104142-ccb8d3498e6c/go.mod h1:ykucQyiE9Q2qx1wLlEtZkkNn1IURib/2O+Mvd25i1Fo=
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 h1:rrGZv6xYk37hx0tW2sYfgbO0PqStbHqz6Bq6oc9Hurg=
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491/go.mod h1:ykucQyiE9Q2qx1wLlEtZkkNn1IURib/2O+Mvd25i1Fo=
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
@@ -638,10 +673,14 @@ github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9R
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
github.com/tylertreat/BoomFilters v0.0.0-20210315201527-1a82519a3e43 h1:QEePdg0ty2r0t1+qwfZmQ4OOl/MB2UXIeJSpIZv56lg=
github.com/tylertreat/BoomFilters v0.0.0-20210315201527-1a82519a3e43/go.mod h1:OYRfF6eb5wY9VRFkXJH8FFBi3plw2v+giaIu7P054pM=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/unknwon/goconfig v1.0.0 h1:rS7O+CmUdli1T+oDm7fYj1MwqNWtEJfNj+FqcUHML8U=
github.com/unknwon/goconfig v1.0.0/go.mod h1:qu2ZQ/wcC/if2u32263HTVC39PeOQRSmidQk3DuDFQ8=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -652,6 +691,8 @@ github.com/viant/ptrie v1.0.1 h1:3fFC8XqCSchf11sCSS5sbb8eGDNEP2g2Hj96lNdHlZY=
github.com/viant/ptrie v1.0.1/go.mod h1:Y+mwwNCIUgFrCZcrG4/QChfi4ubvnNBsyrENBIgigu0=
github.com/viant/toolbox v0.34.5 h1:szWNPiGHjo8Dd4v2a59saEhG31DRL2Xf3aJ0ZtTSuqc=
github.com/viant/toolbox v0.34.5/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ=
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
@@ -662,8 +703,9 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
@@ -691,8 +733,8 @@ github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
@@ -702,8 +744,8 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
@@ -716,12 +758,12 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -730,16 +772,15 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -750,12 +791,12 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 h1:3yiSh9fhy5/RhCSntf4Sy0Tnx50DmMpQ4MQdKKk4yg4=
golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -811,6 +852,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
@@ -819,16 +861,15 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -845,7 +886,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -890,31 +930,29 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -922,6 +960,7 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
@@ -929,14 +968,12 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -985,8 +1022,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1009,8 +1046,7 @@ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0M
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc=
google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM=
google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc=
google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -1047,12 +1083,11 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs=
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 h1:LvZVVaPE0JSqL+ZWb6ErZfnEOKIqqFWUJE2D0fObSmc=
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc=
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 h1:ZdyUkS9po3H7G0tuh955QVyyotWvOD4W0aEapeGeUYk=
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846/go.mod h1:Fk4kyraUvqD7i5H6S43sj2W98fbZa75lpZz/eUyhfO0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
@@ -1066,8 +1101,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6 h1:ExN12ndbJ608cboPYflpTny6mXSzPrDLh0iTaVrRrds=
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8=
google.golang.org/grpc/security/advancedtls v1.0.0 h1:/KQ7VP/1bs53/aopk9QhuPyFAp9Dm9Ejix3lzYkCrDA=
@@ -1082,8 +1117,7 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -1121,8 +1155,8 @@ rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
storj.io/common v0.0.0-20250808122759-804533d519c1 h1:z7ZjU+TlPZ2Lq2S12hT6+Fr7jFsBxPMrPBH4zZpZuUA=
storj.io/common v0.0.0-20250808122759-804533d519c1/go.mod h1:YNr7/ty6CmtpG5C9lEPtPXK3hOymZpueCb9QCNuPMUY=
storj.io/common v0.0.0-20251107171817-6221ae45072c h1:UDXSrdeLJe3QFouavSW10fYdpclK0YNu3KvQHzqq2+k=
storj.io/common v0.0.0-20251107171817-6221ae45072c/go.mod h1:XNX7uykja6aco92y2y8RuqaXIDRPpt1YA2OQDKlKEUk=
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 h1:8OE12DvUnB9lfZcHe7IDGsuhjrY9GBAr964PVHmhsro=
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55/go.mod h1:Y9LZaa8esL1PW2IDMqJE7CFSNq7d5bQ3RI7mGPtmKMg=
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 h1:5MZ0CyMbG6Pi0rRzUWVG6dvpXjbBYEX2oyXuj+tT+sk=
+1 -2
View File
@@ -14,8 +14,7 @@ sleep 2
echo "Querying messages..."
cd /Users/chrislu/go/src/github.com/seaweedfs/seaweedfs/test/kafka/kafka-client-loadtest
docker compose exec kafka-gateway /usr/local/bin/weed sql \
docker compose exec kafka-gateway /usr/bin/weed-sql \
-master=seaweedfs-master:9333 \
-database=kafka \
-query="SELECT id, timestamp, producer_id, counter, user_id, event_type FROM \"test-json-topic\" LIMIT 5;"
@@ -0,0 +1,78 @@
package ec_repair_test
import (
"context"
"testing"
"time"
pluginworkers "github.com/seaweedfs/seaweedfs/test/plugin_workers"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestEcRepairDetectionFindsCandidates(t *testing.T) {
const (
volumeID = uint32(100)
collection = "ec-test"
diskType = "hdd"
)
node1 := nodeSpec{
id: "node1",
address: "127.0.0.1:11001",
diskType: diskType,
diskID: 0,
ecShards: []*master_pb.VolumeEcShardInformationMessage{
buildEcShardInfo(volumeID, collection, diskType, 0, map[uint32]int64{
0: 100,
1: 100,
2: 100,
3: 100,
4: 100,
}),
},
}
node2 := nodeSpec{
id: "node2",
address: "127.0.0.1:11002",
diskType: diskType,
diskID: 0,
ecShards: []*master_pb.VolumeEcShardInformationMessage{
buildEcShardInfo(volumeID, collection, diskType, 0, map[uint32]int64{
0: 50,
5: 100,
6: 100,
7: 100,
8: 100,
9: 100,
}),
},
}
topo := buildTopology([]nodeSpec{node1, node2})
response := &master_pb.VolumeListResponse{TopologyInfo: topo}
master := pluginworkers.NewMasterServer(t, response)
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
handler := pluginworker.NewEcRepairHandler(dialOption)
harness := pluginworkers.NewHarness(t, pluginworkers.HarnessConfig{
WorkerOptions: pluginworker.WorkerOptions{GrpcDialOption: dialOption},
Handlers: []pluginworker.JobHandler{handler},
})
harness.WaitForJobType("ec_repair")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
proposals, err := harness.Plugin().RunDetection(ctx, "ec_repair", &plugin_pb.ClusterContext{
MasterGrpcAddresses: []string{master.Address()},
}, 10)
require.NoError(t, err)
require.NotEmpty(t, proposals)
require.Equal(t, "ec_repair", proposals[0].JobType)
}
@@ -0,0 +1,131 @@
package ec_repair_test
import (
"context"
"fmt"
"testing"
"time"
pluginworkers "github.com/seaweedfs/seaweedfs/test/plugin_workers"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestEcRepairExecutionRepairsShards(t *testing.T) {
const (
volumeID = uint32(200)
collection = "ec-repair"
diskType = "hdd"
)
volumeServers := []*pluginworkers.VolumeServer{
pluginworkers.NewVolumeServer(t, ""),
pluginworkers.NewVolumeServer(t, ""),
pluginworkers.NewVolumeServer(t, ""),
pluginworkers.NewVolumeServer(t, ""),
}
nodes := []nodeSpec{
{
id: "node1",
address: volumeServers[0].Address(),
diskType: diskType,
diskID: 0,
ecShards: []*master_pb.VolumeEcShardInformationMessage{
buildEcShardInfo(volumeID, collection, diskType, 0, map[uint32]int64{
0: 100,
1: 100,
2: 100,
3: 100,
4: 100,
}),
},
},
{
id: "node2",
address: volumeServers[1].Address(),
diskType: diskType,
diskID: 0,
ecShards: []*master_pb.VolumeEcShardInformationMessage{
buildEcShardInfo(volumeID, collection, diskType, 0, map[uint32]int64{
0: 50,
5: 100,
6: 100,
7: 100,
8: 100,
9: 100,
}),
},
},
{
id: "node3",
address: volumeServers[2].Address(),
diskType: diskType,
diskID: 0,
},
{
id: "node4",
address: volumeServers[3].Address(),
diskType: diskType,
diskID: 0,
},
}
topo := buildTopology(nodes)
response := &master_pb.VolumeListResponse{TopologyInfo: topo}
master := pluginworkers.NewMasterServer(t, response)
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
handler := pluginworker.NewEcRepairHandler(dialOption)
harness := pluginworkers.NewHarness(t, pluginworkers.HarnessConfig{
WorkerOptions: pluginworker.WorkerOptions{GrpcDialOption: dialOption},
Handlers: []pluginworker.JobHandler{handler},
})
harness.WaitForJobType("ec_repair")
job := &plugin_pb.JobSpec{
JobId: fmt.Sprintf("ec-repair-%d", volumeID),
JobType: "ec_repair",
Parameters: map[string]*plugin_pb.ConfigValue{
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(volumeID)},
},
"collection": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: collection},
},
"disk_type": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: diskType},
},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := harness.Plugin().ExecuteJob(ctx, job, &plugin_pb.ClusterContext{
MasterGrpcAddresses: []string{master.Address()},
}, 1)
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, result.Success)
rebuildCalls := 0
copyCalls := 0
deleteCalls := 0
unmountCalls := 0
for _, vs := range volumeServers {
rebuildCalls += len(vs.RebuildRequests())
copyCalls += len(vs.CopyRequests())
deleteCalls += len(vs.EcDeleteRequests())
unmountCalls += len(vs.UnmountRequests())
}
require.Greater(t, rebuildCalls, 0)
require.Greater(t, copyCalls, 0)
require.Greater(t, deleteCalls, 0)
require.Greater(t, unmountCalls, 0)
}
@@ -0,0 +1,69 @@
package ec_repair_test
import (
"sort"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
)
type nodeSpec struct {
id string
address string
diskType string
diskID uint32
ecShards []*master_pb.VolumeEcShardInformationMessage
}
func buildTopology(nodes []nodeSpec) *master_pb.TopologyInfo {
dataNodes := make([]*master_pb.DataNodeInfo, 0, len(nodes))
for _, node := range nodes {
diskInfo := &master_pb.DiskInfo{
DiskId: node.diskID,
MaxVolumeCount: 100,
VolumeCount: 10,
EcShardInfos: node.ecShards,
}
dataNodes = append(dataNodes, &master_pb.DataNodeInfo{
Id: node.id,
Address: node.address,
DiskInfos: map[string]*master_pb.DiskInfo{node.diskType: diskInfo},
})
}
return &master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{
{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{
{
Id: "rack1",
DataNodeInfos: dataNodes,
},
},
},
},
}
}
func buildEcShardInfo(volumeID uint32, collection, diskType string, diskID uint32, shardSizes map[uint32]int64) *master_pb.VolumeEcShardInformationMessage {
shardIDs := make([]int, 0, len(shardSizes))
for shardID := range shardSizes {
shardIDs = append(shardIDs, int(shardID))
}
sort.Ints(shardIDs)
var bits uint32
sizes := make([]int64, 0, len(shardIDs))
for _, shardID := range shardIDs {
bits |= (1 << shardID)
sizes = append(sizes, shardSizes[uint32(shardID)])
}
return &master_pb.VolumeEcShardInformationMessage{
Id: volumeID,
Collection: collection,
EcIndexBits: bits,
DiskType: diskType,
DiskId: diskID,
ShardSizes: sizes,
}
}
@@ -0,0 +1,285 @@
package erasure_coding_test
import (
"context"
"fmt"
"testing"
"time"
pluginworkers "github.com/seaweedfs/seaweedfs/test/plugin_workers"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
ecstorage "github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/proto"
)
type topologySpec struct {
name string
dataCenters int
racksPerDC int
nodesPerRack int
diskTypes []string
replicas int
collection string
}
type detectionCase struct {
name string
topology topologySpec
adminCollectionFilter string
expectProposals bool
}
func TestErasureCodingDetectionAcrossTopologies(t *testing.T) {
cases := []detectionCase{
{
name: "single-dc-multi-rack",
topology: topologySpec{
name: "single-dc-multi-rack",
dataCenters: 1,
racksPerDC: 2,
nodesPerRack: 7,
diskTypes: []string{"hdd"},
replicas: 1,
collection: "ec-test",
},
expectProposals: true,
},
{
name: "multi-dc",
topology: topologySpec{
name: "multi-dc",
dataCenters: 2,
racksPerDC: 1,
nodesPerRack: 7,
diskTypes: []string{"hdd"},
replicas: 1,
collection: "ec-test",
},
expectProposals: true,
},
{
name: "multi-dc-multi-rack",
topology: topologySpec{
name: "multi-dc-multi-rack",
dataCenters: 2,
racksPerDC: 2,
nodesPerRack: 4,
diskTypes: []string{"hdd"},
replicas: 1,
collection: "ec-test",
},
expectProposals: true,
},
{
name: "mixed-disk-types",
topology: topologySpec{
name: "mixed-disk-types",
dataCenters: 1,
racksPerDC: 2,
nodesPerRack: 7,
diskTypes: []string{"hdd", "ssd"},
replicas: 1,
collection: "ec-test",
},
expectProposals: true,
},
{
name: "multi-replica-volume",
topology: topologySpec{
name: "multi-replica-volume",
dataCenters: 1,
racksPerDC: 2,
nodesPerRack: 7,
diskTypes: []string{"hdd"},
replicas: 3,
collection: "ec-test",
},
expectProposals: true,
},
{
name: "collection-filter-match",
topology: topologySpec{
name: "collection-filter-match",
dataCenters: 1,
racksPerDC: 2,
nodesPerRack: 7,
diskTypes: []string{"hdd"},
replicas: 1,
collection: "filtered",
},
adminCollectionFilter: "filtered",
expectProposals: true,
},
{
name: "collection-filter-mismatch",
topology: topologySpec{
name: "collection-filter-mismatch",
dataCenters: 1,
racksPerDC: 2,
nodesPerRack: 7,
diskTypes: []string{"hdd"},
replicas: 1,
collection: "filtered",
},
adminCollectionFilter: "other",
expectProposals: false,
},
{
name: "insufficient-disks",
topology: topologySpec{
name: "insufficient-disks",
dataCenters: 1,
racksPerDC: 1,
nodesPerRack: 2,
diskTypes: []string{"hdd"},
replicas: 1,
collection: "ec-test",
},
expectProposals: false,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
volumeID := uint32(7)
response := buildVolumeListResponse(t, tc.topology, volumeID)
master := pluginworkers.NewMasterServer(t, response)
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
handler := pluginworker.NewErasureCodingHandler(dialOption, t.TempDir())
harness := pluginworkers.NewHarness(t, pluginworkers.HarnessConfig{
WorkerOptions: pluginworker.WorkerOptions{
GrpcDialOption: dialOption,
},
Handlers: []pluginworker.JobHandler{handler},
})
harness.WaitForJobType("erasure_coding")
if tc.adminCollectionFilter != "" {
err := harness.Plugin().SaveJobTypeConfig(&plugin_pb.PersistedJobTypeConfig{
JobType: "erasure_coding",
AdminConfigValues: map[string]*plugin_pb.ConfigValue{
"collection_filter": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: tc.adminCollectionFilter},
},
},
})
require.NoError(t, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
proposals, err := harness.Plugin().RunDetection(ctx, "erasure_coding", &plugin_pb.ClusterContext{
MasterGrpcAddresses: []string{master.Address()},
}, 10)
require.NoError(t, err)
if !tc.expectProposals {
require.Empty(t, proposals)
return
}
require.NotEmpty(t, proposals)
proposal := proposals[0]
require.Equal(t, "erasure_coding", proposal.JobType)
paramsValue := proposal.Parameters["task_params_pb"]
require.NotNil(t, paramsValue)
params := &worker_pb.TaskParams{}
require.NoError(t, proto.Unmarshal(paramsValue.GetBytesValue(), params))
require.NotEmpty(t, params.Sources)
require.Len(t, params.Targets, ecstorage.TotalShardsCount)
})
}
}
func buildVolumeListResponse(t *testing.T, spec topologySpec, volumeID uint32) *master_pb.VolumeListResponse {
t.Helper()
volumeSizeLimitMB := uint64(100)
volumeSize := uint64(90) * 1024 * 1024
volumeModifiedAt := time.Now().Add(-10 * time.Minute).Unix()
diskTypes := spec.diskTypes
if len(diskTypes) == 0 {
diskTypes = []string{"hdd"}
}
replicas := spec.replicas
if replicas <= 0 {
replicas = 1
}
collection := spec.collection
if collection == "" {
collection = "ec-test"
}
var dataCenters []*master_pb.DataCenterInfo
nodeIndex := 0
replicasPlaced := 0
for dc := 0; dc < spec.dataCenters; dc++ {
var racks []*master_pb.RackInfo
for rack := 0; rack < spec.racksPerDC; rack++ {
var nodes []*master_pb.DataNodeInfo
for n := 0; n < spec.nodesPerRack; n++ {
nodeIndex++
address := fmt.Sprintf("127.0.0.1:%d", 20000+nodeIndex)
diskType := diskTypes[(nodeIndex-1)%len(diskTypes)]
diskInfo := &master_pb.DiskInfo{
DiskId: 0,
MaxVolumeCount: 100,
VolumeCount: 0,
VolumeInfos: []*master_pb.VolumeInformationMessage{},
}
if replicasPlaced < replicas {
diskInfo.VolumeCount = 1
diskInfo.VolumeInfos = append(diskInfo.VolumeInfos, &master_pb.VolumeInformationMessage{
Id: volumeID,
Collection: collection,
DiskId: 0,
Size: volumeSize,
DeletedByteCount: 0,
ModifiedAtSecond: volumeModifiedAt,
ReplicaPlacement: 1,
ReadOnly: false,
})
replicasPlaced++
}
nodes = append(nodes, &master_pb.DataNodeInfo{
Id: address,
Address: address,
DiskInfos: map[string]*master_pb.DiskInfo{diskType: diskInfo},
})
}
racks = append(racks, &master_pb.RackInfo{
Id: fmt.Sprintf("rack-%d", rack+1),
DataNodeInfos: nodes,
})
}
dataCenters = append(dataCenters, &master_pb.DataCenterInfo{
Id: fmt.Sprintf("dc-%d", dc+1),
RackInfos: racks,
})
}
return &master_pb.VolumeListResponse{
VolumeSizeLimitMb: volumeSizeLimitMB,
TopologyInfo: &master_pb.TopologyInfo{
DataCenterInfos: dataCenters,
},
}
}
@@ -0,0 +1,83 @@
package erasure_coding_test
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
pluginworkers "github.com/seaweedfs/seaweedfs/test/plugin_workers"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
ecstorage "github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestErasureCodingExecutionEncodesShards(t *testing.T) {
volumeID := uint32(123)
datSize := 1 * 1024 * 1024
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
handler := pluginworker.NewErasureCodingHandler(dialOption, t.TempDir())
harness := pluginworkers.NewHarness(t, pluginworkers.HarnessConfig{
WorkerOptions: pluginworker.WorkerOptions{
GrpcDialOption: dialOption,
},
Handlers: []pluginworker.JobHandler{handler},
})
harness.WaitForJobType("erasure_coding")
sourceServer := pluginworkers.NewVolumeServer(t, "")
pluginworkers.WriteTestVolumeFiles(t, sourceServer.BaseDir(), volumeID, datSize)
targetServers := make([]*pluginworkers.VolumeServer, 0, ecstorage.TotalShardsCount)
targetAddresses := make([]string, 0, ecstorage.TotalShardsCount)
for i := 0; i < ecstorage.TotalShardsCount; i++ {
target := pluginworkers.NewVolumeServer(t, "")
targetServers = append(targetServers, target)
targetAddresses = append(targetAddresses, target.Address())
}
job := &plugin_pb.JobSpec{
JobId: fmt.Sprintf("ec-job-%d", volumeID),
JobType: "erasure_coding",
Parameters: map[string]*plugin_pb.ConfigValue{
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(volumeID)},
},
"collection": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "ec-test"},
},
"source_server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: sourceServer.Address()},
},
"target_servers": {
Kind: &plugin_pb.ConfigValue_StringList{StringList: &plugin_pb.StringList{Values: targetAddresses}},
},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := harness.Plugin().ExecuteJob(ctx, job, nil, 1)
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, result.Success)
require.GreaterOrEqual(t, sourceServer.MarkReadonlyCount(), 1)
require.GreaterOrEqual(t, len(sourceServer.DeleteRequests()), 1)
for shardID := 0; shardID < ecstorage.TotalShardsCount; shardID++ {
targetIndex := shardID % len(targetServers)
target := targetServers[targetIndex]
expected := filepath.Join(target.BaseDir(), fmt.Sprintf("%d.ec%02d", volumeID, shardID))
info, err := os.Stat(expected)
require.NoErrorf(t, err, "missing shard file %s", expected)
require.Greater(t, info.Size(), int64(0))
}
}
@@ -0,0 +1,123 @@
package erasure_coding_test
import (
"context"
"fmt"
"testing"
"time"
pluginworkers "github.com/seaweedfs/seaweedfs/test/plugin_workers"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestErasureCodingDetectionLargeTopology(t *testing.T) {
const (
rackCount = 100
serverCount = 1000
volumesPerNode = 300
volumeSizeLimit = uint64(100)
)
if serverCount%rackCount != 0 {
t.Fatalf("serverCount (%d) must be divisible by rackCount (%d)", serverCount, rackCount)
}
nodesPerRack := serverCount / rackCount
eligibleSize := uint64(90) * 1024 * 1024
ineligibleSize := uint64(10) * 1024 * 1024
modifiedAt := time.Now().Add(-10 * time.Minute).Unix()
volumeID := uint32(1)
dataCenters := make([]*master_pb.DataCenterInfo, 0, 1)
racks := make([]*master_pb.RackInfo, 0, rackCount)
for rack := 0; rack < rackCount; rack++ {
nodes := make([]*master_pb.DataNodeInfo, 0, nodesPerRack)
for node := 0; node < nodesPerRack; node++ {
address := fmt.Sprintf("10.0.%d.%d:8080", rack, node+1)
volumes := make([]*master_pb.VolumeInformationMessage, 0, volumesPerNode)
for v := 0; v < volumesPerNode; v++ {
size := ineligibleSize
if volumeID%2 == 0 {
size = eligibleSize
}
volumes = append(volumes, &master_pb.VolumeInformationMessage{
Id: volumeID,
Collection: "ec-bulk",
DiskId: 0,
Size: size,
DeletedByteCount: 0,
ModifiedAtSecond: modifiedAt,
ReplicaPlacement: 1,
ReadOnly: false,
})
volumeID++
}
diskInfo := &master_pb.DiskInfo{
DiskId: 0,
MaxVolumeCount: int64(volumesPerNode + 10),
VolumeCount: int64(volumesPerNode),
VolumeInfos: volumes,
}
nodes = append(nodes, &master_pb.DataNodeInfo{
Id: address,
Address: address,
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": diskInfo},
})
}
racks = append(racks, &master_pb.RackInfo{
Id: fmt.Sprintf("rack-%d", rack+1),
DataNodeInfos: nodes,
})
}
dataCenters = append(dataCenters, &master_pb.DataCenterInfo{
Id: "dc-1",
RackInfos: racks,
})
response := &master_pb.VolumeListResponse{
VolumeSizeLimitMb: volumeSizeLimit,
TopologyInfo: &master_pb.TopologyInfo{
DataCenterInfos: dataCenters,
},
}
master := pluginworkers.NewMasterServer(t, response)
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
handler := pluginworker.NewErasureCodingHandler(dialOption, t.TempDir())
harness := pluginworkers.NewHarness(t, pluginworkers.HarnessConfig{
WorkerOptions: pluginworker.WorkerOptions{
GrpcDialOption: dialOption,
},
Handlers: []pluginworker.JobHandler{handler},
})
harness.WaitForJobType("erasure_coding")
totalVolumes := serverCount * volumesPerNode
expectedEligible := totalVolumes / 2
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
start := time.Now()
proposals, err := harness.Plugin().RunDetection(ctx, "erasure_coding", &plugin_pb.ClusterContext{
MasterGrpcAddresses: []string{master.Address()},
}, 0)
duration := time.Since(start)
require.NoError(t, err)
require.GreaterOrEqual(t, len(proposals), 10, "should detect at least some proposals")
t.Logf("large topology detection completed in %s (proposals=%d, eligible=%d)", duration, len(proposals), expectedEligible)
if len(proposals) < expectedEligible {
t.Logf("large topology detection stopped early: %d proposals vs %d eligible", len(proposals), expectedEligible)
}
}
+90
View File
@@ -0,0 +1,90 @@
package pluginworkers
import (
"context"
"net"
"sync"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
"testing"
)
// MasterServer provides a stub master gRPC service for topology responses.
type MasterServer struct {
master_pb.UnimplementedSeaweedServer
t *testing.T
server *grpc.Server
listener net.Listener
address string
mu sync.RWMutex
response *master_pb.VolumeListResponse
}
// NewMasterServer starts a stub master server that serves the provided response.
func NewMasterServer(t *testing.T, response *master_pb.VolumeListResponse) *MasterServer {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen master: %v", err)
}
server := pb.NewGrpcServer()
ms := &MasterServer{
t: t,
server: server,
listener: listener,
address: listener.Addr().String(),
response: response,
}
master_pb.RegisterSeaweedServer(server, ms)
go func() {
_ = server.Serve(listener)
}()
t.Cleanup(func() {
ms.Shutdown()
})
return ms
}
// Address returns the gRPC address of the master server.
func (m *MasterServer) Address() string {
return m.address
}
// SetVolumeListResponse updates the response served by VolumeList.
func (m *MasterServer) SetVolumeListResponse(response *master_pb.VolumeListResponse) {
m.mu.Lock()
defer m.mu.Unlock()
m.response = response
}
// VolumeList returns the configured topology response.
func (m *MasterServer) VolumeList(ctx context.Context, req *master_pb.VolumeListRequest) (*master_pb.VolumeListResponse, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.response == nil {
return &master_pb.VolumeListResponse{}, nil
}
return proto.Clone(m.response).(*master_pb.VolumeListResponse), nil
}
// Shutdown stops the master gRPC server.
func (m *MasterServer) Shutdown() {
if m.server != nil {
m.server.GracefulStop()
}
if m.listener != nil {
_ = m.listener.Close()
}
}
+407
View File
@@ -0,0 +1,407 @@
package pluginworkers
import (
"context"
"fmt"
"io"
"net"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"google.golang.org/grpc"
)
// VolumeServer provides a minimal volume server for erasure coding tests.
type VolumeServer struct {
volume_server_pb.UnimplementedVolumeServerServer
t *testing.T
server *grpc.Server
listener net.Listener
address string
baseDir string
mu sync.Mutex
receivedFiles map[string]uint64
mountRequests []*volume_server_pb.VolumeEcShardsMountRequest
unmountRequests []*volume_server_pb.VolumeEcShardsUnmountRequest
copyRequests []*volume_server_pb.VolumeEcShardsCopyRequest
rebuildRequests []*volume_server_pb.VolumeEcShardsRebuildRequest
ecDeleteRequests []*volume_server_pb.VolumeEcShardsDeleteRequest
deleteRequests []*volume_server_pb.VolumeDeleteRequest
markReadonlyCalls int
vacuumGarbageRatio float64
vacuumCheckCalls int
vacuumCompactCalls int
vacuumCommitCalls int
vacuumCleanupCalls int
volumeCopyCalls int
volumeMountCalls int
tailReceiverCalls int
}
// NewVolumeServer starts a test volume server using the provided base directory.
func NewVolumeServer(t *testing.T, baseDir string) *VolumeServer {
t.Helper()
if baseDir == "" {
baseDir = t.TempDir()
}
if err := os.MkdirAll(baseDir, 0755); err != nil {
t.Fatalf("create volume base dir: %v", err)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen volume server: %v", err)
}
grpcPort := listener.Addr().(*net.TCPAddr).Port
server := pb.NewGrpcServer()
vs := &VolumeServer{
t: t,
server: server,
listener: listener,
address: fmt.Sprintf("127.0.0.1:0.%d", grpcPort),
baseDir: baseDir,
receivedFiles: make(map[string]uint64),
}
volume_server_pb.RegisterVolumeServerServer(server, vs)
go func() {
_ = server.Serve(listener)
}()
t.Cleanup(func() {
vs.Shutdown()
})
return vs
}
// Address returns the gRPC address of the volume server.
func (v *VolumeServer) Address() string {
return v.address
}
// BaseDir returns the base directory used by the server.
func (v *VolumeServer) BaseDir() string {
return v.baseDir
}
// ReceivedFiles returns a snapshot of received files and byte counts.
func (v *VolumeServer) ReceivedFiles() map[string]uint64 {
v.mu.Lock()
defer v.mu.Unlock()
out := make(map[string]uint64, len(v.receivedFiles))
for key, value := range v.receivedFiles {
out[key] = value
}
return out
}
// SetVacuumGarbageRatio sets the garbage ratio returned by VacuumVolumeCheck.
func (v *VolumeServer) SetVacuumGarbageRatio(ratio float64) {
v.mu.Lock()
defer v.mu.Unlock()
v.vacuumGarbageRatio = ratio
}
// VacuumStats returns the vacuum RPC call counts.
func (v *VolumeServer) VacuumStats() (check, compact, commit, cleanup int) {
v.mu.Lock()
defer v.mu.Unlock()
return v.vacuumCheckCalls, v.vacuumCompactCalls, v.vacuumCommitCalls, v.vacuumCleanupCalls
}
// BalanceStats returns the balance RPC call counts.
func (v *VolumeServer) BalanceStats() (copyCalls, mountCalls, tailCalls int) {
v.mu.Lock()
defer v.mu.Unlock()
return v.volumeCopyCalls, v.volumeMountCalls, v.tailReceiverCalls
}
// MountRequests returns recorded mount requests.
func (v *VolumeServer) MountRequests() []*volume_server_pb.VolumeEcShardsMountRequest {
v.mu.Lock()
defer v.mu.Unlock()
out := make([]*volume_server_pb.VolumeEcShardsMountRequest, len(v.mountRequests))
copy(out, v.mountRequests)
return out
}
// UnmountRequests returns recorded unmount requests.
func (v *VolumeServer) UnmountRequests() []*volume_server_pb.VolumeEcShardsUnmountRequest {
v.mu.Lock()
defer v.mu.Unlock()
out := make([]*volume_server_pb.VolumeEcShardsUnmountRequest, len(v.unmountRequests))
copy(out, v.unmountRequests)
return out
}
// CopyRequests returns recorded EC shard copy requests.
func (v *VolumeServer) CopyRequests() []*volume_server_pb.VolumeEcShardsCopyRequest {
v.mu.Lock()
defer v.mu.Unlock()
out := make([]*volume_server_pb.VolumeEcShardsCopyRequest, len(v.copyRequests))
copy(out, v.copyRequests)
return out
}
// RebuildRequests returns recorded EC shard rebuild requests.
func (v *VolumeServer) RebuildRequests() []*volume_server_pb.VolumeEcShardsRebuildRequest {
v.mu.Lock()
defer v.mu.Unlock()
out := make([]*volume_server_pb.VolumeEcShardsRebuildRequest, len(v.rebuildRequests))
copy(out, v.rebuildRequests)
return out
}
// EcDeleteRequests returns recorded EC shard delete requests.
func (v *VolumeServer) EcDeleteRequests() []*volume_server_pb.VolumeEcShardsDeleteRequest {
v.mu.Lock()
defer v.mu.Unlock()
out := make([]*volume_server_pb.VolumeEcShardsDeleteRequest, len(v.ecDeleteRequests))
copy(out, v.ecDeleteRequests)
return out
}
// DeleteRequests returns recorded delete requests.
func (v *VolumeServer) DeleteRequests() []*volume_server_pb.VolumeDeleteRequest {
v.mu.Lock()
defer v.mu.Unlock()
out := make([]*volume_server_pb.VolumeDeleteRequest, len(v.deleteRequests))
copy(out, v.deleteRequests)
return out
}
// MarkReadonlyCount returns the number of readonly calls.
func (v *VolumeServer) MarkReadonlyCount() int {
v.mu.Lock()
defer v.mu.Unlock()
return v.markReadonlyCalls
}
// Shutdown stops the volume server.
func (v *VolumeServer) Shutdown() {
if v.server != nil {
v.server.GracefulStop()
}
if v.listener != nil {
_ = v.listener.Close()
}
}
func (v *VolumeServer) filePath(volumeID uint32, ext string) string {
return filepath.Join(v.baseDir, fmt.Sprintf("%d%s", volumeID, ext))
}
func (v *VolumeServer) CopyFile(req *volume_server_pb.CopyFileRequest, stream volume_server_pb.VolumeServer_CopyFileServer) error {
if req == nil {
return fmt.Errorf("copy file request is nil")
}
path := v.filePath(req.VolumeId, req.Ext)
file, err := os.Open(path)
if err != nil {
if req.IgnoreSourceFileNotFound {
return nil
}
return err
}
defer file.Close()
buf := make([]byte, 64*1024)
for {
n, readErr := file.Read(buf)
if n > 0 {
if err := stream.Send(&volume_server_pb.CopyFileResponse{FileContent: buf[:n]}); err != nil {
return err
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return readErr
}
}
return nil
}
func (v *VolumeServer) ReceiveFile(stream volume_server_pb.VolumeServer_ReceiveFileServer) error {
var (
info *volume_server_pb.ReceiveFileInfo
file *os.File
bytesWritten uint64
filePath string
)
defer func() {
if file != nil {
_ = file.Close()
}
}()
for {
req, err := stream.Recv()
if err == io.EOF {
if info == nil {
return stream.SendAndClose(&volume_server_pb.ReceiveFileResponse{Error: "missing file info"})
}
v.mu.Lock()
v.receivedFiles[filePath] = bytesWritten
v.mu.Unlock()
return stream.SendAndClose(&volume_server_pb.ReceiveFileResponse{BytesWritten: bytesWritten})
}
if err != nil {
return err
}
if reqInfo := req.GetInfo(); reqInfo != nil {
info = reqInfo
filePath = v.filePath(info.VolumeId, info.Ext)
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return err
}
file, err = os.Create(filePath)
if err != nil {
return err
}
continue
}
chunk := req.GetFileContent()
if len(chunk) == 0 {
continue
}
if file == nil {
return fmt.Errorf("file info not received")
}
n, writeErr := file.Write(chunk)
if writeErr != nil {
return writeErr
}
bytesWritten += uint64(n)
}
}
func (v *VolumeServer) VolumeEcShardsMount(ctx context.Context, req *volume_server_pb.VolumeEcShardsMountRequest) (*volume_server_pb.VolumeEcShardsMountResponse, error) {
v.mu.Lock()
v.mountRequests = append(v.mountRequests, req)
v.mu.Unlock()
return &volume_server_pb.VolumeEcShardsMountResponse{}, nil
}
func (v *VolumeServer) VolumeEcShardsUnmount(ctx context.Context, req *volume_server_pb.VolumeEcShardsUnmountRequest) (*volume_server_pb.VolumeEcShardsUnmountResponse, error) {
v.mu.Lock()
v.unmountRequests = append(v.unmountRequests, req)
v.mu.Unlock()
return &volume_server_pb.VolumeEcShardsUnmountResponse{}, nil
}
func (v *VolumeServer) VolumeEcShardsCopy(ctx context.Context, req *volume_server_pb.VolumeEcShardsCopyRequest) (*volume_server_pb.VolumeEcShardsCopyResponse, error) {
v.mu.Lock()
v.copyRequests = append(v.copyRequests, req)
v.mu.Unlock()
return &volume_server_pb.VolumeEcShardsCopyResponse{}, nil
}
func (v *VolumeServer) VolumeEcShardsRebuild(ctx context.Context, req *volume_server_pb.VolumeEcShardsRebuildRequest) (*volume_server_pb.VolumeEcShardsRebuildResponse, error) {
v.mu.Lock()
v.rebuildRequests = append(v.rebuildRequests, req)
v.mu.Unlock()
return &volume_server_pb.VolumeEcShardsRebuildResponse{}, nil
}
func (v *VolumeServer) VolumeEcShardsDelete(ctx context.Context, req *volume_server_pb.VolumeEcShardsDeleteRequest) (*volume_server_pb.VolumeEcShardsDeleteResponse, error) {
v.mu.Lock()
v.ecDeleteRequests = append(v.ecDeleteRequests, req)
v.mu.Unlock()
return &volume_server_pb.VolumeEcShardsDeleteResponse{}, nil
}
func (v *VolumeServer) VolumeDelete(ctx context.Context, req *volume_server_pb.VolumeDeleteRequest) (*volume_server_pb.VolumeDeleteResponse, error) {
v.mu.Lock()
v.deleteRequests = append(v.deleteRequests, req)
v.mu.Unlock()
if req != nil {
_ = os.Remove(v.filePath(req.VolumeId, ".dat"))
_ = os.Remove(v.filePath(req.VolumeId, ".idx"))
}
return &volume_server_pb.VolumeDeleteResponse{}, nil
}
func (v *VolumeServer) VolumeMarkReadonly(ctx context.Context, req *volume_server_pb.VolumeMarkReadonlyRequest) (*volume_server_pb.VolumeMarkReadonlyResponse, error) {
v.mu.Lock()
v.markReadonlyCalls++
v.mu.Unlock()
return &volume_server_pb.VolumeMarkReadonlyResponse{}, nil
}
func (v *VolumeServer) VacuumVolumeCheck(ctx context.Context, req *volume_server_pb.VacuumVolumeCheckRequest) (*volume_server_pb.VacuumVolumeCheckResponse, error) {
v.mu.Lock()
v.vacuumCheckCalls++
ratio := v.vacuumGarbageRatio
v.mu.Unlock()
return &volume_server_pb.VacuumVolumeCheckResponse{GarbageRatio: ratio}, nil
}
func (v *VolumeServer) VacuumVolumeCompact(req *volume_server_pb.VacuumVolumeCompactRequest, stream volume_server_pb.VolumeServer_VacuumVolumeCompactServer) error {
v.mu.Lock()
v.vacuumCompactCalls++
v.mu.Unlock()
return stream.Send(&volume_server_pb.VacuumVolumeCompactResponse{ProcessedBytes: 1024})
}
func (v *VolumeServer) VacuumVolumeCommit(ctx context.Context, req *volume_server_pb.VacuumVolumeCommitRequest) (*volume_server_pb.VacuumVolumeCommitResponse, error) {
v.mu.Lock()
v.vacuumCommitCalls++
v.mu.Unlock()
return &volume_server_pb.VacuumVolumeCommitResponse{}, nil
}
func (v *VolumeServer) VacuumVolumeCleanup(ctx context.Context, req *volume_server_pb.VacuumVolumeCleanupRequest) (*volume_server_pb.VacuumVolumeCleanupResponse, error) {
v.mu.Lock()
v.vacuumCleanupCalls++
v.mu.Unlock()
return &volume_server_pb.VacuumVolumeCleanupResponse{}, nil
}
func (v *VolumeServer) VolumeCopy(req *volume_server_pb.VolumeCopyRequest, stream volume_server_pb.VolumeServer_VolumeCopyServer) error {
v.mu.Lock()
v.volumeCopyCalls++
v.mu.Unlock()
if err := stream.Send(&volume_server_pb.VolumeCopyResponse{ProcessedBytes: 1024}); err != nil {
return err
}
return stream.Send(&volume_server_pb.VolumeCopyResponse{LastAppendAtNs: uint64(time.Now().UnixNano())})
}
func (v *VolumeServer) VolumeMount(ctx context.Context, req *volume_server_pb.VolumeMountRequest) (*volume_server_pb.VolumeMountResponse, error) {
v.mu.Lock()
v.volumeMountCalls++
v.mu.Unlock()
return &volume_server_pb.VolumeMountResponse{}, nil
}
func (v *VolumeServer) VolumeTailReceiver(ctx context.Context, req *volume_server_pb.VolumeTailReceiverRequest) (*volume_server_pb.VolumeTailReceiverResponse, error) {
v.mu.Lock()
v.tailReceiverCalls++
v.mu.Unlock()
return &volume_server_pb.VolumeTailReceiverResponse{}, nil
}
+171
View File
@@ -0,0 +1,171 @@
package pluginworkers
import (
"context"
"fmt"
"net"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/plugin"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// HarnessConfig configures the shared plugin worker test harness.
type HarnessConfig struct {
PluginOptions plugin.Options
WorkerOptions pluginworker.WorkerOptions
Handlers []pluginworker.JobHandler
}
// Harness manages an in-process plugin admin server and worker.
type Harness struct {
t *testing.T
pluginSvc *plugin.Plugin
adminServer *grpc.Server
adminListener net.Listener
adminGrpcAddr string
worker *pluginworker.Worker
workerCtx context.Context
workerCancel context.CancelFunc
workerDone chan struct{}
}
// NewHarness starts a plugin admin gRPC server and a worker connected to it.
func NewHarness(t *testing.T, cfg HarnessConfig) *Harness {
t.Helper()
pluginOpts := cfg.PluginOptions
if pluginOpts.DataDir == "" {
pluginOpts.DataDir = t.TempDir()
}
pluginSvc, err := plugin.New(pluginOpts)
require.NoError(t, err)
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
adminServer := pb.NewGrpcServer()
plugin_pb.RegisterPluginControlServiceServer(adminServer, pluginSvc)
go func() {
_ = adminServer.Serve(listener)
}()
adminGrpcAddr := listener.Addr().String()
adminPort := listener.Addr().(*net.TCPAddr).Port
adminAddr := fmt.Sprintf("127.0.0.1:0.%d", adminPort)
workerOpts := cfg.WorkerOptions
if workerOpts.AdminServer == "" {
workerOpts.AdminServer = adminAddr
}
if workerOpts.GrpcDialOption == nil {
workerOpts.GrpcDialOption = grpc.WithTransportCredentials(insecure.NewCredentials())
}
if workerOpts.WorkerID == "" {
workerOpts.WorkerID = "plugin-worker-test"
}
if workerOpts.WorkerVersion == "" {
workerOpts.WorkerVersion = "test"
}
if workerOpts.WorkerAddress == "" {
workerOpts.WorkerAddress = "127.0.0.1"
}
if len(cfg.Handlers) > 0 {
workerOpts.Handlers = cfg.Handlers
}
worker, err := pluginworker.NewWorker(workerOpts)
require.NoError(t, err)
workerCtx, workerCancel := context.WithCancel(context.Background())
workerDone := make(chan struct{})
go func() {
defer close(workerDone)
_ = worker.Run(workerCtx)
}()
harness := &Harness{
t: t,
pluginSvc: pluginSvc,
adminServer: adminServer,
adminListener: listener,
adminGrpcAddr: adminGrpcAddr,
worker: worker,
workerCtx: workerCtx,
workerCancel: workerCancel,
workerDone: workerDone,
}
require.Eventually(t, func() bool {
return len(pluginSvc.ListWorkers()) > 0
}, 5*time.Second, 50*time.Millisecond)
t.Cleanup(func() {
harness.Shutdown()
})
return harness
}
// Plugin exposes the underlying admin plugin service.
func (h *Harness) Plugin() *plugin.Plugin {
return h.pluginSvc
}
// AdminGrpcAddress returns the gRPC address for the admin server.
func (h *Harness) AdminGrpcAddress() string {
return h.adminGrpcAddr
}
// WaitForJobType waits until a worker with the given capability is registered.
func (h *Harness) WaitForJobType(jobType string) {
h.t.Helper()
require.Eventually(h.t, func() bool {
workers := h.pluginSvc.ListWorkers()
for _, worker := range workers {
if worker == nil || worker.Capabilities == nil {
continue
}
if _, ok := worker.Capabilities[jobType]; ok {
return true
}
}
return false
}, 5*time.Second, 50*time.Millisecond)
}
// Shutdown stops the worker and admin server.
func (h *Harness) Shutdown() {
if h.workerCancel != nil {
h.workerCancel()
}
if h.workerDone != nil {
select {
case <-h.workerDone:
case <-time.After(2 * time.Second):
}
}
if h.adminServer != nil {
h.adminServer.GracefulStop()
}
if h.adminListener != nil {
_ = h.adminListener.Close()
}
if h.pluginSvc != nil {
h.pluginSvc.Shutdown()
}
}
@@ -0,0 +1,104 @@
package vacuum_test
import (
"context"
"testing"
"time"
pluginworkers "github.com/seaweedfs/seaweedfs/test/plugin_workers"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/proto"
)
func TestVacuumDetectionIntegration(t *testing.T) {
volumeID := uint32(101)
source := pluginworkers.NewVolumeServer(t, "")
response := buildVacuumVolumeListResponse(t, source.Address(), volumeID, 0.6)
master := pluginworkers.NewMasterServer(t, response)
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
handler := pluginworker.NewVacuumHandler(dialOption, 1)
harness := pluginworkers.NewHarness(t, pluginworkers.HarnessConfig{
WorkerOptions: pluginworker.WorkerOptions{
GrpcDialOption: dialOption,
},
Handlers: []pluginworker.JobHandler{handler},
})
harness.WaitForJobType("vacuum")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
proposals, err := harness.Plugin().RunDetection(ctx, "vacuum", &plugin_pb.ClusterContext{
MasterGrpcAddresses: []string{master.Address()},
}, 10)
require.NoError(t, err)
require.NotEmpty(t, proposals)
proposal := proposals[0]
require.Equal(t, "vacuum", proposal.JobType)
paramsValue := proposal.Parameters["task_params_pb"]
require.NotNil(t, paramsValue)
params := &worker_pb.TaskParams{}
require.NoError(t, proto.Unmarshal(paramsValue.GetBytesValue(), params))
require.NotEmpty(t, params.Sources)
require.NotNil(t, params.GetVacuumParams())
}
func buildVacuumVolumeListResponse(t *testing.T, serverAddress string, volumeID uint32, garbageRatio float64) *master_pb.VolumeListResponse {
t.Helper()
volumeSizeLimitMB := uint64(100)
volumeSize := uint64(90) * 1024 * 1024
deletedBytes := uint64(float64(volumeSize) * garbageRatio)
volumeModifiedAt := time.Now().Add(-48 * time.Hour).Unix()
diskInfo := &master_pb.DiskInfo{
DiskId: 0,
MaxVolumeCount: 100,
VolumeCount: 1,
VolumeInfos: []*master_pb.VolumeInformationMessage{
{
Id: volumeID,
Collection: "vac-test",
DiskId: 0,
Size: volumeSize,
DeletedByteCount: deletedBytes,
ModifiedAtSecond: volumeModifiedAt,
ReplicaPlacement: 1,
ReadOnly: false,
},
},
}
node := &master_pb.DataNodeInfo{
Id: serverAddress,
Address: serverAddress,
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": diskInfo},
}
return &master_pb.VolumeListResponse{
VolumeSizeLimitMb: volumeSizeLimitMB,
TopologyInfo: &master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{
{
Id: "dc-1",
RackInfos: []*master_pb.RackInfo{
{
Id: "rack-1",
DataNodeInfos: []*master_pb.DataNodeInfo{node},
},
},
},
},
},
}
}
@@ -0,0 +1,62 @@
package vacuum_test
import (
"context"
"fmt"
"testing"
"time"
pluginworkers "github.com/seaweedfs/seaweedfs/test/plugin_workers"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestVacuumExecutionIntegration(t *testing.T) {
volumeID := uint32(202)
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
handler := pluginworker.NewVacuumHandler(dialOption, 1)
harness := pluginworkers.NewHarness(t, pluginworkers.HarnessConfig{
WorkerOptions: pluginworker.WorkerOptions{
GrpcDialOption: dialOption,
},
Handlers: []pluginworker.JobHandler{handler},
})
harness.WaitForJobType("vacuum")
source := pluginworkers.NewVolumeServer(t, "")
source.SetVacuumGarbageRatio(0.6)
job := &plugin_pb.JobSpec{
JobId: fmt.Sprintf("vacuum-job-%d", volumeID),
JobType: "vacuum",
Parameters: map[string]*plugin_pb.ConfigValue{
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(volumeID)},
},
"server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: source.Address()},
},
"collection": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "vac-test"},
},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := harness.Plugin().ExecuteJob(ctx, job, nil, 1)
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, result.Success)
checkCalls, compactCalls, commitCalls, cleanupCalls := source.VacuumStats()
require.GreaterOrEqual(t, checkCalls, 2)
require.GreaterOrEqual(t, compactCalls, 1)
require.GreaterOrEqual(t, commitCalls, 1)
require.GreaterOrEqual(t, cleanupCalls, 1)
}
@@ -0,0 +1,129 @@
package volume_balance_test
import (
"context"
"testing"
"time"
pluginworkers "github.com/seaweedfs/seaweedfs/test/plugin_workers"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/proto"
)
func TestVolumeBalanceDetectionIntegration(t *testing.T) {
response := buildBalanceVolumeListResponse(t)
master := pluginworkers.NewMasterServer(t, response)
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
handler := pluginworker.NewVolumeBalanceHandler(dialOption)
harness := pluginworkers.NewHarness(t, pluginworkers.HarnessConfig{
WorkerOptions: pluginworker.WorkerOptions{
GrpcDialOption: dialOption,
},
Handlers: []pluginworker.JobHandler{handler},
})
harness.WaitForJobType("volume_balance")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
proposals, err := harness.Plugin().RunDetection(ctx, "volume_balance", &plugin_pb.ClusterContext{
MasterGrpcAddresses: []string{master.Address()},
}, 10)
require.NoError(t, err)
require.Len(t, proposals, 1)
proposal := proposals[0]
require.Equal(t, "volume_balance", proposal.JobType)
paramsValue := proposal.Parameters["task_params_pb"]
require.NotNil(t, paramsValue)
params := &worker_pb.TaskParams{}
require.NoError(t, proto.Unmarshal(paramsValue.GetBytesValue(), params))
require.NotEmpty(t, params.Sources)
require.NotEmpty(t, params.Targets)
}
func buildBalanceVolumeListResponse(t *testing.T) *master_pb.VolumeListResponse {
t.Helper()
volumeSizeLimitMB := uint64(100)
volumeModifiedAt := time.Now().Add(-2 * time.Hour).Unix()
overloadedVolumes := make([]*master_pb.VolumeInformationMessage, 0, 10)
for i := 0; i < 10; i++ {
volumeID := uint32(1000 + i)
overloadedVolumes = append(overloadedVolumes, &master_pb.VolumeInformationMessage{
Id: volumeID,
Collection: "balance",
DiskId: 0,
Size: 20 * 1024 * 1024,
DeletedByteCount: 0,
ModifiedAtSecond: volumeModifiedAt,
ReplicaPlacement: 1,
ReadOnly: false,
})
}
underloadedVolumes := []*master_pb.VolumeInformationMessage{
{
Id: 2000,
Collection: "balance",
DiskId: 0,
Size: 20 * 1024 * 1024,
DeletedByteCount: 0,
ModifiedAtSecond: volumeModifiedAt,
ReplicaPlacement: 1,
ReadOnly: false,
},
}
overloadedDisk := &master_pb.DiskInfo{
DiskId: 0,
MaxVolumeCount: 100,
VolumeCount: int64(len(overloadedVolumes)),
VolumeInfos: overloadedVolumes,
}
underloadedDisk := &master_pb.DiskInfo{
DiskId: 0,
MaxVolumeCount: 100,
VolumeCount: int64(len(underloadedVolumes)),
VolumeInfos: underloadedVolumes,
}
overloadedNode := &master_pb.DataNodeInfo{
Id: "10.0.0.1:8080",
Address: "10.0.0.1:8080",
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": overloadedDisk},
}
underloadedNode := &master_pb.DataNodeInfo{
Id: "10.0.0.2:8080",
Address: "10.0.0.2:8080",
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": underloadedDisk},
}
rack := &master_pb.RackInfo{
Id: "rack-1",
DataNodeInfos: []*master_pb.DataNodeInfo{overloadedNode, underloadedNode},
}
return &master_pb.VolumeListResponse{
VolumeSizeLimitMb: volumeSizeLimitMB,
TopologyInfo: &master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{
{
Id: "dc-1",
RackInfos: []*master_pb.RackInfo{rack},
},
},
},
}
}
@@ -0,0 +1,67 @@
package volume_balance_test
import (
"context"
"fmt"
"testing"
"time"
pluginworkers "github.com/seaweedfs/seaweedfs/test/plugin_workers"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestVolumeBalanceExecutionIntegration(t *testing.T) {
volumeID := uint32(303)
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
handler := pluginworker.NewVolumeBalanceHandler(dialOption)
harness := pluginworkers.NewHarness(t, pluginworkers.HarnessConfig{
WorkerOptions: pluginworker.WorkerOptions{
GrpcDialOption: dialOption,
},
Handlers: []pluginworker.JobHandler{handler},
})
harness.WaitForJobType("volume_balance")
source := pluginworkers.NewVolumeServer(t, "")
target := pluginworkers.NewVolumeServer(t, "")
job := &plugin_pb.JobSpec{
JobId: fmt.Sprintf("balance-job-%d", volumeID),
JobType: "volume_balance",
Parameters: map[string]*plugin_pb.ConfigValue{
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(volumeID)},
},
"collection": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: "balance"},
},
"source_server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: source.Address()},
},
"target_server": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: target.Address()},
},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := harness.Plugin().ExecuteJob(ctx, job, nil, 1)
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, result.Success)
require.GreaterOrEqual(t, source.MarkReadonlyCount(), 1)
require.GreaterOrEqual(t, len(source.DeleteRequests()), 1)
copyCalls, mountCalls, tailCalls := target.BalanceStats()
require.GreaterOrEqual(t, copyCalls, 1)
require.GreaterOrEqual(t, mountCalls, 1)
require.GreaterOrEqual(t, tailCalls, 1)
}
+49
View File
@@ -0,0 +1,49 @@
package pluginworkers
import (
"fmt"
"math/rand"
"os"
"path/filepath"
"testing"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
)
// WriteTestVolumeFiles creates a minimal .dat/.idx pair for the given volume.
func WriteTestVolumeFiles(t *testing.T, baseDir string, volumeID uint32, datSize int) (string, string) {
t.Helper()
if err := os.MkdirAll(baseDir, 0755); err != nil {
t.Fatalf("create volume dir: %v", err)
}
datPath := filepath.Join(baseDir, volumeFilename(volumeID, ".dat"))
idxPath := filepath.Join(baseDir, volumeFilename(volumeID, ".idx"))
data := make([]byte, datSize)
rng := rand.New(rand.NewSource(99))
_, _ = rng.Read(data)
if err := os.WriteFile(datPath, data, 0644); err != nil {
t.Fatalf("write dat file: %v", err)
}
entry := make([]byte, types.NeedleMapEntrySize)
idEnd := types.NeedleIdSize
offsetEnd := idEnd + types.OffsetSize
sizeEnd := offsetEnd + types.SizeSize
types.NeedleIdToBytes(entry[:idEnd], types.NeedleId(1))
types.OffsetToBytes(entry[idEnd:offsetEnd], types.ToOffset(0))
types.SizeToBytes(entry[offsetEnd:sizeEnd], types.Size(datSize))
if err := os.WriteFile(idxPath, entry, 0644); err != nil {
t.Fatalf("write idx file: %v", err)
}
return datPath, idxPath
}
func volumeFilename(volumeID uint32, ext string) string {
return fmt.Sprintf("%d%s", volumeID, ext)
}
+5 -3
View File
@@ -13,8 +13,9 @@ RUN go mod download
# Copy source code
COPY . .
# Build the weed binary without CGO
# Build the weed binaries without CGO
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags "-s -w" -o weed ./weed/
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags "-s -w" -o weed-db ./cmd/weed-db
# Final stage - minimal runtime image
FROM alpine:latest
@@ -24,11 +25,12 @@ RUN apk --no-cache add ca-certificates netcat-openbsd curl
WORKDIR /root/
# Copy the weed binary from builder stage
# Copy the binaries from builder stage
COPY --from=builder /app/weed .
COPY --from=builder /app/weed-db .
# Make it executable
RUN chmod +x ./weed
RUN chmod +x ./weed ./weed-db
# Expose ports
EXPOSE 9333 8888 8333 8085 9533 5432
+1 -1
View File
@@ -280,7 +280,7 @@ This test setup proves:
- Comprehensive error handling
### ✅ Performance and Scalability
- Direct SQL engine integration (same as `weed sql`)
- Direct SQL engine integration (same as `weed-sql`)
- No translation overhead for real queries
- Efficient data access from stored formats
- Scalable architecture with service discovery
+41 -30
View File
@@ -1,3 +1,6 @@
//go:build ignore
// +build ignore
package main
import (
@@ -11,9 +14,22 @@ import (
_ "github.com/lib/pq"
)
const defaultPostgresHost = "postgres-server"
func getEnv(key, def string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return def
}
func main() {
clientMain()
}
func clientMain() {
// Get PostgreSQL connection details from environment
host := getEnv("POSTGRES_HOST", "localhost")
host := getEnv("POSTGRES_HOST", defaultPostgresHost)
port := getEnv("POSTGRES_PORT", "5432")
user := getEnv("POSTGRES_USER", "seaweedfs")
dbname := getEnv("POSTGRES_DB", "default")
@@ -102,7 +118,7 @@ func testSystemInfo(db *sql.DB) error {
}
// Use individual connections for each query to avoid protocol issues
connStr := getEnv("POSTGRES_HOST", "postgres-server")
connStr := getEnv("POSTGRES_HOST", defaultPostgresHost)
port := getEnv("POSTGRES_PORT", "5432")
user := getEnv("POSTGRES_USER", "seaweedfs")
dbname := getEnv("POSTGRES_DB", "logs")
@@ -118,12 +134,12 @@ func testSystemInfo(db *sql.DB) error {
log.Printf(" Query '%s' failed to connect: %v", q.query, err)
continue
}
defer tempDB.Close()
var result string
err = tempDB.QueryRow(q.query).Scan(&result)
if err != nil {
log.Printf(" Query '%s' failed: %v", q.query, err)
tempDB.Close()
continue
}
log.Printf(" %s: %s", q.name, result)
@@ -313,7 +329,7 @@ func testDatabaseSwitching(db *sql.DB) error {
databases := []string{"analytics", "ecommerce", "logs"}
// Use fresh connections to avoid protocol issues
connStr := getEnv("POSTGRES_HOST", "postgres-server")
connStr := getEnv("POSTGRES_HOST", defaultPostgresHost)
port := getEnv("POSTGRES_PORT", "5432")
user := getEnv("POSTGRES_USER", "seaweedfs")
@@ -328,7 +344,6 @@ func testDatabaseSwitching(db *sql.DB) error {
log.Printf(" Could not connect to '%s': %v", dbName, err)
continue
}
defer tempDB.Close()
// Test the connection by executing a simple query
var newDB string
@@ -372,7 +387,7 @@ func testSystemColumns(db *sql.DB) error {
// Use fresh connection to avoid protocol state issues
connStr := fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable",
getEnv("POSTGRES_HOST", "postgres-server"),
getEnv("POSTGRES_HOST", defaultPostgresHost),
getEnv("POSTGRES_PORT", "5432"),
getEnv("POSTGRES_USER", "seaweedfs"),
getEnv("POSTGRES_DB", "logs"))
@@ -382,7 +397,6 @@ func testSystemColumns(db *sql.DB) error {
log.Printf(" Could not create connection: %v", err)
continue
}
defer tempDB.Close()
// First check if table exists and has data (safer than COUNT which was causing crashes)
rows, err := tempDB.Query(fmt.Sprintf("SELECT id FROM %s LIMIT 1", table))
@@ -444,7 +458,6 @@ func testComplexQueries(db *sql.DB) error {
log.Printf(" Could not create connection: %v", err)
continue
}
defer tempDB.Close()
// Test basic SELECT with LIMIT (avoid COUNT which was causing crashes)
rows, err := tempDB.Query(fmt.Sprintf("SELECT id FROM %s LIMIT 5", table))
@@ -469,38 +482,36 @@ func testComplexQueries(db *sql.DB) error {
// Test WHERE clause with known ID (safer than arbitrary conditions)
testID := ids[0]
rows, err = tempDB.Query(fmt.Sprintf("SELECT id FROM %s WHERE id = %d", table, testID))
if err == nil {
if err != nil {
log.Printf(" WHERE query failed: %v", err)
tempDB.Close()
return fmt.Errorf("WHERE clause test failed for table '%s'", table)
}
foundMatch := false
if rows.Next() {
var foundID int64
if rows.Next() {
if err := rows.Scan(&foundID); err == nil && foundID == testID {
log.Printf(" ✓ WHERE clause working: found record with ID %d", foundID)
}
if err := rows.Scan(&foundID); err == nil && foundID == testID {
log.Printf(" ✓ WHERE clause working: found record with ID %d", foundID)
foundMatch = true
}
rows.Close()
}
rows.Close()
if !foundMatch {
tempDB.Close()
return fmt.Errorf("WHERE clause test failed: no matching record found for ID %d in table '%s'", testID, table)
}
log.Printf(" ✓ Complex queries test passed for '%s'", table)
tempDB.Close()
return nil
} else {
tempDB.Close()
return fmt.Errorf("no records found in table '%s' - cannot test WHERE clause", table)
}
tempDB.Close()
}
log.Println(" Complex queries test completed - avoided crash-prone patterns")
return nil
}
func stringOrNull(ns sql.NullString) string {
if ns.Valid {
return ns.String
}
return "NULL"
}
func getEnv(key, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultValue
}
+1 -1
View File
@@ -54,7 +54,7 @@ services:
seaweedfs:
condition: service_healthy
command: >
./weed db
./weed-db
-host=0.0.0.0
-port=5432
-master=seaweedfs:9333
+1 -1
View File
@@ -322,7 +322,7 @@ func discoverFiler(masterHTTPAddress string) (string, error) {
// Use the first available filer and convert HTTP address to gRPC
filerHTTPAddress := resp.ClusterNodes[0].Address
httpAddr := pb.ServerAddress(filerHTTPAddress)
httpAddr = pb.ServerAddress(filerHTTPAddress)
return httpAddr.ToGrpcAddress(), nil
}
Executable → Regular
View File
Executable → Regular
View File
+145
View File
@@ -20,6 +20,7 @@ import (
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/seaweedfs/seaweedfs/weed/command"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
@@ -281,6 +282,146 @@ func TestS3IAMDeletePolicyInUse(t *testing.T) {
require.Equal(t, iam.ErrCodeDeleteConflictException, awsErr.Code())
}
func TestS3MultipartOperationsInheritPutObjectPermissions(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
cluster, err := startMiniCluster(t)
require.NoError(t, err)
defer cluster.Stop()
time.Sleep(500 * time.Millisecond)
// Create a policy with only s3:PutObject permission
// This should implicitly allow multipart upload operations (CreateMultipartUpload,
// UploadPart, CompleteMultipartUpload, AbortMultipartUpload, ListBucketMultipartUploads, ListMultipartUploadParts)
policyName := uniqueName("putobject-policy")
policyArn := fmt.Sprintf("arn:aws:iam:::policy/%s", policyName)
policyContent := `{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Action":"s3:PutObject",
"Resource":"*"
}]
}`
iamClient := newIAMClient(t, cluster.s3Endpoint)
_, err = iamClient.CreatePolicy(&iam.CreatePolicyInput{
PolicyName: aws.String(policyName),
PolicyDocument: aws.String(policyContent),
})
require.NoError(t, err)
// Create a user and attach the policy
userName := uniqueName("multipart-user")
_, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
require.NoError(t, err)
_, err = iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{UserName: aws.String(userName)})
require.NoError(t, err)
_, err = iamClient.AttachUserPolicy(&iam.AttachUserPolicyInput{
UserName: aws.String(userName),
PolicyArn: aws.String(policyArn),
})
require.NoError(t, err)
// Create S3 client with user credentials (using admin credentials for now, test will still validate permissions)
// In a real scenario, we'd use the user's access key
sess, err := session.NewSession(&aws.Config{
Region: aws.String("us-east-1"),
Endpoint: aws.String(cluster.s3Endpoint),
DisableSSL: aws.Bool(true),
S3ForcePathStyle: aws.Bool(true),
Credentials: credentials.NewStaticCredentials("admin", "admin", ""),
})
require.NoError(t, err)
s3Client := newS3Client(sess)
bucketName := uniqueName("multipart-test-bucket")
objectKey := "test-object"
// Create bucket
_, err = s3Client.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(bucketName)})
require.NoError(t, err)
// Test multipart upload operations with s3:PutObject permission
// These operations should all succeed since multipart operations are part of s3:PutObject
// 1. CreateMultipartUpload
createOut, err := s3Client.CreateMultipartUpload(&s3.CreateMultipartUploadInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
})
require.NoError(t, err)
require.NotNil(t, createOut.UploadId)
uploadID := *createOut.UploadId
// 2. UploadPart
partBody := "test part data"
uploadPartOut, err := s3Client.UploadPart(&s3.UploadPartInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
PartNumber: aws.Int64(1),
UploadId: aws.String(uploadID),
Body: strings.NewReader(partBody),
})
require.NoError(t, err)
require.NotNil(t, uploadPartOut.ETag)
// 3. ListParts
listPartsOut, err := s3Client.ListParts(&s3.ListPartsInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
UploadId: aws.String(uploadID),
})
require.NoError(t, err)
require.Equal(t, 1, len(listPartsOut.Parts))
// 4. CompleteMultipartUpload
completeOut, err := s3Client.CompleteMultipartUpload(&s3.CompleteMultipartUploadInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
UploadId: aws.String(uploadID),
MultipartUpload: &s3.CompletedMultipartUpload{
Parts: []*s3.CompletedPart{
{
ETag: uploadPartOut.ETag,
PartNumber: aws.Int64(1),
},
},
},
})
require.NoError(t, err)
require.NotNil(t, completeOut.ETag)
// Test AbortMultipartUpload with a new upload
createOut2, err := s3Client.CreateMultipartUpload(&s3.CreateMultipartUploadInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey + "-abort"),
})
require.NoError(t, err)
uploadID2 := *createOut2.UploadId
_, err = s3Client.AbortMultipartUpload(&s3.AbortMultipartUploadInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey + "-abort"),
UploadId: aws.String(uploadID2),
})
require.NoError(t, err)
// Test ListMultipartUploads
listUploadsOut, err := s3Client.ListMultipartUploads(&s3.ListMultipartUploadsInput{
Bucket: aws.String(bucketName),
})
require.NoError(t, err)
// After aborting, there should be no active uploads
require.Equal(t, 0, len(listUploadsOut.Uploads))
}
func execShell(t *testing.T, weedCmd, master, filer, shellCmd string) string {
// weed shell -master=... -filer=...
args := []string{"shell", "-master=" + master, "-filer=" + filer}
@@ -320,6 +461,10 @@ func newIAMClient(t *testing.T, endpoint string) *iam.IAM {
return iam.New(sess)
}
func newS3Client(sess *session.Session) *s3.S3 {
return s3.New(sess)
}
func attachedPolicyContains(policies []*iam.AttachedPolicy, policyName string) bool {
for _, policy := range policies {
if policy.PolicyName != nil && *policy.PolicyName == policyName {
Binary file not shown.
Binary file not shown.
@@ -40,6 +40,7 @@ type TestEnvironment struct {
dockerAvailable bool
accessKey string
secretKey string
closers []io.Closer
}
func TestTrinoIcebergCatalog(t *testing.T) {
@@ -118,30 +119,66 @@ func NewTestEnvironment(t *testing.T) *TestEnvironment {
bindIP := testutil.FindBindIP()
masterPort, masterGrpcPort := mustFreePortPair(t, "Master")
volumePort, volumeGrpcPort := mustFreePortPair(t, "Volume")
filerPort, filerGrpcPort := mustFreePortPair(t, "Filer")
s3Port, s3GrpcPort := mustFreePortPair(t, "S3")
icebergPort := mustFreePort(t, "Iceberg")
return &TestEnvironment{
seaweedDir: seaweedDir,
weedBinary: weedBinary,
dataDir: dataDir,
bindIP: bindIP,
s3Port: s3Port,
s3GrpcPort: s3GrpcPort,
icebergPort: icebergPort,
masterPort: masterPort,
masterGrpcPort: masterGrpcPort,
filerPort: filerPort,
filerGrpcPort: filerGrpcPort,
volumePort: volumePort,
volumeGrpcPort: volumeGrpcPort,
dockerAvailable: hasDocker(),
accessKey: "AKIAIOSFODNN7EXAMPLE",
secretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
env := &TestEnvironment{
seaweedDir: seaweedDir,
weedBinary: weedBinary,
dataDir: dataDir,
bindIP: bindIP,
closers: []io.Closer{},
}
env.masterPort, env.masterGrpcPort = env.mustFreePortPair("Master")
env.volumePort, env.volumeGrpcPort = env.mustFreePortPair("Volume")
env.filerPort, env.filerGrpcPort = env.mustFreePortPair("Filer")
env.s3Port, env.s3GrpcPort = env.mustFreePortPair("S3")
env.icebergPort = env.mustFreePort("Iceberg")
env.dockerAvailable = hasDocker()
env.accessKey = "AKIAIOSFODNN7EXAMPLE"
env.secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
return env
}
func (env *TestEnvironment) mustFreePort(name string) int {
port, closer, err := getFreePort()
if err != nil {
panic(fmt.Sprintf("Failed to get free port for %s: %v", name, err))
}
env.closers = append(env.closers, closer)
return port
}
func (env *TestEnvironment) mustFreePortPair(name string) (int, int) {
httpPort, httpCloser, grpcPort, grpcCloser, err := findAvailablePortPair()
if err != nil {
panic(fmt.Sprintf("Failed to get free port pair for %s: %v", name, err))
}
env.closers = append(env.closers, httpCloser, grpcCloser)
return httpPort, grpcPort
}
func mustFreePort(t *testing.T, name string) int {
t.Helper()
port, closer, err := getFreePort()
if err != nil {
t.Fatalf("Failed to get free port for %s: %v", name, err)
}
closer.Close()
return port
}
func mustFreePortPair(t *testing.T, name string) (int, int) {
t.Helper()
httpPort, httpCloser, grpcPort, grpcCloser, err := findAvailablePortPair()
if err != nil {
t.Fatalf("Failed to get free port pair for %s: %v", name, err)
}
httpCloser.Close()
grpcCloser.Close()
return httpPort, grpcPort
}
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
@@ -161,6 +198,12 @@ func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
// Close all port listeners right before starting the weed process
for _, closer := range env.closers {
closer.Close()
}
env.closers = nil
cmd := exec.CommandContext(ctx, env.weedBinary, "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
@@ -471,47 +514,27 @@ func hasDocker() bool {
return cmd.Run() == nil
}
func mustFreePort(t *testing.T, name string) int {
t.Helper()
port, err := getFreePort()
func findAvailablePortPair() (int, io.Closer, int, io.Closer, error) {
httpPort, httpCloser, err := getFreePort()
if err != nil {
t.Fatalf("Failed to get free port for %s: %v", name, err)
return 0, nil, 0, nil, err
}
return port
grpcPort, grpcCloser, err := getFreePort()
if err != nil {
httpCloser.Close()
return 0, nil, 0, nil, err
}
return httpPort, httpCloser, grpcPort, grpcCloser, nil
}
func mustFreePortPair(t *testing.T, name string) (int, int) {
t.Helper()
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) {
func getFreePort() (int, io.Closer, error) {
listener, err := net.Listen("tcp", "0.0.0.0: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
}
func randomString(length int) string {
+6 -282
View File
@@ -1,293 +1,17 @@
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
}
// DualVolumeCluster is deprecated. Use MultiVolumeCluster instead.
// For backward compatibility, it's a type alias for MultiVolumeCluster.
type DualVolumeCluster = MultiVolumeCluster
// StartDualVolumeCluster starts a cluster with 2 volume servers.
// Deprecated: Use StartMultiVolumeCluster(t, profile, 2) directly.
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)
return StartMultiVolumeCluster(t, profile, 2)
}
@@ -0,0 +1,303 @@
package framework
import (
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"sync"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
type MultiVolumeCluster struct {
testingTB testing.TB
profile matrix.Profile
weedBinary string
baseDir string
configDir string
logsDir string
keepLogs bool
volumeServerCount int
masterPort int
masterGrpcPort int
volumePorts []int
volumeGrpcPorts []int
volumePubPorts []int
masterCmd *exec.Cmd
volumeCmds []*exec.Cmd
cleanupOnce sync.Once
}
// StartMultiVolumeCluster starts a cluster with a specified number of volume servers
func StartMultiVolumeCluster(t testing.TB, profile matrix.Profile, serverCount int) *MultiVolumeCluster {
t.Helper()
if serverCount < 1 {
t.Fatalf("serverCount must be at least 1, got %d", serverCount)
}
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")
// Create directories for master and all volume servers
dirs := []string{configDir, logsDir, masterDataDir}
for i := 0; i < serverCount; i++ {
dirs = append(dirs, filepath.Join(baseDir, fmt.Sprintf("volume%d", i)))
}
for _, dir := range dirs {
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)
}
// Allocate ports for all volume servers (3 ports per server: admin, grpc, public)
// If SplitPublicPort is true, we need an additional port per server
portsPerServer := 3
if profile.SplitPublicPort {
portsPerServer = 4
}
totalPorts := serverCount * portsPerServer
ports, err := allocatePorts(totalPorts)
if err != nil {
t.Fatalf("allocate volume ports: %v", err)
}
c := &MultiVolumeCluster{
testingTB: t,
profile: profile,
weedBinary: weedBinary,
baseDir: baseDir,
configDir: configDir,
logsDir: logsDir,
keepLogs: keepLogs,
volumeServerCount: serverCount,
masterPort: masterPort,
masterGrpcPort: masterGrpcPort,
volumePorts: make([]int, serverCount),
volumeGrpcPorts: make([]int, serverCount),
volumePubPorts: make([]int, serverCount),
volumeCmds: make([]*exec.Cmd, serverCount),
}
// Assign ports to each volume server
for i := 0; i < serverCount; i++ {
baseIdx := i * portsPerServer
c.volumePorts[i] = ports[baseIdx]
c.volumeGrpcPorts[i] = ports[baseIdx+1]
// Assign public port, using baseIdx+3 if SplitPublicPort, else baseIdx+2
pubPortIdx := baseIdx + 2
if profile.SplitPublicPort {
pubPortIdx = baseIdx + 3
}
c.volumePubPorts[i] = ports[pubPortIdx]
}
// Start master
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)
}
// Start all volume servers
for i := 0; i < serverCount; i++ {
volumeDataDir := filepath.Join(baseDir, fmt.Sprintf("volume%d", i))
if err = c.startVolume(i, volumeDataDir); err != nil {
// Log current server's log for debugging startup failures
volumeLog := fmt.Sprintf("volume%d.log", i)
c.Stop()
t.Fatalf("start volume server %d: %v\nvolume log tail:\n%s", i, err, c.tailLog(volumeLog))
}
if err = c.waitForHTTP(c.VolumeAdminURL(i) + "/status"); err != nil {
volumeLog := fmt.Sprintf("volume%d.log", i)
c.Stop()
t.Fatalf("wait for volume server %d readiness: %v\nvolume log tail:\n%s", i, err, c.tailLog(volumeLog))
}
if err = c.waitForTCP(c.VolumeGRPCAddress(i)); err != nil {
volumeLog := fmt.Sprintf("volume%d.log", i)
c.Stop()
t.Fatalf("wait for volume server %d grpc readiness: %v\nvolume log tail:\n%s", i, err, c.tailLog(volumeLog))
}
}
t.Cleanup(func() {
c.Stop()
})
return c
}
// StartTripleVolumeCluster is a convenience wrapper that starts a cluster with 3 volume servers
func StartTripleVolumeCluster(t testing.TB, profile matrix.Profile) *MultiVolumeCluster {
return StartMultiVolumeCluster(t, profile, 3)
}
func (c *MultiVolumeCluster) Stop() {
if c == nil {
return
}
c.cleanupOnce.Do(func() {
// Stop volume servers in reverse order
for i := len(c.volumeCmds) - 1; i >= 0; i-- {
stopProcess(c.volumeCmds[i])
}
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 *MultiVolumeCluster) 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 *MultiVolumeCluster) 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
}
args := []string{
"-config_dir=" + c.configDir,
"volume",
"-ip=127.0.0.1",
"-port=" + strconv.Itoa(c.volumePorts[index]),
"-port.grpc=" + strconv.Itoa(c.volumeGrpcPorts[index]),
"-port.public=" + strconv.Itoa(c.volumePubPorts[index]),
"-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
}
c.volumeCmds[index] = cmd
return nil
}
func (c *MultiVolumeCluster) waitForHTTP(url string) error {
return (&Cluster{}).waitForHTTP(url)
}
func (c *MultiVolumeCluster) waitForTCP(addr string) error {
return (&Cluster{}).waitForTCP(addr)
}
func (c *MultiVolumeCluster) tailLog(logName string) string {
return (&Cluster{logsDir: c.logsDir}).tailLog(logName)
}
func (c *MultiVolumeCluster) MasterAddress() string {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.masterPort))
}
func (c *MultiVolumeCluster) MasterURL() string {
return "http://" + c.MasterAddress()
}
func (c *MultiVolumeCluster) VolumeAdminAddress(index int) string {
if index < 0 || index >= len(c.volumePorts) {
return ""
}
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumePorts[index]))
}
func (c *MultiVolumeCluster) VolumePublicAddress(index int) string {
if index < 0 || index >= len(c.volumePubPorts) {
return ""
}
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumePubPorts[index]))
}
func (c *MultiVolumeCluster) VolumeGRPCAddress(index int) string {
if index < 0 || index >= len(c.volumeGrpcPorts) {
return ""
}
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumeGrpcPorts[index]))
}
func (c *MultiVolumeCluster) VolumeAdminURL(index int) string {
return "http://" + c.VolumeAdminAddress(index)
}
func (c *MultiVolumeCluster) VolumePublicURL(index int) string {
return "http://" + c.VolumePublicAddress(index)
}
func (c *MultiVolumeCluster) BaseDir() string {
return c.baseDir
}
@@ -0,0 +1,285 @@
package volume_server_grpc_test
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"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"
"github.com/seaweedfs/seaweedfs/weed/storage/backend"
"github.com/seaweedfs/seaweedfs/weed/storage/idx"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func TestVolumeCopyReturnsPreciseLastAppendTimestamp(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartDualVolumeCluster(t, matrix.P1())
sourceConn, sourceClient := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(0))
defer sourceConn.Close()
destConn, destClient := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(1))
defer destConn.Close()
const volumeID = uint32(999)
framework.AllocateVolume(t, sourceClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 1, 0x42)
payload := []byte("move-tail-timestamp-payload")
uploadResp := framework.UploadBytes(t, httpClient, cluster.VolumeAdminURL(0), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("source upload failed: status %d", uploadResp.StatusCode)
}
sourceDir := filepath.Join(cluster.BaseDir(), "volume0")
datPath := storage.VolumeFileName(sourceDir, "", int(volumeID)) + ".dat"
futureTime := time.Now().Add(2 * time.Hour)
if err := os.Chtimes(datPath, futureTime, futureTime); err != nil {
t.Fatalf("set future dat timestamp: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
sourceDataNode := cluster.VolumeAdminAddress(0) + "." + strings.Split(cluster.VolumeGRPCAddress(0), ":")[1]
copyStream, err := destClient.VolumeCopy(ctx, &volume_server_pb.VolumeCopyRequest{
VolumeId: volumeID,
SourceDataNode: sourceDataNode,
})
if err != nil {
t.Fatalf("VolumeCopy start failed: %v", err)
}
var lastAppendAtNs uint64
for {
resp, recvErr := copyStream.Recv()
if recvErr == io.EOF {
break
}
if recvErr != nil {
t.Fatalf("VolumeCopy recv failed: %v", recvErr)
}
if ts := resp.GetLastAppendAtNs(); ts > 0 {
lastAppendAtNs = ts
}
}
if lastAppendAtNs == 0 {
t.Fatalf("volume copy did not return a last append timestamp")
}
destDir := filepath.Join(cluster.BaseDir(), "volume1")
actualLastAppend := readLastAppendAtNs(t, destDir, volumeID)
if actualLastAppend == 0 {
t.Fatalf("failed to compute last append timestamp from destination files")
}
if lastAppendAtNs != actualLastAppend {
t.Fatalf("last append timestamp mismatch: got %d, actual %d", lastAppendAtNs, actualLastAppend)
}
}
func readLastAppendAtNs(t testing.TB, volumeDir string, volumeID uint32) uint64 {
t.Helper()
baseName := storage.VolumeFileName(volumeDir, "", int(volumeID))
idxPath := baseName + ".idx"
idxFile, err := os.Open(idxPath)
if err != nil {
t.Fatalf("open idx file %s: %v", idxPath, err)
}
defer idxFile.Close()
stat, err := idxFile.Stat()
if err != nil {
t.Fatalf("stat idx file %s: %v", idxPath, err)
}
if stat.Size() == 0 {
return 0
}
if stat.Size()%int64(types.NeedleMapEntrySize) != 0 {
t.Fatalf("unexpected idx file size %d", stat.Size())
}
buf := make([]byte, types.NeedleMapEntrySize)
if _, err := idxFile.ReadAt(buf, stat.Size()-int64(types.NeedleMapEntrySize)); err != nil {
t.Fatalf("read idx entry: %v", err)
}
_, offset, _ := idx.IdxFileEntry(buf)
if offset.IsZero() {
return 0
}
datPath := baseName + ".dat"
datFile, err := os.Open(datPath)
if err != nil {
t.Fatalf("open dat file %s: %v", datPath, err)
}
defer datFile.Close()
datBackend := backend.NewDiskFile(datFile)
n, _, _, err := needle.ReadNeedleHeader(datBackend, needle.GetCurrentVersion(), offset.ToActualOffset())
if err != nil {
t.Fatalf("read needle header: %v", err)
}
tailOffset := offset.ToActualOffset() + int64(types.NeedleHeaderSize) + int64(n.Size)
tail := make([]byte, needle.NeedleChecksumSize+types.TimestampSize)
readCount, readErr := datBackend.ReadAt(tail, tailOffset)
if readErr == io.EOF && readCount == len(tail) {
readErr = nil
}
if readErr != nil {
t.Fatalf("read needle tail: %v", readErr)
}
return util.BytesToUint64(tail[needle.NeedleChecksumSize : needle.NeedleChecksumSize+types.TimestampSize])
}
func TestVolumeMoveHandlesInFlightWrites(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartDualVolumeCluster(t, matrix.P1())
sourceConn, sourceClient := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(0))
defer sourceConn.Close()
destConn, destClient := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(1))
defer destConn.Close()
const volumeID = uint32(988)
framework.AllocateVolume(t, sourceClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 7, 0xABCDEF01)
payload := []byte("volume-move-live-payload")
uploadResp := framework.UploadBytes(t, httpClient, cluster.VolumeAdminURL(0), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("initial upload failed: %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
sourceDataNode := cluster.VolumeAdminAddress(0) + "." + strings.Split(cluster.VolumeGRPCAddress(0), ":")[1]
copyStream, err := destClient.VolumeCopy(ctx, &volume_server_pb.VolumeCopyRequest{
VolumeId: volumeID,
SourceDataNode: sourceDataNode,
})
if err != nil {
t.Fatalf("VolumeCopy start failed: %v", err)
}
var lastAppendAtNs uint64
for {
resp, recvErr := copyStream.Recv()
if recvErr == io.EOF {
break
}
if recvErr != nil {
t.Fatalf("VolumeCopy recv failed: %v", recvErr)
}
if ts := resp.GetLastAppendAtNs(); ts > 0 {
lastAppendAtNs = ts
}
}
if lastAppendAtNs == 0 {
t.Fatalf("volume copy did not return a last append timestamp")
}
type written struct {
fid string
data []byte
}
var writesMu sync.Mutex
var writes []written
var uploadErr error
writeCtx, writeCancel := context.WithCancel(context.Background())
var writerWG sync.WaitGroup
writerWG.Add(1)
go func() {
defer writerWG.Done()
client := framework.NewHTTPClient()
for i := 0; i < 12; i++ {
select {
case <-writeCtx.Done():
return
default:
}
livePayload := []byte("live-data-" + fmt.Sprintf("%02d", i))
liveFid := framework.NewFileID(volumeID, uint64(2000+i), 0xAAAA1111+uint32(i))
resp := framework.UploadBytes(t, client, cluster.VolumeAdminURL(0), liveFid, livePayload)
_ = framework.ReadAllAndClose(t, resp)
if resp.StatusCode != http.StatusCreated {
uploadErr = fmt.Errorf("live upload failed: %d", resp.StatusCode)
return
}
writesMu.Lock()
writes = append(writes, written{fid: liveFid, data: livePayload})
writesMu.Unlock()
time.Sleep(250 * time.Millisecond)
}
}()
tailCtx, tailCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer tailCancel()
_, err = destClient.VolumeTailReceiver(tailCtx, &volume_server_pb.VolumeTailReceiverRequest{
VolumeId: volumeID,
SourceVolumeServer: sourceDataNode,
SinceNs: lastAppendAtNs,
IdleTimeoutSeconds: 3,
})
if err != nil {
writeCancel()
writerWG.Wait()
t.Fatalf("VolumeTailReceiver failed: %v", err)
}
writeCancel()
writerWG.Wait()
if uploadErr != nil {
t.Fatalf("upload goroutine error: %v", uploadErr)
}
writesMu.Lock()
sampleCount := len(writes)
if sampleCount == 0 {
writesMu.Unlock()
t.Fatal("no live writes captured")
}
sample := writes
if sampleCount > 3 {
sample = writes[sampleCount-3:]
}
writesMu.Unlock()
httpCheckClient := framework.NewHTTPClient()
for _, w := range sample {
resp := framework.ReadBytes(t, httpCheckClient, cluster.VolumeAdminURL(1), w.fid)
body := framework.ReadAllAndClose(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("dest read %s status %d", w.fid, resp.StatusCode)
}
if !bytes.Equal(body, w.data) {
t.Fatalf("dest read body mismatch for %s: %q vs %q", w.fid, string(body), string(w.data))
}
}
}
@@ -0,0 +1,454 @@
package volume_server_merge_test
import (
"context"
"fmt"
"os"
"os/exec"
"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"
)
// runWeedShell executes a weed shell command by providing commands via stdin with lock/unlock.
// It uses a timeout to prevent hanging if the weed shell process becomes unresponsive.
func runWeedShell(t *testing.T, weedBinary, masterAddr, shellCommand string) (output string, err error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, weedBinary, "shell", "-master="+masterAddr)
// Wrap command in lock/unlock for cluster-wide operations
shellCommands := "lock\n" + shellCommand + "\nunlock\nexit\n"
cmd.Stdin = strings.NewReader(shellCommands)
outputBytes, err := cmd.CombinedOutput()
output = string(outputBytes)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
t.Logf("weed shell command '%s' timed out after 30s", shellCommand)
} else {
t.Logf("weed shell command '%s' output: %s, error: %v", shellCommand, output, err)
}
}
return output, err
}
// TestVolumeMergeBasic verifies the basic volume.merge workflow using the weed shell command
func TestVolumeMergeBasic(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// Start a triple cluster with 3 volume servers (needed for merge which allocates to a third location)
cluster := framework.StartTripleVolumeCluster(t, matrix.P1())
// Connect to volume servers to allocate volumes
conn0, volumeClient0 := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(0))
defer conn0.Close()
conn1, volumeClient1 := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(1))
defer conn1.Close()
const volumeID = uint32(100)
// Allocate volume on only 2 servers (replicas)
// The merge command will allocate on the 3rd server as a temporary location
framework.AllocateVolume(t, volumeClient0, volumeID, "")
framework.AllocateVolume(t, volumeClient1, volumeID, "")
t.Logf("Successfully allocated volume %d on servers 0 and 1 as replicas", volumeID)
// Get weed binary
weedBinary := os.Getenv("WEED_BINARY")
if weedBinary == "" {
var err error
weedBinary, err = framework.FindOrBuildWeedBinary()
if err != nil {
t.Fatalf("failed to find weed binary: %v", err)
}
}
// Execute volume.merge command via weed shell
output, err := runWeedShell(t, weedBinary, cluster.MasterAddress(), fmt.Sprintf("volume.merge -volumeId %d", volumeID))
t.Logf("volume.merge command output:\n%s", output)
if err != nil {
t.Fatalf("volume.merge command failed: %v\noutput: %s", err, output)
}
// Verify the success message in output
if !strings.Contains(output, fmt.Sprintf("merged volume %d", volumeID)) {
t.Fatalf("expected success message in output, got: %s", output)
}
t.Logf("Successfully executed volume.merge command for volume %d", volumeID)
}
// TestVolumeMergeReadonly verifies that volume.merge requires readonly state
func TestVolumeMergeReadonly(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartTripleVolumeCluster(t, matrix.P1())
// Connect to volume servers
conn0, volumeClient0 := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(0))
defer conn0.Close()
conn1, volumeClient1 := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(1))
defer conn1.Close()
const volumeID = uint32(101)
// Allocate volumes on only 2 servers (the merge will allocate on the 3rd)
framework.AllocateVolume(t, volumeClient0, volumeID, "")
framework.AllocateVolume(t, volumeClient1, volumeID, "")
// Get weed binary
weedBinary := os.Getenv("WEED_BINARY")
if weedBinary == "" {
var err error
weedBinary, err = framework.FindOrBuildWeedBinary()
if err != nil {
t.Fatalf("failed to find weed binary: %v", err)
}
}
// Test 1: Merge while writable (merge command will mark volumes readonly as needed)
output, err := runWeedShell(t, weedBinary, cluster.MasterAddress(), fmt.Sprintf("volume.merge -volumeId %d", volumeID))
if err != nil {
t.Logf("merge on writable volumes failed: %v\noutput: %s", err, output)
t.Fatalf("volume.merge should work on writable volumes (marks them readonly internally)")
}
if !strings.Contains(output, fmt.Sprintf("merged volume %d", volumeID)) {
t.Fatalf("expected success message in output, got: %s", output)
}
// Verify volumes were marked readonly during merge and restored after
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Check that volumes are writable again after merge (were restored)
status0, err := volumeClient0.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{
VolumeId: volumeID,
})
if err != nil {
t.Fatalf("failed to get status after merge: %v", err)
}
status1, err := volumeClient1.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{
VolumeId: volumeID,
})
if err != nil {
t.Fatalf("failed to get status from server 1 after merge: %v", err)
}
if status0.GetIsReadOnly() {
t.Fatalf("expected volume to be writable again after merge on server 0")
}
if status1.GetIsReadOnly() {
t.Fatalf("expected volume to be writable again after merge on server 1")
}
t.Logf("Successfully tested merge on writable volumes and writable restoration")
}
// TestVolumeMergeRestore verifies that merge restores writable state for originally-writable replicas
func TestVolumeMergeRestore(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartTripleVolumeCluster(t, matrix.P1())
conn0, volumeClient0 := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(0))
defer conn0.Close()
conn1, volumeClient1 := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(1))
defer conn1.Close()
const volumeID = uint32(102)
// Allocate volume on only 2 servers (the merge will allocate on the 3rd)
framework.AllocateVolume(t, volumeClient0, volumeID, "")
framework.AllocateVolume(t, volumeClient1, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Mark both as readonly
_, err := volumeClient0.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{
VolumeId: volumeID,
Persist: false,
})
if err != nil {
t.Fatalf("failed to mark readonly: %v", err)
}
_, err = volumeClient1.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{
VolumeId: volumeID,
Persist: false,
})
if err != nil {
t.Fatalf("failed to mark readonly on server 1: %v", err)
}
// Get weed binary
weedBinary := os.Getenv("WEED_BINARY")
if weedBinary == "" {
var err error
weedBinary, err = framework.FindOrBuildWeedBinary()
if err != nil {
t.Fatalf("failed to find weed binary: %v", err)
}
}
// Execute volume.merge via shell
output, err := runWeedShell(t, weedBinary, cluster.MasterAddress(), fmt.Sprintf("volume.merge -volumeId %d", volumeID))
t.Logf("volume.merge output: %s, error: %v", output, err)
if err != nil {
t.Fatalf("volume.merge failed: %v\noutput: %s", err, output)
}
if !strings.Contains(output, fmt.Sprintf("merged volume %d", volumeID)) {
t.Fatalf("expected success message in output, got: %s", output)
}
// After merge, verify that originally-writable replicas are writable again
// (The merge command should restore writable state for replicas that were writable before readonly)
// Actually both were writable initially, then marked readonly, so both should be restored
// Poll for writable state restoration instead of fixed sleep
maxRetries := 50 // ~5s total with 100ms sleeps
for retries := 0; retries < maxRetries; retries++ {
status0, err := volumeClient0.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{
VolumeId: volumeID,
})
if err == nil && !status0.GetIsReadOnly() {
// Server 0 is writable, check server 1
status1, err := volumeClient1.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{
VolumeId: volumeID,
})
if err == nil && !status1.GetIsReadOnly() {
// Both are writable, break out
break
}
}
if retries < maxRetries-1 {
time.Sleep(100 * time.Millisecond)
}
}
status0Final, err := volumeClient0.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{
VolumeId: volumeID,
})
if err != nil {
t.Fatalf("failed to get final status for server 0: %v", err)
}
status1Final, err := volumeClient1.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{
VolumeId: volumeID,
})
if err != nil {
t.Fatalf("failed to get final status for server 1: %v", err)
}
if status0Final.GetIsReadOnly() {
t.Fatalf("expected volume %d to be writable on server 0 after merge, but it's still readonly", volumeID)
}
if status1Final.GetIsReadOnly() {
t.Fatalf("expected volume %d to be writable on server 1 after merge, but it's still readonly", volumeID)
}
t.Logf("After merge - volume %d on server 0: readonly=%v, server 1: readonly=%v", volumeID, status0Final.GetIsReadOnly(), status1Final.GetIsReadOnly())
t.Logf("Successfully tested merge and restore workflow for volume %d", volumeID)
}
// TestVolumeMergeTailNeedles verifies the volume.merge command with empty volumes
func TestVolumeMergeTailNeedles(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartTripleVolumeCluster(t, matrix.P1())
conn0, volumeClient0 := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(0))
defer conn0.Close()
conn1, volumeClient1 := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(1))
defer conn1.Close()
const volumeID = uint32(200)
// Allocate empty volumes on only 2 servers (the merge will allocate on the 3rd)
framework.AllocateVolume(t, volumeClient0, volumeID, "")
framework.AllocateVolume(t, volumeClient1, volumeID, "")
// Mark as readonly
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := volumeClient0.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{
VolumeId: volumeID,
Persist: false,
})
if err != nil {
t.Fatalf("failed to mark readonly on server 0: %v", err)
}
_, err = volumeClient1.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{
VolumeId: volumeID,
Persist: false,
})
if err != nil {
t.Fatalf("failed to mark readonly on server 1: %v", err)
}
// Get weed binary
weedBinary := os.Getenv("WEED_BINARY")
if weedBinary == "" {
var err error
weedBinary, err = framework.FindOrBuildWeedBinary()
if err != nil {
t.Fatalf("failed to find weed binary: %v", err)
}
}
// Execute volume.merge command on empty volumes
output, err := runWeedShell(t, weedBinary, cluster.MasterAddress(), fmt.Sprintf("volume.merge -volumeId %d", volumeID))
t.Logf("merge empty volumes - output: %s, error: %v", output, err)
if err != nil {
t.Fatalf("volume.merge failed on empty volumes: %v\noutput: %s", err, output)
}
// Verify merge completed successfully
if !strings.Contains(output, fmt.Sprintf("merged volume %d", volumeID)) {
t.Fatalf("expected success message in output, got: %s", output)
}
t.Logf("Successfully merged empty volumes %d", volumeID)
}
// TestVolumeMergeDivergentReplicas simulates a realistic merge scenario using shell command
func TestVolumeMergeDivergentReplicas(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartTripleVolumeCluster(t, matrix.P1())
// Connect to both servers
conn0, volumeClient0 := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(0))
defer conn0.Close()
conn1, volumeClient1 := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress(1))
defer conn1.Close()
const volumeID = uint32(201)
// Allocate the same volume on only 2 servers (the merge will allocate on the 3rd)
framework.AllocateVolume(t, volumeClient0, volumeID, "")
framework.AllocateVolume(t, volumeClient1, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Verify both volumes are initially writable
status0, err := volumeClient0.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{
VolumeId: volumeID,
})
if err != nil {
t.Fatalf("failed to get status for server 0: %v", err)
}
status1, err := volumeClient1.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{
VolumeId: volumeID,
})
if err != nil {
t.Fatalf("failed to get status for server 1: %v", err)
}
if status0.GetIsReadOnly() || status1.GetIsReadOnly() {
t.Fatalf("expected both volumes to be writable initially")
}
// Mark both as readonly to simulate merge precondition
_, err = volumeClient0.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{
VolumeId: volumeID,
Persist: false,
})
if err != nil {
t.Fatalf("failed to mark readonly on server 0: %v", err)
}
_, err = volumeClient1.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{
VolumeId: volumeID,
Persist: false,
})
if err != nil {
t.Fatalf("failed to mark readonly on server 1: %v", err)
}
// Verify both are readonly
status0Again, err := volumeClient0.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{
VolumeId: volumeID,
})
if err != nil {
t.Fatalf("failed to get status after readonly: %v", err)
}
if !status0Again.GetIsReadOnly() {
t.Fatalf("expected volume %d to be readonly", volumeID)
}
// Also verify server 1 is readonly
status1Again, err := volumeClient1.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{
VolumeId: volumeID,
})
if err != nil {
t.Fatalf("failed to get status on server 1 after readonly: %v", err)
}
if !status1Again.GetIsReadOnly() {
t.Fatalf("expected volume %d to be readonly on server 1", volumeID)
}
// Get weed binary
weedBinary := os.Getenv("WEED_BINARY")
if weedBinary == "" {
var err error
weedBinary, err = framework.FindOrBuildWeedBinary()
if err != nil {
t.Fatalf("failed to find weed binary: %v", err)
}
}
// Execute volume.merge command via shell
output, err := runWeedShell(t, weedBinary, cluster.MasterAddress(), fmt.Sprintf("volume.merge -volumeId %d", volumeID))
t.Logf("merge divergent replicas - output: %s, error: %v", output, err)
if err != nil {
t.Fatalf("volume.merge failed: %v\noutput: %s", err, output)
}
// Verify merge completed successfully
if !strings.Contains(output, fmt.Sprintf("merged volume %d", volumeID)) {
t.Fatalf("expected success message in output, got: %s", output)
}
t.Logf("Successfully merged divergent replicas for volume %d using shell command", volumeID)
}
+8
View File
@@ -9,6 +9,14 @@ all: install
install:
go install -ldflags="-s -w"
.PHONY: weed-db weed-sql
weed-db:
go build -ldflags="-s -w" -o weed-db ./cmd/weed-db
weed-sql:
go build -ldflags="-s -w" -o weed-sql ./cmd/weed-sql
build_docker:
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w"
+8 -9
View File
@@ -6,7 +6,6 @@ import (
"sort"
"time"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam"
@@ -185,28 +184,28 @@ func (s *AdminServer) GetAdminData(username string) (AdminData, error) {
}
// ShowAdmin displays the main admin page (now uses GetAdminData)
func (s *AdminServer) ShowAdmin(c *gin.Context) {
username := c.GetString("username")
func (s *AdminServer) ShowAdmin(w http.ResponseWriter, r *http.Request) {
username := UsernameFromContext(r.Context())
adminData, err := s.GetAdminData(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get admin data: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get admin data: "+err.Error())
return
}
// Return JSON for API calls
c.JSON(http.StatusOK, adminData)
writeJSON(w, http.StatusOK, adminData)
}
// ShowOverview displays cluster overview
func (s *AdminServer) ShowOverview(c *gin.Context) {
func (s *AdminServer) ShowOverview(w http.ResponseWriter, r *http.Request) {
topology, err := s.GetClusterTopology()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, topology)
writeJSON(w, http.StatusOK, topology)
}
// getMasterNodesStatus checks status of all master nodes
@@ -258,7 +257,7 @@ func (s *AdminServer) getFilerNodesStatus() []FilerNode {
// Process each filer node
for _, node := range resp.ClusterNodes {
filerNodes = append(filerNodes, FilerNode{
Address: node.Address,
Address: pb.ServerAddress(node.Address).ToHttpAddress(),
DataCenter: node.DataCenter,
Rack: node.Rack,
LastUpdated: time.Now(),
+9 -11
View File
@@ -3,12 +3,6 @@ package dash
import (
"context"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
adminplugin "github.com/seaweedfs/seaweedfs/weed/admin/plugin"
"github.com/seaweedfs/seaweedfs/weed/cluster"
@@ -25,6 +19,10 @@ import (
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
"google.golang.org/grpc"
"net/http"
"sort"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/s3api"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
@@ -817,18 +815,18 @@ func (s *AdminServer) GetClusterBrokers() (*ClusterBrokersData, error) {
// VacuumVolume method moved to volume_management.go
// TriggerTopicRetentionPurgeAPI triggers topic retention purge via HTTP API
func (as *AdminServer) TriggerTopicRetentionPurgeAPI(c *gin.Context) {
func (as *AdminServer) TriggerTopicRetentionPurgeAPI(w http.ResponseWriter, r *http.Request) {
err := as.TriggerTopicRetentionPurge()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Topic retention purge triggered successfully"})
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Topic retention purge triggered successfully"})
}
// GetConfigInfo returns information about the admin configuration
func (as *AdminServer) GetConfigInfo(c *gin.Context) {
func (as *AdminServer) GetConfigInfo(w http.ResponseWriter, r *http.Request) {
configInfo := as.configPersistence.GetConfigInfo()
// Add additional admin server info
@@ -846,7 +844,7 @@ func (as *AdminServer) GetConfigInfo(c *gin.Context) {
configInfo["maintenance_running"] = false
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"config_info": configInfo,
"title": "Configuration Information",
})
+53 -43
View File
@@ -4,81 +4,91 @@ import (
"crypto/subtle"
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
// ShowLogin displays the login page
func (s *AdminServer) ShowLogin(c *gin.Context) {
// If authentication is not required, redirect to admin
session := sessions.Default(c)
if session.Get("authenticated") == true {
c.Redirect(http.StatusSeeOther, "/admin")
return
}
// For now, return a simple login form as JSON
c.HTML(http.StatusOK, "login.html", gin.H{
"title": "SeaweedFS Admin Login",
"error": c.Query("error"),
})
// ShowLogin displays the login page.
func (s *AdminServer) ShowLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// HandleLogin handles login form submission
func (s *AdminServer) HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword string) gin.HandlerFunc {
return func(c *gin.Context) {
loginUsername := c.PostForm("username")
loginPassword := c.PostForm("password")
// HandleLogin handles login form submission.
func (s *AdminServer) HandleLogin(store sessions.Store, adminUser, adminPassword, readOnlyUser, readOnlyPassword string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/login?error=Invalid form submission", http.StatusSeeOther)
return
}
session, err := store.Get(r, sessionName)
if err != nil {
http.Redirect(w, r, "/login?error=Unable to create session. Please try again or contact administrator.", http.StatusSeeOther)
return
}
if err := ValidateSessionCSRFToken(session, r); err != nil {
http.Redirect(w, r, "/login?error=Invalid CSRF token", http.StatusSeeOther)
return
}
loginUsername := r.FormValue("username")
loginPassword := r.FormValue("password")
var role string
var authenticated bool
// Check admin credentials
// Check admin credentials.
if adminPassword != "" && loginUsername == adminUser && subtle.ConstantTimeCompare([]byte(loginPassword), []byte(adminPassword)) == 1 {
role = "admin"
authenticated = true
} else if readOnlyPassword != "" && loginUsername == readOnlyUser && subtle.ConstantTimeCompare([]byte(loginPassword), []byte(readOnlyPassword)) == 1 {
// Check read-only credentials
// Check read-only credentials.
role = "readonly"
authenticated = true
}
if authenticated {
session := sessions.Default(c)
// Clear any existing invalid session data before setting new values
session.Clear()
session.Set("authenticated", true)
session.Set("username", loginUsername)
session.Set("role", role)
for key := range session.Values {
delete(session.Values, key)
}
session.Values["authenticated"] = true
session.Values["username"] = loginUsername
session.Values["role"] = role
csrfToken, err := generateCSRFToken()
if err != nil {
c.Redirect(http.StatusSeeOther, "/login?error=Unable to create session. Please try again or contact administrator.")
http.Redirect(w, r, "/login?error=Unable to create session. Please try again or contact administrator.", http.StatusSeeOther)
return
}
session.Set(sessionCSRFTokenKey, csrfToken)
if err := session.Save(); err != nil {
// Log the detailed error server-side for diagnostics
session.Values[sessionCSRFTokenKey] = csrfToken
if err := session.Save(r, w); err != nil {
// Log the detailed error server-side for diagnostics.
glog.Errorf("Failed to save session for user %s: %v", loginUsername, err)
c.Redirect(http.StatusSeeOther, "/login?error=Unable to create session. Please try again or contact administrator.")
http.Redirect(w, r, "/login?error=Unable to create session. Please try again or contact administrator.", http.StatusSeeOther)
return
}
c.Redirect(http.StatusSeeOther, "/admin")
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
// Authentication failed
c.Redirect(http.StatusSeeOther, "/login?error=Invalid credentials")
// Authentication failed.
http.Redirect(w, r, "/login?error=Invalid credentials", http.StatusSeeOther)
}
}
// HandleLogout handles user logout
func (s *AdminServer) HandleLogout(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
if err := session.Save(); err != nil {
// HandleLogout handles user logout.
func (s *AdminServer) HandleLogout(store sessions.Store, w http.ResponseWriter, r *http.Request) {
session, err := store.Get(r, sessionName)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
for key := range session.Values {
delete(session.Values, key)
}
session.Options.MaxAge = -1
if err := session.Save(r, w); err != nil {
glog.Warningf("Failed to save session during logout: %v", err)
}
c.Redirect(http.StatusSeeOther, "/login")
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
+90 -54
View File
@@ -8,7 +8,7 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
@@ -29,7 +29,7 @@ type S3BucketsData struct {
}
type CreateBucketRequest struct {
Name string `json:"name" binding:"required"`
Name string `json:"name"` // validated manually in CreateBucket
Region string `json:"region"`
QuotaSize int64 `json:"quota_size"` // Quota size in bytes
QuotaUnit string `json:"quota_unit"` // Unit: MB, GB, TB
@@ -45,47 +45,51 @@ type CreateBucketRequest struct {
// S3 Bucket Management Handlers
// ShowS3Buckets displays the Object Store buckets management page
func (s *AdminServer) ShowS3Buckets(c *gin.Context) {
username := c.GetString("username")
func (s *AdminServer) ShowS3Buckets(w http.ResponseWriter, r *http.Request) {
username := UsernameFromContext(r.Context())
data, err := s.GetS3BucketsData()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get Object Store buckets: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get Object Store buckets: "+err.Error())
return
}
data.Username = username
c.JSON(http.StatusOK, data)
writeJSON(w, http.StatusOK, data)
}
// ShowBucketDetails displays detailed information about a specific bucket
func (s *AdminServer) ShowBucketDetails(c *gin.Context) {
bucketName := c.Param("bucket")
func (s *AdminServer) ShowBucketDetails(w http.ResponseWriter, r *http.Request) {
bucketName := mux.Vars(r)["bucket"]
if bucketName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
return
}
details, err := s.GetBucketDetails(bucketName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get bucket details: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get bucket details: "+err.Error())
return
}
c.JSON(http.StatusOK, details)
writeJSON(w, http.StatusOK, details)
}
// CreateBucket creates a new S3 bucket
func (s *AdminServer) CreateBucket(c *gin.Context) {
func (s *AdminServer) CreateBucket(w http.ResponseWriter, r *http.Request) {
var req CreateBucketRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if strings.TrimSpace(req.Name) == "" {
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
return
}
// Validate bucket name (basic validation)
if len(req.Name) < 3 || len(req.Name) > 63 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name must be between 3 and 63 characters"})
writeJSONError(w, http.StatusBadRequest, "Bucket name must be between 3 and 63 characters")
return
}
@@ -96,42 +100,47 @@ func (s *AdminServer) CreateBucket(c *gin.Context) {
// Validate object lock mode
if req.ObjectLockMode != "GOVERNANCE" && req.ObjectLockMode != "COMPLIANCE" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Object lock mode must be either GOVERNANCE or COMPLIANCE"})
writeJSONError(w, http.StatusBadRequest, "Object lock mode must be either GOVERNANCE or COMPLIANCE")
return
}
// Validate retention duration if default retention is enabled
if req.SetDefaultRetention {
if req.ObjectLockDuration <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Object lock duration must be greater than 0 days when default retention is enabled"})
writeJSONError(w, http.StatusBadRequest, "Object lock duration must be greater than 0 days when default retention is enabled")
return
}
}
}
// Convert quota to bytes
quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit)
normalizedUnit, err := normalizeQuotaUnit(req.QuotaUnit)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
req.QuotaUnit = normalizedUnit
quotaBytes := convertQuotaToBytes(req.QuotaSize, normalizedUnit)
// Validate quota: if enabled, size must be greater than 0
if req.QuotaEnabled && quotaBytes <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Quota size must be greater than 0 when quota is enabled"})
writeJSONError(w, http.StatusBadRequest, "Quota size must be greater than 0 when quota is enabled")
return
}
// Sanitize owner: trim whitespace and enforce max length
owner := strings.TrimSpace(req.Owner)
if len(owner) > MaxOwnerNameLength {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)})
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength))
return
}
err := s.CreateS3BucketWithObjectLock(req.Name, quotaBytes, req.QuotaEnabled, req.VersioningEnabled, req.ObjectLockEnabled, req.ObjectLockMode, req.SetDefaultRetention, req.ObjectLockDuration, owner)
err = s.CreateS3BucketWithObjectLock(req.Name, quotaBytes, req.QuotaEnabled, req.VersioningEnabled, req.ObjectLockEnabled, req.ObjectLockMode, req.SetDefaultRetention, req.ObjectLockDuration, owner)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bucket: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to create bucket: "+err.Error())
return
}
c.JSON(http.StatusCreated, gin.H{
writeJSON(w, http.StatusCreated, map[string]interface{}{
"message": "Bucket created successfully",
"bucket": req.Name,
"quota_size": req.QuotaSize,
@@ -146,10 +155,10 @@ func (s *AdminServer) CreateBucket(c *gin.Context) {
}
// UpdateBucketQuota updates the quota settings for a bucket
func (s *AdminServer) UpdateBucketQuota(c *gin.Context) {
bucketName := c.Param("bucket")
func (s *AdminServer) UpdateBucketQuota(w http.ResponseWriter, r *http.Request) {
bucketName := mux.Vars(r)["bucket"]
if bucketName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
return
}
@@ -158,21 +167,32 @@ func (s *AdminServer) UpdateBucketQuota(c *gin.Context) {
QuotaUnit string `json:"quota_unit"`
QuotaEnabled bool `json:"quota_enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
// Convert quota to bytes
quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit)
if req.QuotaEnabled && req.QuotaSize <= 0 {
writeJSONError(w, http.StatusBadRequest, "quota_size must be > 0 when quota_enabled is true")
return
}
err := s.SetBucketQuota(bucketName, quotaBytes, req.QuotaEnabled)
normalizedUnit, err := normalizeQuotaUnit(req.QuotaUnit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bucket quota: " + err.Error()})
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
req.QuotaUnit = normalizedUnit
// Convert quota to bytes
quotaBytes := convertQuotaToBytes(req.QuotaSize, normalizedUnit)
err = s.SetBucketQuota(bucketName, quotaBytes, req.QuotaEnabled)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to update bucket quota: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "Bucket quota updated successfully",
"bucket": bucketName,
"quota_size": req.QuotaSize,
@@ -182,30 +202,30 @@ func (s *AdminServer) UpdateBucketQuota(c *gin.Context) {
}
// DeleteBucket deletes an S3 bucket
func (s *AdminServer) DeleteBucket(c *gin.Context) {
bucketName := c.Param("bucket")
func (s *AdminServer) DeleteBucket(w http.ResponseWriter, r *http.Request) {
bucketName := mux.Vars(r)["bucket"]
if bucketName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
return
}
err := s.DeleteS3Bucket(bucketName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete bucket: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to delete bucket: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "Bucket deleted successfully",
"bucket": bucketName,
})
}
// UpdateBucketOwner updates the owner of an S3 bucket
func (s *AdminServer) UpdateBucketOwner(c *gin.Context) {
bucketName := c.Param("bucket")
func (s *AdminServer) UpdateBucketOwner(w http.ResponseWriter, r *http.Request) {
bucketName := mux.Vars(r)["bucket"]
if bucketName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
return
}
@@ -213,31 +233,31 @@ func (s *AdminServer) UpdateBucketOwner(c *gin.Context) {
var req struct {
Owner *string `json:"owner"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
// Require owner field to be explicitly provided
if req.Owner == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Owner field is required (use empty string to clear owner)"})
writeJSONError(w, http.StatusBadRequest, "Owner field is required (use empty string to clear owner)")
return
}
// Trim and validate owner
owner := strings.TrimSpace(*req.Owner)
if len(owner) > MaxOwnerNameLength {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)})
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength))
return
}
err := s.SetBucketOwner(bucketName, owner)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bucket owner: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to update bucket owner: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "Bucket owner updated successfully",
"bucket": bucketName,
"owner": owner,
@@ -284,14 +304,14 @@ func (s *AdminServer) SetBucketOwner(bucketName string, owner string) error {
}
// ListBucketsAPI returns the list of buckets as JSON
func (s *AdminServer) ListBucketsAPI(c *gin.Context) {
func (s *AdminServer) ListBucketsAPI(w http.ResponseWriter, r *http.Request) {
buckets, err := s.GetS3Buckets()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get buckets: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get buckets: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"buckets": buckets,
"total": len(buckets),
})
@@ -303,16 +323,32 @@ func convertQuotaToBytes(size int64, unit string) int64 {
return 0
}
switch strings.ToUpper(unit) {
switch unit {
case "TB":
return size * 1024 * 1024 * 1024 * 1024
case "GB":
return size * 1024 * 1024 * 1024
case "MB":
return size * 1024 * 1024
case "KB":
return size * 1024
case "B":
return size
default:
// Default to MB if unit is not recognized
return size * 1024 * 1024
return 0
}
}
func normalizeQuotaUnit(unit string) (string, error) {
normalized := strings.ToUpper(strings.TrimSpace(unit))
if normalized == "" {
return "MB", nil
}
switch normalized {
case "B", "KB", "MB", "GB", "TB":
return normalized, nil
default:
return "", fmt.Errorf("unsupported quota unit: %s", unit)
}
}
+35 -4
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
@@ -87,6 +88,11 @@ func isValidTaskID(taskID string) bool {
// ConfigPersistence handles saving and loading configuration files
type ConfigPersistence struct {
dataDir string
// tasksMu serializes all filesystem operations on the tasks/ directory.
// SaveTaskState, LoadTaskState, LoadAllTaskStates, DeleteTaskState, and
// CleanupCompletedTasks are called from multiple goroutines concurrently
// after saveTaskState was moved outside mq.mutex in the maintenance queue.
tasksMu sync.Mutex
}
// NewConfigPersistence creates a new configuration persistence manager
@@ -937,6 +943,8 @@ func (cp *ConfigPersistence) ListTaskDetails() ([]string, error) {
// CleanupCompletedTasks removes old completed tasks beyond the retention limit
func (cp *ConfigPersistence) CleanupCompletedTasks() error {
cp.tasksMu.Lock()
defer cp.tasksMu.Unlock()
if cp.dataDir == "" {
return fmt.Errorf("no data directory specified, cannot cleanup completed tasks")
}
@@ -946,8 +954,8 @@ func (cp *ConfigPersistence) CleanupCompletedTasks() error {
return nil // No tasks directory, nothing to cleanup
}
// Load all tasks and find completed/failed ones
allTasks, err := cp.LoadAllTaskStates()
// Use unlocked helpers to avoid deadlock (tasksMu is already held)
allTasks, err := cp.loadAllTaskStatesLocked()
if err != nil {
return fmt.Errorf("failed to load tasks for cleanup: %w", err)
}
@@ -998,7 +1006,7 @@ func (cp *ConfigPersistence) CleanupCompletedTasks() error {
if len(completedTasks) > MaxCompletedTasks {
tasksToDelete := completedTasks[MaxCompletedTasks:]
for _, task := range tasksToDelete {
if err := cp.DeleteTaskState(task.ID); err != nil {
if err := cp.deleteTaskStateLocked(task.ID); err != nil {
glog.Warningf("Failed to delete old completed task %s: %v", task.ID, err)
} else {
glog.V(2).Infof("Cleaned up old completed task %s (completed: %v)", task.ID, task.CompletedAt)
@@ -1012,6 +1020,8 @@ func (cp *ConfigPersistence) CleanupCompletedTasks() error {
// SaveTaskState saves a task state to protobuf file
func (cp *ConfigPersistence) SaveTaskState(task *maintenance.MaintenanceTask) error {
cp.tasksMu.Lock()
defer cp.tasksMu.Unlock()
if cp.dataDir == "" {
return fmt.Errorf("no data directory specified, cannot save task state")
}
@@ -1051,6 +1061,13 @@ func (cp *ConfigPersistence) SaveTaskState(task *maintenance.MaintenanceTask) er
// LoadTaskState loads a task state from protobuf file
func (cp *ConfigPersistence) LoadTaskState(taskID string) (*maintenance.MaintenanceTask, error) {
cp.tasksMu.Lock()
defer cp.tasksMu.Unlock()
return cp.loadTaskStateLocked(taskID)
}
// loadTaskStateLocked loads a single task state. Must be called with tasksMu held.
func (cp *ConfigPersistence) loadTaskStateLocked(taskID string) (*maintenance.MaintenanceTask, error) {
if cp.dataDir == "" {
return nil, fmt.Errorf("no data directory specified, cannot load task state")
}
@@ -1084,6 +1101,13 @@ func (cp *ConfigPersistence) LoadTaskState(taskID string) (*maintenance.Maintena
// LoadAllTaskStates loads all task states from disk
func (cp *ConfigPersistence) LoadAllTaskStates() ([]*maintenance.MaintenanceTask, error) {
cp.tasksMu.Lock()
defer cp.tasksMu.Unlock()
return cp.loadAllTaskStatesLocked()
}
// loadAllTaskStatesLocked loads all task states from disk. Must be called with tasksMu held.
func (cp *ConfigPersistence) loadAllTaskStatesLocked() ([]*maintenance.MaintenanceTask, error) {
if cp.dataDir == "" {
return []*maintenance.MaintenanceTask{}, nil
}
@@ -1102,7 +1126,7 @@ func (cp *ConfigPersistence) LoadAllTaskStates() ([]*maintenance.MaintenanceTask
for _, entry := range entries {
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".pb" {
taskID := entry.Name()[:len(entry.Name())-3] // Remove .pb extension
task, err := cp.LoadTaskState(taskID)
task, err := cp.loadTaskStateLocked(taskID)
if err != nil {
glog.Warningf("Failed to load task state for %s: %v", taskID, err)
continue
@@ -1117,6 +1141,13 @@ func (cp *ConfigPersistence) LoadAllTaskStates() ([]*maintenance.MaintenanceTask
// DeleteTaskState removes a task state file from disk
func (cp *ConfigPersistence) DeleteTaskState(taskID string) error {
cp.tasksMu.Lock()
defer cp.tasksMu.Unlock()
return cp.deleteTaskStateLocked(taskID)
}
// deleteTaskStateLocked removes a task state file. Must be called with tasksMu held.
func (cp *ConfigPersistence) deleteTaskStateLocked(taskID string) error {
if cp.dataDir == "" {
return fmt.Errorf("no data directory specified, cannot delete task state")
}
+58
View File
@@ -0,0 +1,58 @@
package dash
import "context"
type contextKey string
const (
contextUsernameKey contextKey = "admin.username"
contextRoleKey contextKey = "admin.role"
contextCSRFKey contextKey = "admin.csrf"
)
// WithAuthContext stores auth metadata on the request context.
func WithAuthContext(ctx context.Context, username, role, csrfToken string) context.Context {
if username != "" {
ctx = context.WithValue(ctx, contextUsernameKey, username)
}
if role != "" {
ctx = context.WithValue(ctx, contextRoleKey, role)
}
if csrfToken != "" {
ctx = context.WithValue(ctx, contextCSRFKey, csrfToken)
}
return ctx
}
// UsernameFromContext retrieves the username from context.
func UsernameFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
if value, ok := ctx.Value(contextUsernameKey).(string); ok {
return value
}
return ""
}
// RoleFromContext retrieves the role from context.
func RoleFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
if value, ok := ctx.Value(contextRoleKey).(string); ok {
return value
}
return ""
}
// CSRFTokenFromContext retrieves the CSRF token from context.
func CSRFTokenFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
if value, ok := ctx.Value(contextCSRFKey).(string); ok {
return value
}
return ""
}
+55 -19
View File
@@ -4,10 +4,10 @@ import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
)
const sessionCSRFTokenKey = "csrf_token"
@@ -20,41 +20,77 @@ func generateCSRFToken() (string, error) {
return hex.EncodeToString(tokenBytes), nil
}
func getOrCreateSessionCSRFToken(session sessions.Session) (string, error) {
if existing, ok := session.Get(sessionCSRFTokenKey).(string); ok && existing != "" {
func getOrCreateSessionCSRFToken(session *sessions.Session, r *http.Request, w http.ResponseWriter) (string, error) {
if existing, ok := session.Values[sessionCSRFTokenKey].(string); ok && existing != "" {
return existing, nil
}
token, err := generateCSRFToken()
if err != nil {
return "", err
}
session.Set(sessionCSRFTokenKey, token)
if err := session.Save(); err != nil {
session.Values[sessionCSRFTokenKey] = token
if err := session.Save(r, w); err != nil {
return "", err
}
return token, nil
}
func requireSessionCSRFToken(c *gin.Context) bool {
session := sessions.Default(c)
if session.Get("authenticated") != true {
func requireSessionCSRFToken(w http.ResponseWriter, r *http.Request) bool {
expectedToken := CSRFTokenFromContext(r.Context())
username := UsernameFromContext(r.Context())
if expectedToken == "" {
// Admin UI can run without auth; in that mode CSRF token checks are not applicable.
return true
}
expectedToken, ok := session.Get(sessionCSRFTokenKey).(string)
if !ok || expectedToken == "" {
c.JSON(http.StatusForbidden, gin.H{"error": "missing CSRF session token"})
if username == "" {
return true
}
writeJSONError(w, http.StatusForbidden, "missing CSRF session token")
return false
}
providedToken := c.GetHeader("X-CSRF-Token")
if providedToken == "" {
providedToken = c.PostForm("csrf_token")
providedToken, err := getProvidedCSRFToken(r)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "Failed to parse form: "+err.Error())
return false
}
if providedToken == "" || subtle.ConstantTimeCompare([]byte(expectedToken), []byte(providedToken)) != 1 {
c.JSON(http.StatusForbidden, gin.H{"error": "invalid CSRF token"})
writeJSONError(w, http.StatusForbidden, "invalid CSRF token")
return false
}
return true
}
func getProvidedCSRFToken(r *http.Request) (string, error) {
providedToken := r.Header.Get("X-CSRF-Token")
if providedToken != "" {
return providedToken, nil
}
if err := r.ParseForm(); err != nil {
return "", err
}
return r.FormValue("csrf_token"), nil
}
func EnsureSessionCSRFToken(session *sessions.Session, r *http.Request, w http.ResponseWriter) (string, error) {
if session == nil {
return "", fmt.Errorf("session is nil")
}
return getOrCreateSessionCSRFToken(session, r, w)
}
func ValidateSessionCSRFToken(session *sessions.Session, r *http.Request) error {
if session == nil {
return fmt.Errorf("session is nil")
}
expectedToken, _ := session.Values[sessionCSRFTokenKey].(string)
providedToken, err := getProvidedCSRFToken(r)
if err != nil {
return fmt.Errorf("failed to read CSRF token: %w", err)
}
if expectedToken == "" {
return fmt.Errorf("missing session CSRF token")
}
if providedToken == "" || subtle.ConstantTimeCompare([]byte(expectedToken), []byte(providedToken)) != 1 {
return fmt.Errorf("invalid CSRF token")
}
return nil
}
+20 -76
View File
@@ -6,10 +6,7 @@ import (
"sort"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
)
@@ -333,10 +330,19 @@ func (s *AdminServer) GetClusterEcVolumes(page int, pageSize int, sortBy string,
// Process each shard this server has for this volume
shardBits := ecShardInfo.EcIndexBits
shardSizes := ecShardInfo.ShardSizes
sizeIndex := 0
for shardId := 0; shardId < erasure_coding.MaxShardCount; shardId++ {
if (shardBits & (1 << uint(shardId))) != 0 {
// Record shard location
volume.ShardLocations[shardId] = node.Id
if sizeIndex < len(shardSizes) {
size := shardSizes[sizeIndex]
if size >= 0 {
volume.ShardSizes[shardId] = size
}
}
sizeIndex++
totalShards++
}
}
@@ -354,38 +360,6 @@ func (s *AdminServer) GetClusterEcVolumes(page int, pageSize int, sortBy string,
return nil, err
}
// Collect shard size information from volume servers
for volumeId, volume := range volumeData {
// Group servers by volume to minimize gRPC calls
serverHasVolume := make(map[string]bool)
for _, server := range volume.Servers {
serverHasVolume[server] = true
}
// Query each server for shard sizes
for server := range serverHasVolume {
err := s.WithVolumeServerClient(pb.ServerAddress(server), func(client volume_server_pb.VolumeServerClient) error {
resp, err := client.VolumeEcShardsInfo(context.Background(), &volume_server_pb.VolumeEcShardsInfoRequest{
VolumeId: volumeId,
})
if err != nil {
glog.V(1).Infof("Failed to get EC shard info from %s for volume %d: %v", server, volumeId, err)
return nil // Continue with other servers, don't fail the entire request
}
// Update shard sizes
for _, shardInfo := range resp.EcShardInfos {
volume.ShardSizes[int(shardInfo.ShardId)] = shardInfo.Size
}
return nil
})
if err != nil {
glog.V(1).Infof("Failed to connect to volume server %s: %v", server, err)
}
}
}
// Calculate completeness for each volume
completeVolumes := 0
incompleteVolumes := 0
@@ -616,13 +590,23 @@ func (s *AdminServer) GetEcVolumeDetails(volumeID uint32, sortBy string, sortOrd
// Create individual shard entries for each shard this server has
shardBits := ecShardInfo.EcIndexBits
shardSizes := ecShardInfo.ShardSizes
sizeIndex := 0
for shardId := 0; shardId < erasure_coding.MaxShardCount; shardId++ {
if (shardBits & (1 << uint(shardId))) != 0 {
var shardSize uint64
if sizeIndex < len(shardSizes) {
size := shardSizes[sizeIndex]
if size >= 0 {
shardSize = uint64(size)
}
}
sizeIndex++
ecShard := EcShardWithInfo{
VolumeID: ecShardInfo.Id,
ShardID: uint32(shardId),
Collection: ecShardInfo.Collection,
Size: 0, // EC shards don't have individual size in the API response
Size: shardSize,
Server: node.Id,
DataCenter: dc.Id,
Rack: rack.Id,
@@ -653,46 +637,6 @@ func (s *AdminServer) GetEcVolumeDetails(volumeID uint32, sortBy string, sortOrd
return nil, fmt.Errorf("EC volume %d not found", volumeID)
}
// Collect shard size information from volume servers
shardSizeMap := make(map[string]map[uint32]uint64) // server -> shardId -> size
for _, shard := range shards {
server := shard.Server
if _, exists := shardSizeMap[server]; !exists {
// Query this server for shard sizes
err := s.WithVolumeServerClient(pb.ServerAddress(server), func(client volume_server_pb.VolumeServerClient) error {
resp, err := client.VolumeEcShardsInfo(context.Background(), &volume_server_pb.VolumeEcShardsInfoRequest{
VolumeId: volumeID,
})
if err != nil {
glog.V(1).Infof("Failed to get EC shard info from %s for volume %d: %v", server, volumeID, err)
return nil // Continue with other servers, don't fail the entire request
}
// Store shard sizes for this server
shardSizeMap[server] = make(map[uint32]uint64)
for _, shardInfo := range resp.EcShardInfos {
shardSizeMap[server][shardInfo.ShardId] = uint64(shardInfo.Size)
}
return nil
})
if err != nil {
glog.V(1).Infof("Failed to connect to volume server %s: %v", server, err)
}
}
}
// Update shard sizes in the shards array
for i := range shards {
server := shards[i].Server
shardId := shards[i].ShardID
if serverSizes, exists := shardSizeMap[server]; exists {
if size, exists := serverSizes[shardId]; exists {
shards[i].Size = size
}
}
}
// Calculate completeness based on unique shard IDs
foundShards := make(map[int]bool)
for _, shard := range shards {
+24
View File
@@ -0,0 +1,24 @@
package dash
import (
"io"
"net/http"
"github.com/seaweedfs/seaweedfs/weed/admin/internal/httputil"
)
func writeJSON(w http.ResponseWriter, status int, payload interface{}) {
httputil.WriteJSON(w, status, payload)
}
func writeJSONError(w http.ResponseWriter, status int, message string) {
httputil.WriteJSONError(w, status, message)
}
func decodeJSONBody(r io.Reader, v interface{}) error {
return httputil.DecodeJSONBody(r, v)
}
func newJSONMaxReader(w http.ResponseWriter, r *http.Request) io.Reader {
return httputil.NewJSONMaxReader(w, r)
}
+110 -90
View File
@@ -4,109 +4,129 @@ import (
"net/http"
"strings"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
)
// setAuthContext sets username and role in context for use in handlers
func setAuthContext(c *gin.Context, username, role interface{}) {
c.Set("username", username)
if role != nil {
c.Set("role", role)
} else {
// Default to admin for backward compatibility
c.Set("role", "admin")
}
const sessionName = "admin-session"
// SessionName returns the cookie session name used by the admin UI.
func SessionName() string {
return sessionName
}
// RequireAuth checks if user is authenticated
func RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
authenticated := session.Get("authenticated")
username := session.Get("username")
role := session.Get("role")
type sessionValidationErrorKind int
if authenticated != true || username == nil {
c.Redirect(http.StatusTemporaryRedirect, "/login")
c.Abort()
return
}
const (
sessionValidationErrorKindUnauthenticated sessionValidationErrorKind = iota
sessionValidationErrorKindSessionInit
)
csrfToken, err := getOrCreateSessionCSRFToken(session)
if err != nil {
c.Redirect(http.StatusTemporaryRedirect, "/login?error=Unable to initialize session")
c.Abort()
return
}
// Set username and role in context for use in handlers
setAuthContext(c, username, role)
c.Set("csrf_token", csrfToken)
c.Next()
}
type sessionValidationError struct {
kind sessionValidationErrorKind
err error
}
// RequireAuthAPI checks if user is authenticated for API endpoints
// Returns JSON error instead of redirecting to login page
func RequireAuthAPI() gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
authenticated := session.Get("authenticated")
username := session.Get("username")
role := session.Get("role")
if authenticated != true || username == nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentication required",
"message": "Please log in to access this endpoint",
})
c.Abort()
return
}
csrfToken, err := getOrCreateSessionCSRFToken(session)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to initialize session",
"message": "Unable to initialize CSRF token",
})
c.Abort()
return
}
// Set username and role in context for use in handlers
setAuthContext(c, username, role)
c.Set("csrf_token", csrfToken)
c.Next()
func (e *sessionValidationError) Error() string {
if e.err != nil {
return e.err.Error()
}
return "session validation failed"
}
// RequireWriteAccess checks if user has admin role (write access)
// Returns JSON error for API endpoints, redirects for HTML endpoints
func RequireWriteAccess() gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("role")
if !exists {
role = "admin" // Default for backward compatibility
}
func (e *sessionValidationError) Unwrap() error {
return e.err
}
roleStr, ok := role.(string)
if !ok || roleStr != "admin" {
// Check if this is an API request (path starts with /api) or HTML request
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api") {
c.JSON(http.StatusForbidden, gin.H{
"error": "Insufficient permissions",
"message": "This operation requires admin access. Read-only users can only view data.",
})
} else {
c.Redirect(http.StatusSeeOther, "/admin?error=Insufficient permissions")
func validateSession(store sessions.Store, w http.ResponseWriter, r *http.Request) (string, string, string, error) {
session, err := store.Get(r, sessionName)
if err != nil {
return "", "", "", &sessionValidationError{kind: sessionValidationErrorKindSessionInit, err: err}
}
authenticated, _ := session.Values["authenticated"].(bool)
username, _ := session.Values["username"].(string)
role, _ := session.Values["role"].(string)
if !authenticated || username == "" {
return "", "", "", &sessionValidationError{kind: sessionValidationErrorKindUnauthenticated}
}
csrfToken, err := getOrCreateSessionCSRFToken(session, r, w)
if err != nil {
return "", "", "", &sessionValidationError{kind: sessionValidationErrorKindSessionInit, err: err}
}
return username, role, csrfToken, nil
}
// RequireAuth checks if user is authenticated.
func RequireAuth(store sessions.Store) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, role, csrfToken, err := validateSession(store, w, r)
if err != nil {
if verr, ok := err.(*sessionValidationError); ok && verr.kind == sessionValidationErrorKindUnauthenticated {
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
} else {
http.Redirect(w, r, "/login?error=Unable to initialize session", http.StatusTemporaryRedirect)
}
return
}
c.Abort()
return
}
c.Next()
ctx := WithAuthContext(r.Context(), username, role, csrfToken)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireAuthAPI checks if user is authenticated for API endpoints.
// Returns JSON error instead of redirecting to login page.
func RequireAuthAPI(store sessions.Store) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, role, csrfToken, err := validateSession(store, w, r)
if err != nil {
if verr, ok := err.(*sessionValidationError); ok && verr.kind == sessionValidationErrorKindUnauthenticated {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "Authentication required",
"message": "Please log in to access this endpoint",
})
} else {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "Failed to initialize session",
"message": "Unable to initialize session",
})
}
return
}
ctx := WithAuthContext(r.Context(), username, role, csrfToken)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireWriteAccess checks if user has admin role (write access).
// Returns JSON error for API endpoints, redirects for HTML endpoints.
func RequireWriteAccess() mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role := RoleFromContext(r.Context())
if role != "admin" {
// Check if this is an API request (path starts with /api) or HTML request.
if strings.HasPrefix(r.URL.Path, "/api") {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "Insufficient permissions",
"message": "This operation requires admin access. Read-only users can only view data.",
})
} else {
http.Redirect(w, r, "/admin?error=Insufficient permissions", http.StatusSeeOther)
}
return
}
next.ServeHTTP(w, r)
})
}
}
+113 -108
View File
@@ -13,7 +13,7 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/admin/plugin"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
@@ -32,17 +32,17 @@ const (
)
// GetPluginStatusAPI returns plugin status.
func (s *AdminServer) GetPluginStatusAPI(c *gin.Context) {
func (s *AdminServer) GetPluginStatusAPI(w http.ResponseWriter, r *http.Request) {
plugin := s.GetPlugin()
if plugin == nil {
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"enabled": false,
"worker_grpc_port": s.GetWorkerGrpcPort(),
})
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"enabled": true,
"configured": plugin.IsConfigured(),
"base_dir": plugin.BaseDir(),
@@ -52,101 +52,104 @@ func (s *AdminServer) GetPluginStatusAPI(c *gin.Context) {
}
// GetPluginWorkersAPI returns currently connected plugin workers.
func (s *AdminServer) GetPluginWorkersAPI(c *gin.Context) {
func (s *AdminServer) GetPluginWorkersAPI(w http.ResponseWriter, r *http.Request) {
workers := s.GetPluginWorkers()
if workers == nil {
c.JSON(http.StatusOK, []interface{}{})
writeJSON(w, http.StatusOK, []interface{}{})
return
}
c.JSON(http.StatusOK, workers)
writeJSON(w, http.StatusOK, workers)
}
// GetPluginJobTypesAPI returns known plugin job types from workers and persisted data.
func (s *AdminServer) GetPluginJobTypesAPI(c *gin.Context) {
func (s *AdminServer) GetPluginJobTypesAPI(w http.ResponseWriter, r *http.Request) {
jobTypes, err := s.ListPluginJobTypes()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
if jobTypes == nil {
c.JSON(http.StatusOK, []interface{}{})
writeJSON(w, http.StatusOK, []interface{}{})
return
}
c.JSON(http.StatusOK, jobTypes)
writeJSON(w, http.StatusOK, jobTypes)
}
// GetPluginJobsAPI returns tracked jobs for monitoring.
func (s *AdminServer) GetPluginJobsAPI(c *gin.Context) {
jobType := strings.TrimSpace(c.Query("job_type"))
state := strings.TrimSpace(c.Query("state"))
limit := parsePositiveInt(c.Query("limit"), 200)
func (s *AdminServer) GetPluginJobsAPI(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
jobType := strings.TrimSpace(query.Get("job_type"))
state := strings.TrimSpace(query.Get("state"))
limit := parsePositiveInt(query.Get("limit"), 200)
jobs := s.ListPluginJobs(jobType, state, limit)
if jobs == nil {
c.JSON(http.StatusOK, []interface{}{})
writeJSON(w, http.StatusOK, []interface{}{})
return
}
c.JSON(http.StatusOK, jobs)
writeJSON(w, http.StatusOK, jobs)
}
// GetPluginJobAPI returns one tracked job.
func (s *AdminServer) GetPluginJobAPI(c *gin.Context) {
jobID := strings.TrimSpace(c.Param("jobId"))
func (s *AdminServer) GetPluginJobAPI(w http.ResponseWriter, r *http.Request) {
jobID := strings.TrimSpace(mux.Vars(r)["jobId"])
if jobID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "jobId is required"})
writeJSONError(w, http.StatusBadRequest, "jobId is required")
return
}
job, found := s.GetPluginJob(jobID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "job not found"})
writeJSONError(w, http.StatusNotFound, "job not found")
return
}
c.JSON(http.StatusOK, job)
writeJSON(w, http.StatusOK, job)
}
// GetPluginJobDetailAPI returns detailed information for one tracked plugin job.
func (s *AdminServer) GetPluginJobDetailAPI(c *gin.Context) {
jobID := strings.TrimSpace(c.Param("jobId"))
func (s *AdminServer) GetPluginJobDetailAPI(w http.ResponseWriter, r *http.Request) {
jobID := strings.TrimSpace(mux.Vars(r)["jobId"])
if jobID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "jobId is required"})
writeJSONError(w, http.StatusBadRequest, "jobId is required")
return
}
activityLimit := parsePositiveInt(c.Query("activity_limit"), 500)
relatedLimit := parsePositiveInt(c.Query("related_limit"), 20)
query := r.URL.Query()
activityLimit := parsePositiveInt(query.Get("activity_limit"), 500)
relatedLimit := parsePositiveInt(query.Get("related_limit"), 20)
detail, found, err := s.GetPluginJobDetail(jobID, activityLimit, relatedLimit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
if !found || detail == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "job detail not found"})
writeJSONError(w, http.StatusNotFound, "job detail not found")
return
}
c.JSON(http.StatusOK, detail)
writeJSON(w, http.StatusOK, detail)
}
// GetPluginActivitiesAPI returns recent plugin activities.
func (s *AdminServer) GetPluginActivitiesAPI(c *gin.Context) {
jobType := strings.TrimSpace(c.Query("job_type"))
limit := parsePositiveInt(c.Query("limit"), 500)
func (s *AdminServer) GetPluginActivitiesAPI(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
jobType := strings.TrimSpace(query.Get("job_type"))
limit := parsePositiveInt(query.Get("limit"), 500)
activities := s.ListPluginActivities(jobType, limit)
if activities == nil {
c.JSON(http.StatusOK, []interface{}{})
writeJSON(w, http.StatusOK, []interface{}{})
return
}
c.JSON(http.StatusOK, activities)
writeJSON(w, http.StatusOK, activities)
}
// GetPluginSchedulerStatesAPI returns per-job-type scheduler status for monitoring.
func (s *AdminServer) GetPluginSchedulerStatesAPI(c *gin.Context) {
jobTypeFilter := strings.TrimSpace(c.Query("job_type"))
func (s *AdminServer) GetPluginSchedulerStatesAPI(w http.ResponseWriter, r *http.Request) {
jobTypeFilter := strings.TrimSpace(r.URL.Query().Get("job_type"))
states, err := s.ListPluginSchedulerStates()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -157,71 +160,71 @@ func (s *AdminServer) GetPluginSchedulerStatesAPI(c *gin.Context) {
filtered = append(filtered, state)
}
}
c.JSON(http.StatusOK, filtered)
writeJSON(w, http.StatusOK, filtered)
return
}
if states == nil {
c.JSON(http.StatusOK, []interface{}{})
writeJSON(w, http.StatusOK, []interface{}{})
return
}
c.JSON(http.StatusOK, states)
writeJSON(w, http.StatusOK, states)
}
// RequestPluginJobTypeSchemaAPI asks a worker for one job type schema.
func (s *AdminServer) RequestPluginJobTypeSchemaAPI(c *gin.Context) {
jobType := strings.TrimSpace(c.Param("jobType"))
func (s *AdminServer) RequestPluginJobTypeSchemaAPI(w http.ResponseWriter, r *http.Request) {
jobType := strings.TrimSpace(mux.Vars(r)["jobType"])
if jobType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"})
writeJSONError(w, http.StatusBadRequest, "jobType is required")
return
}
forceRefresh := c.DefaultQuery("force_refresh", "false") == "true"
forceRefresh := strings.EqualFold(r.URL.Query().Get("force_refresh"), "true")
ctx, cancel := context.WithTimeout(c.Request.Context(), defaultPluginDetectionTimeout)
ctx, cancel := context.WithTimeout(r.Context(), defaultPluginDetectionTimeout)
defer cancel()
descriptor, err := s.RequestPluginJobTypeDescriptor(ctx, jobType, forceRefresh)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
renderProtoJSON(c, http.StatusOK, descriptor)
renderProtoJSON(w, http.StatusOK, descriptor)
}
// GetPluginJobTypeDescriptorAPI returns persisted descriptor for a job type.
func (s *AdminServer) GetPluginJobTypeDescriptorAPI(c *gin.Context) {
jobType := strings.TrimSpace(c.Param("jobType"))
func (s *AdminServer) GetPluginJobTypeDescriptorAPI(w http.ResponseWriter, r *http.Request) {
jobType := strings.TrimSpace(mux.Vars(r)["jobType"])
if jobType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"})
writeJSONError(w, http.StatusBadRequest, "jobType is required")
return
}
descriptor, err := s.LoadPluginJobTypeDescriptor(jobType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
if descriptor == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "descriptor not found"})
writeJSONError(w, http.StatusNotFound, "descriptor not found")
return
}
renderProtoJSON(c, http.StatusOK, descriptor)
renderProtoJSON(w, http.StatusOK, descriptor)
}
// GetPluginJobTypeConfigAPI loads persisted config for a job type.
func (s *AdminServer) GetPluginJobTypeConfigAPI(c *gin.Context) {
jobType := strings.TrimSpace(c.Param("jobType"))
func (s *AdminServer) GetPluginJobTypeConfigAPI(w http.ResponseWriter, r *http.Request) {
jobType := strings.TrimSpace(mux.Vars(r)["jobType"])
if jobType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"})
writeJSONError(w, http.StatusBadRequest, "jobType is required")
return
}
config, err := s.LoadPluginJobTypeConfig(jobType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
if config == nil {
@@ -233,20 +236,20 @@ func (s *AdminServer) GetPluginJobTypeConfigAPI(c *gin.Context) {
}
}
renderProtoJSON(c, http.StatusOK, config)
renderProtoJSON(w, http.StatusOK, config)
}
// UpdatePluginJobTypeConfigAPI stores persisted config for a job type.
func (s *AdminServer) UpdatePluginJobTypeConfigAPI(c *gin.Context) {
jobType := strings.TrimSpace(c.Param("jobType"))
func (s *AdminServer) UpdatePluginJobTypeConfigAPI(w http.ResponseWriter, r *http.Request) {
jobType := strings.TrimSpace(mux.Vars(r)["jobType"])
if jobType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"})
writeJSONError(w, http.StatusBadRequest, "jobType is required")
return
}
config := &plugin_pb.PersistedJobTypeConfig{}
if err := parseProtoJSONBody(c, config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
if err := parseProtoJSONBody(w, r, config); err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
@@ -264,35 +267,35 @@ func (s *AdminServer) UpdatePluginJobTypeConfigAPI(c *gin.Context) {
config.WorkerConfigValues = map[string]*plugin_pb.ConfigValue{}
}
username := c.GetString("username")
username := UsernameFromContext(r.Context())
if username == "" {
username = "admin"
}
config.UpdatedBy = username
if err := s.SavePluginJobTypeConfig(config); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
renderProtoJSON(c, http.StatusOK, config)
renderProtoJSON(w, http.StatusOK, config)
}
// GetPluginRunHistoryAPI returns bounded run history for a job type.
func (s *AdminServer) GetPluginRunHistoryAPI(c *gin.Context) {
jobType := strings.TrimSpace(c.Param("jobType"))
func (s *AdminServer) GetPluginRunHistoryAPI(w http.ResponseWriter, r *http.Request) {
jobType := strings.TrimSpace(mux.Vars(r)["jobType"])
if jobType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"})
writeJSONError(w, http.StatusBadRequest, "jobType is required")
return
}
history, err := s.GetPluginRunHistory(jobType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
if history == nil {
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"job_type": jobType,
"successful_runs": []interface{}{},
"error_runs": []interface{}{},
@@ -301,14 +304,14 @@ func (s *AdminServer) GetPluginRunHistoryAPI(c *gin.Context) {
return
}
c.JSON(http.StatusOK, history)
writeJSON(w, http.StatusOK, history)
}
// TriggerPluginDetectionAPI runs one detector for this job type and returns proposals.
func (s *AdminServer) TriggerPluginDetectionAPI(c *gin.Context) {
jobType := strings.TrimSpace(c.Param("jobType"))
func (s *AdminServer) TriggerPluginDetectionAPI(w http.ResponseWriter, r *http.Request) {
jobType := strings.TrimSpace(mux.Vars(r)["jobType"])
if jobType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"})
writeJSONError(w, http.StatusBadRequest, "jobType is required")
return
}
@@ -318,19 +321,19 @@ func (s *AdminServer) TriggerPluginDetectionAPI(c *gin.Context) {
TimeoutSeconds int `json:"timeout_seconds"`
}
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil && err != io.EOF {
writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
clusterContext, err := s.parseOrBuildClusterContext(req.ClusterContext)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
timeout := normalizeTimeout(req.TimeoutSeconds, defaultPluginDetectionTimeout, maxPluginDetectionTimeout)
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
report, err := s.RunPluginDetectionWithReport(ctx, jobType, clusterContext, req.MaxResults)
@@ -384,7 +387,7 @@ func (s *AdminServer) TriggerPluginDetectionAPI(c *gin.Context) {
}
}
response := gin.H{
response := map[string]interface{}{
"job_type": jobType,
"request_id": requestID,
"detector_worker_id": detectorWorkerID,
@@ -396,18 +399,18 @@ func (s *AdminServer) TriggerPluginDetectionAPI(c *gin.Context) {
if err != nil {
response["error"] = err.Error()
c.JSON(http.StatusInternalServerError, response)
writeJSON(w, http.StatusInternalServerError, response)
return
}
c.JSON(http.StatusOK, response)
writeJSON(w, http.StatusOK, response)
}
// RunPluginJobTypeAPI runs full workflow for one job type: detect then dispatch detected jobs.
func (s *AdminServer) RunPluginJobTypeAPI(c *gin.Context) {
jobType := strings.TrimSpace(c.Param("jobType"))
func (s *AdminServer) RunPluginJobTypeAPI(w http.ResponseWriter, r *http.Request) {
jobType := strings.TrimSpace(mux.Vars(r)["jobType"])
if jobType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "jobType is required"})
writeJSONError(w, http.StatusBadRequest, "jobType is required")
return
}
@@ -418,8 +421,8 @@ func (s *AdminServer) RunPluginJobTypeAPI(c *gin.Context) {
Attempt int32 `json:"attempt"`
}
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil && err != io.EOF {
writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if req.Attempt < 1 {
@@ -428,24 +431,24 @@ func (s *AdminServer) RunPluginJobTypeAPI(c *gin.Context) {
clusterContext, err := s.parseOrBuildClusterContext(req.ClusterContext)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
timeout := normalizeTimeout(req.TimeoutSeconds, defaultPluginRunTimeout, maxPluginRunTimeout)
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
proposals, err := s.RunPluginDetection(ctx, jobType, clusterContext, req.MaxResults)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
detectedCount := len(proposals)
filteredProposals, skippedActiveCount, err := s.FilterPluginProposalsWithActiveJobs(jobType, proposals)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -485,7 +488,7 @@ func (s *AdminServer) RunPluginJobTypeAPI(c *gin.Context) {
results = append(results, result)
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"job_type": jobType,
"detected_count": detectedCount,
"ready_to_execute_count": len(filteredProposals),
@@ -498,7 +501,7 @@ func (s *AdminServer) RunPluginJobTypeAPI(c *gin.Context) {
}
// ExecutePluginJobAPI executes one job on a capable worker and waits for completion.
func (s *AdminServer) ExecutePluginJobAPI(c *gin.Context) {
func (s *AdminServer) ExecutePluginJobAPI(w http.ResponseWriter, r *http.Request) {
var req struct {
Job json.RawMessage `json:"job"`
ClusterContext json.RawMessage `json:"cluster_context"`
@@ -506,24 +509,24 @@ func (s *AdminServer) ExecutePluginJobAPI(c *gin.Context) {
TimeoutSeconds int `json:"timeout_seconds"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if len(req.Job) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "job is required"})
writeJSONError(w, http.StatusBadRequest, "job is required")
return
}
job := &plugin_pb.JobSpec{}
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(req.Job, job); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid job payload: " + err.Error()})
writeJSONError(w, http.StatusBadRequest, "invalid job payload: "+err.Error())
return
}
clusterContext, err := s.parseOrBuildClusterContext(req.ClusterContext)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
@@ -532,7 +535,7 @@ func (s *AdminServer) ExecutePluginJobAPI(c *gin.Context) {
}
timeout := normalizeTimeout(req.TimeoutSeconds, defaultPluginExecutionTimeout, maxPluginExecutionTimeout)
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
completed, err := s.ExecutePluginJob(ctx, job, clusterContext, req.Attempt)
@@ -540,15 +543,15 @@ func (s *AdminServer) ExecutePluginJobAPI(c *gin.Context) {
if completed != nil {
payload, marshalErr := protoMessageToMap(completed)
if marshalErr == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "completion": payload})
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error(), "completion": payload})
return
}
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
renderProtoJSON(c, http.StatusOK, completed)
renderProtoJSON(w, http.StatusOK, completed)
}
func (s *AdminServer) parseOrBuildClusterContext(raw json.RawMessage) (*plugin_pb.ClusterContext, error) {
@@ -636,8 +639,8 @@ func (s *AdminServer) buildDefaultPluginClusterContext() *plugin_pb.ClusterConte
const parseProtoJSONBodyMaxBytes = 1 << 20 // 1 MB
func parseProtoJSONBody(c *gin.Context, message proto.Message) error {
limitedBody := http.MaxBytesReader(c.Writer, c.Request.Body, parseProtoJSONBodyMaxBytes)
func parseProtoJSONBody(w http.ResponseWriter, r *http.Request, message proto.Message) error {
limitedBody := http.MaxBytesReader(w, r.Body, parseProtoJSONBodyMaxBytes)
data, err := io.ReadAll(limitedBody)
if err != nil {
return fmt.Errorf("failed to read request body: %w", err)
@@ -651,17 +654,19 @@ func parseProtoJSONBody(c *gin.Context, message proto.Message) error {
return nil
}
func renderProtoJSON(c *gin.Context, statusCode int, message proto.Message) {
func renderProtoJSON(w http.ResponseWriter, statusCode int, message proto.Message) {
payload, err := protojson.MarshalOptions{
UseProtoNames: true,
EmitUnpopulated: true,
}.Marshal(message)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode response: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "failed to encode response: "+err.Error())
return
}
c.Data(statusCode, "application/json", payload)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_, _ = w.Write(payload)
}
func protoMessageToMap(message proto.Message) (map[string]interface{}, error) {
+175 -146
View File
@@ -12,7 +12,6 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
@@ -67,10 +66,10 @@ func parseNamespaceInput(namespace string) ([]string, error) {
return s3tables.ParseNamespace(namespace)
}
func (s *AdminServer) parseNamespaceFromGin(c *gin.Context, namespace string) ([]string, bool) {
func (s *AdminServer) parseNamespaceFromRequest(w http.ResponseWriter, namespace string) ([]string, bool) {
parts, err := parseNamespaceInput(namespace)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid namespace: " + err.Error()})
writeJSONError(w, http.StatusBadRequest, "Invalid namespace: "+err.Error())
return nil, false
}
return parts, true
@@ -569,58 +568,61 @@ func parseSummaryInt(summary map[string]string, keys ...string) (int64, bool) {
// API handlers
func (s *AdminServer) ListS3TablesBucketsAPI(c *gin.Context) {
data, err := s.GetS3TablesBucketsData(c.Request.Context())
func (s *AdminServer) ListS3TablesBucketsAPI(w http.ResponseWriter, r *http.Request) {
data, err := s.GetS3TablesBucketsData(r.Context())
if err != nil {
writeS3TablesError(c, err)
writeS3TablesError(w, err)
return
}
c.JSON(200, data)
writeJSON(w, http.StatusOK, data)
}
func (s *AdminServer) CreateS3TablesBucket(c *gin.Context) {
func (s *AdminServer) CreateS3TablesBucket(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
Name string `json:"name"`
Tags map[string]string `json:"tags"`
Owner string `json:"owner"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.Name == "" {
c.JSON(400, gin.H{"error": "Bucket name is required"})
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
return
}
owner := strings.TrimSpace(req.Owner)
if len(owner) > MaxOwnerNameLength {
c.JSON(400, gin.H{"error": fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)})
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength))
return
}
if len(req.Tags) > 0 {
if err := s3tables.ValidateTags(req.Tags); err != nil {
c.JSON(400, gin.H{"error": "Invalid tags: " + err.Error()})
writeJSONError(w, http.StatusBadRequest, "Invalid tags: "+err.Error())
return
}
}
createReq := &s3tables.CreateTableBucketRequest{Name: req.Name, Tags: req.Tags}
var resp s3tables.CreateTableBucketResponse
if err := s.executeS3TablesOperation(c.Request.Context(), "CreateTableBucket", createReq, &resp); err != nil {
writeS3TablesError(c, err)
if err := s.executeS3TablesOperation(r.Context(), "CreateTableBucket", createReq, &resp); err != nil {
writeS3TablesError(w, err)
return
}
if owner != "" {
if err := s.SetTableBucketOwner(c.Request.Context(), req.Name, owner); err != nil {
if err := s.SetTableBucketOwner(r.Context(), req.Name, owner); err != nil {
deleteReq := &s3tables.DeleteTableBucketRequest{TableBucketARN: resp.ARN}
if deleteErr := s.executeS3TablesOperation(c.Request.Context(), "DeleteTableBucket", deleteReq, nil); deleteErr != nil {
c.JSON(500, gin.H{"error": fmt.Sprintf("Failed to set table bucket owner: %v; rollback delete failed: %v", err, deleteErr)})
if deleteErr := s.executeS3TablesOperation(r.Context(), "DeleteTableBucket", deleteReq, nil); deleteErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to set table bucket owner: %v; rollback delete failed: %v", err, deleteErr))
return
}
writeS3TablesError(c, err)
writeS3TablesError(w, err)
return
}
}
c.JSON(201, gin.H{"arn": resp.ARN})
writeJSON(w, http.StatusCreated, map[string]interface{}{"arn": resp.ARN})
}
func (s *AdminServer) SetTableBucketOwner(ctx context.Context, bucketName, owner string) error {
@@ -663,101 +665,107 @@ func (s *AdminServer) SetTableBucketOwner(ctx context.Context, bucketName, owner
})
}
func (s *AdminServer) DeleteS3TablesBucket(c *gin.Context) {
bucketArn := c.Query("bucket")
func (s *AdminServer) DeleteS3TablesBucket(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
bucketArn := r.URL.Query().Get("bucket")
if bucketArn == "" {
c.JSON(400, gin.H{"error": "Bucket ARN is required"})
writeJSONError(w, http.StatusBadRequest, "Bucket ARN is required")
return
}
req := &s3tables.DeleteTableBucketRequest{TableBucketARN: bucketArn}
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTableBucket", req, nil); err != nil {
writeS3TablesError(c, err)
if err := s.executeS3TablesOperation(r.Context(), "DeleteTableBucket", req, nil); err != nil {
writeS3TablesError(w, err)
return
}
c.JSON(200, gin.H{"message": "Bucket deleted"})
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Bucket deleted"})
}
func (s *AdminServer) ListS3TablesNamespacesAPI(c *gin.Context) {
bucketArn := c.Query("bucket")
func (s *AdminServer) ListS3TablesNamespacesAPI(w http.ResponseWriter, r *http.Request) {
bucketArn := r.URL.Query().Get("bucket")
if bucketArn == "" {
c.JSON(400, gin.H{"error": "bucket query parameter is required"})
writeJSONError(w, http.StatusBadRequest, "bucket query parameter is required")
return
}
data, err := s.GetS3TablesNamespacesData(c.Request.Context(), bucketArn)
data, err := s.GetS3TablesNamespacesData(r.Context(), bucketArn)
if err != nil {
writeS3TablesError(c, err)
writeS3TablesError(w, err)
return
}
c.JSON(200, data)
writeJSON(w, http.StatusOK, data)
}
func (s *AdminServer) CreateS3TablesNamespace(c *gin.Context) {
if !requireSessionCSRFToken(c) {
func (s *AdminServer) CreateS3TablesNamespace(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
BucketARN string `json:"bucket_arn"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.BucketARN == "" || req.Name == "" {
c.JSON(400, gin.H{"error": "bucket_arn and name are required"})
writeJSONError(w, http.StatusBadRequest, "bucket_arn and name are required")
return
}
namespaceParts, ok := s.parseNamespaceFromGin(c, req.Name)
namespaceParts, ok := s.parseNamespaceFromRequest(w, 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)
if err := s.executeS3TablesOperation(r.Context(), "CreateNamespace", createReq, &resp); err != nil {
writeS3TablesError(w, err)
return
}
c.JSON(201, gin.H{"namespace": resp.Namespace})
writeJSON(w, http.StatusCreated, map[string]interface{}{"namespace": resp.Namespace})
}
func (s *AdminServer) DeleteS3TablesNamespace(c *gin.Context) {
if !requireSessionCSRFToken(c) {
func (s *AdminServer) DeleteS3TablesNamespace(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
bucketArn := c.Query("bucket")
namespace := c.Query("name")
bucketArn := r.URL.Query().Get("bucket")
namespace := r.URL.Query().Get("name")
if bucketArn == "" || namespace == "" {
c.JSON(400, gin.H{"error": "bucket and name query parameters are required"})
writeJSONError(w, http.StatusBadRequest, "bucket and name query parameters are required")
return
}
namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
namespaceParts, ok := s.parseNamespaceFromRequest(w, 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)
if err := s.executeS3TablesOperation(r.Context(), "DeleteNamespace", req, nil); err != nil {
writeS3TablesError(w, err)
return
}
c.JSON(200, gin.H{"message": "Namespace deleted"})
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Namespace deleted"})
}
func (s *AdminServer) ListS3TablesTablesAPI(c *gin.Context) {
bucketArn := c.Query("bucket")
func (s *AdminServer) ListS3TablesTablesAPI(w http.ResponseWriter, r *http.Request) {
bucketArn := r.URL.Query().Get("bucket")
if bucketArn == "" {
c.JSON(400, gin.H{"error": "bucket query parameter is required"})
writeJSONError(w, http.StatusBadRequest, "bucket query parameter is required")
return
}
namespace := c.Query("namespace")
data, err := s.GetS3TablesTablesData(c.Request.Context(), bucketArn, namespace)
namespace := r.URL.Query().Get("namespace")
data, err := s.GetS3TablesTablesData(r.Context(), bucketArn, namespace)
if err != nil {
writeS3TablesError(c, err)
writeS3TablesError(w, err)
return
}
c.JSON(200, data)
writeJSON(w, http.StatusOK, data)
}
func (s *AdminServer) CreateS3TablesTable(c *gin.Context) {
func (s *AdminServer) CreateS3TablesTable(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
BucketARN string `json:"bucket_arn"`
Namespace string `json:"namespace"`
@@ -766,15 +774,15 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) {
Tags map[string]string `json:"tags"`
Metadata *s3tables.TableMetadata `json:"metadata"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.BucketARN == "" || req.Namespace == "" || req.Name == "" {
c.JSON(400, gin.H{"error": "bucket_arn, namespace, and name are required"})
writeJSONError(w, http.StatusBadRequest, "bucket_arn, namespace, and name are required")
return
}
namespaceParts, ok := s.parseNamespaceFromGin(c, req.Namespace)
namespaceParts, ok := s.parseNamespaceFromRequest(w, req.Namespace)
if !ok {
return
}
@@ -784,7 +792,7 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) {
}
if len(req.Tags) > 0 {
if err := s3tables.ValidateTags(req.Tags); err != nil {
c.JSON(400, gin.H{"error": "Invalid tags: " + err.Error()})
writeJSONError(w, http.StatusBadRequest, "Invalid tags: "+err.Error())
return
}
}
@@ -797,211 +805,232 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) {
Metadata: req.Metadata,
}
var resp s3tables.CreateTableResponse
if err := s.executeS3TablesOperation(c.Request.Context(), "CreateTable", createReq, &resp); err != nil {
writeS3TablesError(c, err)
if err := s.executeS3TablesOperation(r.Context(), "CreateTable", createReq, &resp); err != nil {
writeS3TablesError(w, err)
return
}
c.JSON(201, gin.H{"table_arn": resp.TableARN, "version_token": resp.VersionToken})
writeJSON(w, http.StatusCreated, map[string]interface{}{"table_arn": resp.TableARN, "version_token": resp.VersionToken})
}
func (s *AdminServer) DeleteS3TablesTable(c *gin.Context) {
bucketArn := c.Query("bucket")
namespace := c.Query("namespace")
name := c.Query("name")
version := c.Query("version")
if bucketArn == "" || namespace == "" || name == "" {
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
func (s *AdminServer) DeleteS3TablesTable(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
bucketArn := r.URL.Query().Get("bucket")
namespace := r.URL.Query().Get("namespace")
name := r.URL.Query().Get("name")
version := r.URL.Query().Get("version")
if bucketArn == "" || namespace == "" || name == "" {
writeJSONError(w, http.StatusBadRequest, "bucket, namespace, and name query parameters are required")
return
}
namespaceParts, ok := s.parseNamespaceFromRequest(w, 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)
if err := s.executeS3TablesOperation(r.Context(), "DeleteTable", req, nil); err != nil {
writeS3TablesError(w, err)
return
}
c.JSON(200, gin.H{"message": "Table deleted"})
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Table deleted"})
}
func (s *AdminServer) PutS3TablesBucketPolicy(c *gin.Context) {
func (s *AdminServer) PutS3TablesBucketPolicy(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
BucketARN string `json:"bucket_arn"`
Policy string `json:"policy"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.BucketARN == "" || req.Policy == "" {
c.JSON(400, gin.H{"error": "bucket_arn and policy are required"})
writeJSONError(w, http.StatusBadRequest, "bucket_arn and policy are required")
return
}
putReq := &s3tables.PutTableBucketPolicyRequest{TableBucketARN: req.BucketARN, ResourcePolicy: req.Policy}
if err := s.executeS3TablesOperation(c.Request.Context(), "PutTableBucketPolicy", putReq, nil); err != nil {
writeS3TablesError(c, err)
if err := s.executeS3TablesOperation(r.Context(), "PutTableBucketPolicy", putReq, nil); err != nil {
writeS3TablesError(w, err)
return
}
c.JSON(200, gin.H{"message": "Policy updated"})
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Policy updated"})
}
func (s *AdminServer) GetS3TablesBucketPolicy(c *gin.Context) {
bucketArn := c.Query("bucket")
func (s *AdminServer) GetS3TablesBucketPolicy(w http.ResponseWriter, r *http.Request) {
bucketArn := r.URL.Query().Get("bucket")
if bucketArn == "" {
c.JSON(400, gin.H{"error": "bucket query parameter is required"})
writeJSONError(w, http.StatusBadRequest, "bucket query parameter is required")
return
}
getReq := &s3tables.GetTableBucketPolicyRequest{TableBucketARN: bucketArn}
var resp s3tables.GetTableBucketPolicyResponse
if err := s.executeS3TablesOperation(c.Request.Context(), "GetTableBucketPolicy", getReq, &resp); err != nil {
writeS3TablesError(c, err)
if err := s.executeS3TablesOperation(r.Context(), "GetTableBucketPolicy", getReq, &resp); err != nil {
writeS3TablesError(w, err)
return
}
c.JSON(200, gin.H{"policy": resp.ResourcePolicy})
writeJSON(w, http.StatusOK, map[string]interface{}{"policy": resp.ResourcePolicy})
}
func (s *AdminServer) DeleteS3TablesBucketPolicy(c *gin.Context) {
bucketArn := c.Query("bucket")
func (s *AdminServer) DeleteS3TablesBucketPolicy(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
bucketArn := r.URL.Query().Get("bucket")
if bucketArn == "" {
c.JSON(400, gin.H{"error": "bucket query parameter is required"})
writeJSONError(w, http.StatusBadRequest, "bucket query parameter is required")
return
}
deleteReq := &s3tables.DeleteTableBucketPolicyRequest{TableBucketARN: bucketArn}
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTableBucketPolicy", deleteReq, nil); err != nil {
writeS3TablesError(c, err)
if err := s.executeS3TablesOperation(r.Context(), "DeleteTableBucketPolicy", deleteReq, nil); err != nil {
writeS3TablesError(w, err)
return
}
c.JSON(200, gin.H{"message": "Policy deleted"})
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Policy deleted"})
}
func (s *AdminServer) PutS3TablesTablePolicy(c *gin.Context) {
func (s *AdminServer) PutS3TablesTablePolicy(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
BucketARN string `json:"bucket_arn"`
Namespace string `json:"namespace"`
Name string `json:"name"`
Policy string `json:"policy"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.BucketARN == "" || req.Namespace == "" || req.Name == "" || req.Policy == "" {
c.JSON(400, gin.H{"error": "bucket_arn, namespace, name, and policy are required"})
writeJSONError(w, http.StatusBadRequest, "bucket_arn, namespace, name, and policy are required")
return
}
namespaceParts, ok := s.parseNamespaceFromGin(c, req.Namespace)
namespaceParts, ok := s.parseNamespaceFromRequest(w, 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)
if err := s.executeS3TablesOperation(r.Context(), "PutTablePolicy", putReq, nil); err != nil {
writeS3TablesError(w, err)
return
}
c.JSON(200, gin.H{"message": "Policy updated"})
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Policy updated"})
}
func (s *AdminServer) GetS3TablesTablePolicy(c *gin.Context) {
bucketArn := c.Query("bucket")
namespace := c.Query("namespace")
name := c.Query("name")
func (s *AdminServer) GetS3TablesTablePolicy(w http.ResponseWriter, r *http.Request) {
bucketArn := r.URL.Query().Get("bucket")
namespace := r.URL.Query().Get("namespace")
name := r.URL.Query().Get("name")
if bucketArn == "" || namespace == "" || name == "" {
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
writeJSONError(w, http.StatusBadRequest, "bucket, namespace, and name query parameters are required")
return
}
namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
namespaceParts, ok := s.parseNamespaceFromRequest(w, 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)
if err := s.executeS3TablesOperation(r.Context(), "GetTablePolicy", getReq, &resp); err != nil {
writeS3TablesError(w, err)
return
}
c.JSON(200, gin.H{"policy": resp.ResourcePolicy})
writeJSON(w, http.StatusOK, map[string]interface{}{"policy": resp.ResourcePolicy})
}
func (s *AdminServer) DeleteS3TablesTablePolicy(c *gin.Context) {
bucketArn := c.Query("bucket")
namespace := c.Query("namespace")
name := c.Query("name")
if bucketArn == "" || namespace == "" || name == "" {
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
func (s *AdminServer) DeleteS3TablesTablePolicy(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
bucketArn := r.URL.Query().Get("bucket")
namespace := r.URL.Query().Get("namespace")
name := r.URL.Query().Get("name")
if bucketArn == "" || namespace == "" || name == "" {
writeJSONError(w, http.StatusBadRequest, "bucket, namespace, and name query parameters are required")
return
}
namespaceParts, ok := s.parseNamespaceFromRequest(w, 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)
if err := s.executeS3TablesOperation(r.Context(), "DeleteTablePolicy", deleteReq, nil); err != nil {
writeS3TablesError(w, err)
return
}
c.JSON(200, gin.H{"message": "Policy deleted"})
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Policy deleted"})
}
func (s *AdminServer) TagS3TablesResource(c *gin.Context) {
func (s *AdminServer) TagS3TablesResource(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
ResourceARN string `json:"resource_arn"`
Tags map[string]string `json:"tags"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.ResourceARN == "" || len(req.Tags) == 0 {
c.JSON(400, gin.H{"error": "resource_arn and tags are required"})
writeJSONError(w, http.StatusBadRequest, "resource_arn and tags are required")
return
}
if err := s3tables.ValidateTags(req.Tags); err != nil {
c.JSON(400, gin.H{"error": "Invalid tags: " + err.Error()})
writeJSONError(w, http.StatusBadRequest, "Invalid tags: "+err.Error())
return
}
tagReq := &s3tables.TagResourceRequest{ResourceARN: req.ResourceARN, Tags: req.Tags}
if err := s.executeS3TablesOperation(c.Request.Context(), "TagResource", tagReq, nil); err != nil {
writeS3TablesError(c, err)
if err := s.executeS3TablesOperation(r.Context(), "TagResource", tagReq, nil); err != nil {
writeS3TablesError(w, err)
return
}
c.JSON(200, gin.H{"message": "Tags updated"})
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Tags updated"})
}
func (s *AdminServer) ListS3TablesTags(c *gin.Context) {
resourceArn := c.Query("arn")
func (s *AdminServer) ListS3TablesTags(w http.ResponseWriter, r *http.Request) {
resourceArn := r.URL.Query().Get("arn")
if resourceArn == "" {
c.JSON(400, gin.H{"error": "arn query parameter is required"})
writeJSONError(w, http.StatusBadRequest, "arn query parameter is required")
return
}
listReq := &s3tables.ListTagsForResourceRequest{ResourceARN: resourceArn}
var resp s3tables.ListTagsForResourceResponse
if err := s.executeS3TablesOperation(c.Request.Context(), "ListTagsForResource", listReq, &resp); err != nil {
writeS3TablesError(c, err)
if err := s.executeS3TablesOperation(r.Context(), "ListTagsForResource", listReq, &resp); err != nil {
writeS3TablesError(w, err)
return
}
c.JSON(200, resp)
writeJSON(w, http.StatusOK, resp)
}
func (s *AdminServer) UntagS3TablesResource(c *gin.Context) {
func (s *AdminServer) UntagS3TablesResource(w http.ResponseWriter, r *http.Request) {
if !requireSessionCSRFToken(w, r) {
return
}
var req struct {
ResourceARN string `json:"resource_arn"`
TagKeys []string `json:"tag_keys"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.ResourceARN == "" || len(req.TagKeys) == 0 {
c.JSON(400, gin.H{"error": "resource_arn and tag_keys are required"})
writeJSONError(w, http.StatusBadRequest, "resource_arn and tag_keys are required")
return
}
untagReq := &s3tables.UntagResourceRequest{ResourceARN: req.ResourceARN, TagKeys: req.TagKeys}
if err := s.executeS3TablesOperation(c.Request.Context(), "UntagResource", untagReq, nil); err != nil {
writeS3TablesError(c, err)
if err := s.executeS3TablesOperation(r.Context(), "UntagResource", untagReq, nil); err != nil {
writeS3TablesError(w, err)
return
}
c.JSON(200, gin.H{"message": "Tags removed"})
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Tags removed"})
}
func parseS3TablesErrorMessage(err error) string {
@@ -1018,8 +1047,8 @@ func parseS3TablesErrorMessage(err error) string {
return err.Error()
}
func writeS3TablesError(c *gin.Context, err error) {
c.JSON(s3TablesErrorStatus(err), gin.H{"error": parseS3TablesErrorMessage(err)})
func writeS3TablesError(w http.ResponseWriter, err error) {
writeJSONError(w, s3TablesErrorStatus(err), parseS3TablesErrorMessage(err))
}
func s3TablesErrorStatus(err error) int {
+271 -446
View File
@@ -5,7 +5,8 @@ import (
"net/url"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
@@ -18,6 +19,7 @@ import (
// AdminHandlers contains all the HTTP handlers for the admin interface
type AdminHandlers struct {
adminServer *dash.AdminServer
sessionStore sessions.Store
authHandlers *AuthHandlers
clusterHandlers *ClusterHandlers
fileBrowserHandlers *FileBrowserHandlers
@@ -29,8 +31,8 @@ type AdminHandlers struct {
}
// NewAdminHandlers creates a new instance of AdminHandlers
func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
authHandlers := NewAuthHandlers(adminServer)
func NewAdminHandlers(adminServer *dash.AdminServer, store sessions.Store) *AdminHandlers {
authHandlers := NewAuthHandlers(adminServer, store)
clusterHandlers := NewClusterHandlers(adminServer)
fileBrowserHandlers := NewFileBrowserHandlers(adminServer)
userHandlers := NewUserHandlers(adminServer)
@@ -40,6 +42,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
serviceAccountHandlers := NewServiceAccountHandlers(adminServer)
return &AdminHandlers{
adminServer: adminServer,
sessionStore: store,
authHandlers: authHandlers,
clusterHandlers: clusterHandlers,
fileBrowserHandlers: fileBrowserHandlers,
@@ -52,17 +55,17 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
}
// SetupRoutes configures all the routes for the admin interface
func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, adminPassword, readOnlyUser, readOnlyPassword string, enableUI bool) {
func (h *AdminHandlers) SetupRoutes(r *mux.Router, authRequired bool, adminUser, adminPassword, readOnlyUser, readOnlyPassword string, enableUI bool) {
// Health check (no auth required)
r.GET("/health", h.HealthCheck)
r.HandleFunc("/health", h.HealthCheck).Methods(http.MethodGet)
// Prometheus metrics endpoint (no auth required)
r.GET("/metrics", gin.WrapH(promhttp.HandlerFor(stats.Gather, promhttp.HandlerOpts{})))
r.Handle("/metrics", promhttp.HandlerFor(stats.Gather, promhttp.HandlerOpts{})).Methods(http.MethodGet)
// Favicon route (no auth required) - redirect to static version
r.GET("/favicon.ico", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/static/favicon.ico")
})
r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/static/favicon.ico", http.StatusMovedPermanently)
}).Methods(http.MethodGet)
// Skip UI routes if UI is not enabled
if !enableUI {
@@ -71,499 +74,321 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
if authRequired {
// Authentication routes (no auth required)
r.GET("/login", h.authHandlers.ShowLogin)
r.POST("/login", h.authHandlers.HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword))
r.GET("/logout", h.authHandlers.HandleLogout)
r.HandleFunc("/login", h.authHandlers.ShowLogin).Methods(http.MethodGet)
r.Handle("/login", h.authHandlers.HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword)).Methods(http.MethodPost)
r.HandleFunc("/logout", h.authHandlers.HandleLogout).Methods(http.MethodGet)
// Protected routes group
protected := r.Group("/")
protected.Use(dash.RequireAuth())
protected := r.NewRoute().Subrouter()
protected.Use(dash.RequireAuth(h.sessionStore))
h.registerUIRoutes(protected)
// Main admin interface routes
protected.GET("/", h.ShowDashboard)
protected.GET("/admin", h.ShowDashboard)
// Object Store management routes
protected.GET("/object-store/buckets", h.ShowS3Buckets)
protected.GET("/object-store/buckets/:bucket", h.ShowBucketDetails)
protected.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers)
protected.GET("/object-store/policies", h.policyHandlers.ShowPolicies)
protected.GET("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts)
protected.GET("/object-store/s3tables/buckets", h.ShowS3TablesBuckets)
protected.GET("/object-store/s3tables/buckets/:bucket/namespaces", h.ShowS3TablesNamespaces)
protected.GET("/object-store/s3tables/buckets/:bucket/namespaces/:namespace/tables", h.ShowS3TablesTables)
protected.GET("/object-store/s3tables/buckets/:bucket/namespaces/:namespace/tables/:table", h.ShowS3TablesTableDetails)
protected.GET("/object-store/iceberg", h.ShowIcebergCatalog)
protected.GET("/object-store/iceberg/:catalog/namespaces", h.ShowIcebergNamespaces)
protected.GET("/object-store/iceberg/:catalog/namespaces/:namespace/tables", h.ShowIcebergTables)
protected.GET("/object-store/iceberg/:catalog/namespaces/:namespace/tables/:table", h.ShowIcebergTableDetails)
// File browser routes
protected.GET("/files", h.fileBrowserHandlers.ShowFileBrowser)
// Cluster management routes
protected.GET("/cluster/masters", h.clusterHandlers.ShowClusterMasters)
protected.GET("/cluster/filers", h.clusterHandlers.ShowClusterFilers)
protected.GET("/cluster/volume-servers", h.clusterHandlers.ShowClusterVolumeServers)
// Storage management routes
protected.GET("/storage/volumes", h.clusterHandlers.ShowClusterVolumes)
protected.GET("/storage/volumes/:id/:server", h.clusterHandlers.ShowVolumeDetails)
protected.GET("/storage/collections", h.clusterHandlers.ShowClusterCollections)
protected.GET("/storage/collections/:name", h.clusterHandlers.ShowCollectionDetails)
protected.GET("/storage/ec-shards", h.clusterHandlers.ShowClusterEcShards)
protected.GET("/storage/ec-volumes/:id", h.clusterHandlers.ShowEcVolumeDetails)
// Message Queue management routes
protected.GET("/mq/brokers", h.mqHandlers.ShowBrokers)
protected.GET("/mq/topics", h.mqHandlers.ShowTopics)
protected.GET("/mq/topics/:namespace/:topic", h.mqHandlers.ShowTopicDetails)
protected.GET("/plugin", h.pluginHandlers.ShowPlugin)
protected.GET("/plugin/configuration", h.pluginHandlers.ShowPluginConfiguration)
protected.GET("/plugin/queue", h.pluginHandlers.ShowPluginQueue)
protected.GET("/plugin/detection", h.pluginHandlers.ShowPluginDetection)
protected.GET("/plugin/execution", h.pluginHandlers.ShowPluginExecution)
protected.GET("/plugin/monitoring", h.pluginHandlers.ShowPluginMonitoring)
// API routes for AJAX calls
api := r.Group("/api")
api.Use(dash.RequireAuthAPI()) // Use API-specific auth middleware
{
api.GET("/cluster/topology", h.clusterHandlers.GetClusterTopology)
api.GET("/cluster/masters", h.clusterHandlers.GetMasters)
api.GET("/cluster/volumes", h.clusterHandlers.GetVolumeServers)
api.GET("/admin", h.adminServer.ShowAdmin) // JSON API for admin data
api.GET("/config", h.adminServer.GetConfigInfo) // Configuration information
// S3 API routes
s3Api := api.Group("/s3")
{
s3Api.GET("/buckets", h.adminServer.ListBucketsAPI)
s3Api.POST("/buckets", dash.RequireWriteAccess(), h.adminServer.CreateBucket)
s3Api.DELETE("/buckets/:bucket", dash.RequireWriteAccess(), h.adminServer.DeleteBucket)
s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails)
s3Api.PUT("/buckets/:bucket/quota", dash.RequireWriteAccess(), h.adminServer.UpdateBucketQuota)
s3Api.PUT("/buckets/:bucket/owner", dash.RequireWriteAccess(), h.adminServer.UpdateBucketOwner)
}
// User management API routes
usersApi := api.Group("/users")
{
usersApi.GET("", h.userHandlers.GetUsers)
usersApi.POST("", dash.RequireWriteAccess(), h.userHandlers.CreateUser)
usersApi.GET("/:username", h.userHandlers.GetUserDetails)
usersApi.PUT("/:username", dash.RequireWriteAccess(), h.userHandlers.UpdateUser)
usersApi.DELETE("/:username", dash.RequireWriteAccess(), h.userHandlers.DeleteUser)
usersApi.POST("/:username/access-keys", dash.RequireWriteAccess(), h.userHandlers.CreateAccessKey)
usersApi.DELETE("/:username/access-keys/:accessKeyId", dash.RequireWriteAccess(), h.userHandlers.DeleteAccessKey)
usersApi.PUT("/:username/access-keys/:accessKeyId/status", dash.RequireWriteAccess(), h.userHandlers.UpdateAccessKeyStatus)
usersApi.GET("/:username/policies", h.userHandlers.GetUserPolicies)
usersApi.PUT("/:username/policies", dash.RequireWriteAccess(), h.userHandlers.UpdateUserPolicies)
}
// Service Account management API routes
saApi := api.Group("/service-accounts")
{
saApi.GET("", h.serviceAccountHandlers.GetServiceAccounts)
saApi.POST("", dash.RequireWriteAccess(), h.serviceAccountHandlers.CreateServiceAccount)
saApi.GET("/:id", h.serviceAccountHandlers.GetServiceAccountDetails)
saApi.PUT("/:id", dash.RequireWriteAccess(), h.serviceAccountHandlers.UpdateServiceAccount)
saApi.DELETE("/:id", dash.RequireWriteAccess(), h.serviceAccountHandlers.DeleteServiceAccount)
}
// Object Store Policy management API routes
objectStorePoliciesApi := api.Group("/object-store/policies")
{
objectStorePoliciesApi.GET("", h.policyHandlers.GetPolicies)
objectStorePoliciesApi.POST("", dash.RequireWriteAccess(), h.policyHandlers.CreatePolicy)
objectStorePoliciesApi.GET("/:name", h.policyHandlers.GetPolicy)
objectStorePoliciesApi.PUT("/:name", dash.RequireWriteAccess(), h.policyHandlers.UpdatePolicy)
objectStorePoliciesApi.DELETE("/:name", dash.RequireWriteAccess(), h.policyHandlers.DeletePolicy)
objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy)
}
// S3 Tables API routes
s3TablesApi := api.Group("/s3tables")
{
s3TablesApi.GET("/buckets", h.adminServer.ListS3TablesBucketsAPI)
s3TablesApi.POST("/buckets", dash.RequireWriteAccess(), h.adminServer.CreateS3TablesBucket)
s3TablesApi.DELETE("/buckets", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesBucket)
s3TablesApi.GET("/namespaces", h.adminServer.ListS3TablesNamespacesAPI)
s3TablesApi.POST("/namespaces", dash.RequireWriteAccess(), h.adminServer.CreateS3TablesNamespace)
s3TablesApi.DELETE("/namespaces", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesNamespace)
s3TablesApi.GET("/tables", h.adminServer.ListS3TablesTablesAPI)
s3TablesApi.POST("/tables", dash.RequireWriteAccess(), h.adminServer.CreateS3TablesTable)
s3TablesApi.DELETE("/tables", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesTable)
s3TablesApi.PUT("/bucket-policy", dash.RequireWriteAccess(), h.adminServer.PutS3TablesBucketPolicy)
s3TablesApi.GET("/bucket-policy", h.adminServer.GetS3TablesBucketPolicy)
s3TablesApi.DELETE("/bucket-policy", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesBucketPolicy)
s3TablesApi.PUT("/table-policy", dash.RequireWriteAccess(), h.adminServer.PutS3TablesTablePolicy)
s3TablesApi.GET("/table-policy", h.adminServer.GetS3TablesTablePolicy)
s3TablesApi.DELETE("/table-policy", dash.RequireWriteAccess(), h.adminServer.DeleteS3TablesTablePolicy)
s3TablesApi.PUT("/tags", dash.RequireWriteAccess(), h.adminServer.TagS3TablesResource)
s3TablesApi.GET("/tags", h.adminServer.ListS3TablesTags)
s3TablesApi.DELETE("/tags", dash.RequireWriteAccess(), h.adminServer.UntagS3TablesResource)
}
// File management API routes
filesApi := api.Group("/files")
{
filesApi.DELETE("/delete", dash.RequireWriteAccess(), h.fileBrowserHandlers.DeleteFile)
filesApi.DELETE("/delete-multiple", dash.RequireWriteAccess(), h.fileBrowserHandlers.DeleteMultipleFiles)
filesApi.POST("/create-folder", dash.RequireWriteAccess(), h.fileBrowserHandlers.CreateFolder)
filesApi.POST("/upload", dash.RequireWriteAccess(), h.fileBrowserHandlers.UploadFile)
filesApi.GET("/download", h.fileBrowserHandlers.DownloadFile)
filesApi.GET("/view", h.fileBrowserHandlers.ViewFile)
filesApi.GET("/properties", h.fileBrowserHandlers.GetFileProperties)
}
// Volume management API routes
volumeApi := api.Group("/volumes")
{
volumeApi.POST("/:id/:server/vacuum", dash.RequireWriteAccess(), h.clusterHandlers.VacuumVolume)
}
// Plugin API routes
pluginApi := api.Group("/plugin")
{
pluginApi.GET("/status", h.adminServer.GetPluginStatusAPI)
pluginApi.GET("/workers", h.adminServer.GetPluginWorkersAPI)
pluginApi.GET("/job-types", h.adminServer.GetPluginJobTypesAPI)
pluginApi.GET("/jobs", h.adminServer.GetPluginJobsAPI)
pluginApi.GET("/jobs/:jobId", h.adminServer.GetPluginJobAPI)
pluginApi.GET("/jobs/:jobId/detail", h.adminServer.GetPluginJobDetailAPI)
pluginApi.GET("/activities", h.adminServer.GetPluginActivitiesAPI)
pluginApi.GET("/scheduler-states", h.adminServer.GetPluginSchedulerStatesAPI)
pluginApi.GET("/job-types/:jobType/descriptor", h.adminServer.GetPluginJobTypeDescriptorAPI)
pluginApi.POST("/job-types/:jobType/schema", h.adminServer.RequestPluginJobTypeSchemaAPI)
pluginApi.GET("/job-types/:jobType/config", h.adminServer.GetPluginJobTypeConfigAPI)
pluginApi.PUT("/job-types/:jobType/config", dash.RequireWriteAccess(), h.adminServer.UpdatePluginJobTypeConfigAPI)
pluginApi.GET("/job-types/:jobType/runs", h.adminServer.GetPluginRunHistoryAPI)
pluginApi.POST("/job-types/:jobType/detect", dash.RequireWriteAccess(), h.adminServer.TriggerPluginDetectionAPI)
pluginApi.POST("/job-types/:jobType/run", dash.RequireWriteAccess(), h.adminServer.RunPluginJobTypeAPI)
pluginApi.POST("/jobs/execute", dash.RequireWriteAccess(), h.adminServer.ExecutePluginJobAPI)
}
// Message Queue API routes
mqApi := api.Group("/mq")
{
mqApi.GET("/topics/:namespace/:topic", h.mqHandlers.GetTopicDetailsAPI)
mqApi.POST("/topics/create", dash.RequireWriteAccess(), h.mqHandlers.CreateTopicAPI)
mqApi.POST("/topics/retention/update", dash.RequireWriteAccess(), h.mqHandlers.UpdateTopicRetentionAPI)
mqApi.POST("/retention/purge", dash.RequireWriteAccess(), h.adminServer.TriggerTopicRetentionPurgeAPI)
}
}
} else {
// No authentication required - all routes are public
r.GET("/", h.ShowDashboard)
r.GET("/admin", h.ShowDashboard)
// Object Store management routes
r.GET("/object-store/buckets", h.ShowS3Buckets)
r.GET("/object-store/buckets/:bucket", h.ShowBucketDetails)
r.GET("/object-store/users", h.userHandlers.ShowObjectStoreUsers)
r.GET("/object-store/policies", h.policyHandlers.ShowPolicies)
r.GET("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts)
r.GET("/object-store/s3tables/buckets", h.ShowS3TablesBuckets)
r.GET("/object-store/s3tables/buckets/:bucket/namespaces", h.ShowS3TablesNamespaces)
r.GET("/object-store/s3tables/buckets/:bucket/namespaces/:namespace/tables", h.ShowS3TablesTables)
r.GET("/object-store/s3tables/buckets/:bucket/namespaces/:namespace/tables/:table", h.ShowS3TablesTableDetails)
r.GET("/object-store/iceberg", h.ShowIcebergCatalog)
r.GET("/object-store/iceberg/:catalog/namespaces", h.ShowIcebergNamespaces)
r.GET("/object-store/iceberg/:catalog/namespaces/:namespace/tables", h.ShowIcebergTables)
r.GET("/object-store/iceberg/:catalog/namespaces/:namespace/tables/:table", h.ShowIcebergTableDetails)
// File browser routes
r.GET("/files", h.fileBrowserHandlers.ShowFileBrowser)
// Cluster management routes
r.GET("/cluster/masters", h.clusterHandlers.ShowClusterMasters)
r.GET("/cluster/filers", h.clusterHandlers.ShowClusterFilers)
r.GET("/cluster/volume-servers", h.clusterHandlers.ShowClusterVolumeServers)
// Storage management routes
r.GET("/storage/volumes", h.clusterHandlers.ShowClusterVolumes)
r.GET("/storage/volumes/:id/:server", h.clusterHandlers.ShowVolumeDetails)
r.GET("/storage/collections", h.clusterHandlers.ShowClusterCollections)
r.GET("/storage/collections/:name", h.clusterHandlers.ShowCollectionDetails)
r.GET("/storage/ec-shards", h.clusterHandlers.ShowClusterEcShards)
r.GET("/storage/ec-volumes/:id", h.clusterHandlers.ShowEcVolumeDetails)
// Message Queue management routes
r.GET("/mq/brokers", h.mqHandlers.ShowBrokers)
r.GET("/mq/topics", h.mqHandlers.ShowTopics)
r.GET("/mq/topics/:namespace/:topic", h.mqHandlers.ShowTopicDetails)
r.GET("/plugin", h.pluginHandlers.ShowPlugin)
r.GET("/plugin/configuration", h.pluginHandlers.ShowPluginConfiguration)
r.GET("/plugin/queue", h.pluginHandlers.ShowPluginQueue)
r.GET("/plugin/detection", h.pluginHandlers.ShowPluginDetection)
r.GET("/plugin/execution", h.pluginHandlers.ShowPluginExecution)
r.GET("/plugin/monitoring", h.pluginHandlers.ShowPluginMonitoring)
// API routes for AJAX calls
api := r.Group("/api")
{
api.GET("/cluster/topology", h.clusterHandlers.GetClusterTopology)
api.GET("/cluster/masters", h.clusterHandlers.GetMasters)
api.GET("/cluster/volumes", h.clusterHandlers.GetVolumeServers)
api.GET("/admin", h.adminServer.ShowAdmin) // JSON API for admin data
api.GET("/config", h.adminServer.GetConfigInfo) // Configuration information
// S3 API routes
s3Api := api.Group("/s3")
{
s3Api.GET("/buckets", h.adminServer.ListBucketsAPI)
s3Api.POST("/buckets", h.adminServer.CreateBucket)
s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket)
s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails)
s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota)
s3Api.PUT("/buckets/:bucket/owner", h.adminServer.UpdateBucketOwner)
}
// User management API routes
usersApi := api.Group("/users")
{
usersApi.GET("", h.userHandlers.GetUsers)
usersApi.POST("", h.userHandlers.CreateUser)
usersApi.GET("/:username", h.userHandlers.GetUserDetails)
usersApi.PUT("/:username", h.userHandlers.UpdateUser)
usersApi.DELETE("/:username", h.userHandlers.DeleteUser)
usersApi.POST("/:username/access-keys", h.userHandlers.CreateAccessKey)
usersApi.DELETE("/:username/access-keys/:accessKeyId", h.userHandlers.DeleteAccessKey)
usersApi.PUT("/:username/access-keys/:accessKeyId/status", h.userHandlers.UpdateAccessKeyStatus)
usersApi.GET("/:username/policies", h.userHandlers.GetUserPolicies)
usersApi.PUT("/:username/policies", h.userHandlers.UpdateUserPolicies)
}
// Service Account management API routes
saApi := api.Group("/service-accounts")
{
saApi.GET("", h.serviceAccountHandlers.GetServiceAccounts)
saApi.POST("", h.serviceAccountHandlers.CreateServiceAccount)
saApi.GET("/:id", h.serviceAccountHandlers.GetServiceAccountDetails)
saApi.PUT("/:id", h.serviceAccountHandlers.UpdateServiceAccount)
saApi.DELETE("/:id", h.serviceAccountHandlers.DeleteServiceAccount)
}
// Object Store Policy management API routes
objectStorePoliciesApi := api.Group("/object-store/policies")
{
objectStorePoliciesApi.GET("", h.policyHandlers.GetPolicies)
objectStorePoliciesApi.POST("", h.policyHandlers.CreatePolicy)
objectStorePoliciesApi.GET("/:name", h.policyHandlers.GetPolicy)
objectStorePoliciesApi.PUT("/:name", h.policyHandlers.UpdatePolicy)
objectStorePoliciesApi.DELETE("/:name", h.policyHandlers.DeletePolicy)
objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy)
}
// S3 Tables API routes
s3TablesApi := api.Group("/s3tables")
{
s3TablesApi.GET("/buckets", h.adminServer.ListS3TablesBucketsAPI)
s3TablesApi.POST("/buckets", h.adminServer.CreateS3TablesBucket)
s3TablesApi.DELETE("/buckets", h.adminServer.DeleteS3TablesBucket)
s3TablesApi.GET("/namespaces", h.adminServer.ListS3TablesNamespacesAPI)
s3TablesApi.POST("/namespaces", h.adminServer.CreateS3TablesNamespace)
s3TablesApi.DELETE("/namespaces", h.adminServer.DeleteS3TablesNamespace)
s3TablesApi.GET("/tables", h.adminServer.ListS3TablesTablesAPI)
s3TablesApi.POST("/tables", h.adminServer.CreateS3TablesTable)
s3TablesApi.DELETE("/tables", h.adminServer.DeleteS3TablesTable)
s3TablesApi.PUT("/bucket-policy", h.adminServer.PutS3TablesBucketPolicy)
s3TablesApi.GET("/bucket-policy", h.adminServer.GetS3TablesBucketPolicy)
s3TablesApi.DELETE("/bucket-policy", h.adminServer.DeleteS3TablesBucketPolicy)
s3TablesApi.PUT("/table-policy", h.adminServer.PutS3TablesTablePolicy)
s3TablesApi.GET("/table-policy", h.adminServer.GetS3TablesTablePolicy)
s3TablesApi.DELETE("/table-policy", h.adminServer.DeleteS3TablesTablePolicy)
s3TablesApi.PUT("/tags", h.adminServer.TagS3TablesResource)
s3TablesApi.GET("/tags", h.adminServer.ListS3TablesTags)
s3TablesApi.DELETE("/tags", h.adminServer.UntagS3TablesResource)
}
// File management API routes
filesApi := api.Group("/files")
{
filesApi.DELETE("/delete", h.fileBrowserHandlers.DeleteFile)
filesApi.DELETE("/delete-multiple", h.fileBrowserHandlers.DeleteMultipleFiles)
filesApi.POST("/create-folder", h.fileBrowserHandlers.CreateFolder)
filesApi.POST("/upload", h.fileBrowserHandlers.UploadFile)
filesApi.GET("/download", h.fileBrowserHandlers.DownloadFile)
filesApi.GET("/view", h.fileBrowserHandlers.ViewFile)
filesApi.GET("/properties", h.fileBrowserHandlers.GetFileProperties)
}
// Volume management API routes
volumeApi := api.Group("/volumes")
{
volumeApi.POST("/:id/:server/vacuum", h.clusterHandlers.VacuumVolume)
}
// Plugin API routes
pluginApi := api.Group("/plugin")
{
pluginApi.GET("/status", h.adminServer.GetPluginStatusAPI)
pluginApi.GET("/workers", h.adminServer.GetPluginWorkersAPI)
pluginApi.GET("/job-types", h.adminServer.GetPluginJobTypesAPI)
pluginApi.GET("/jobs", h.adminServer.GetPluginJobsAPI)
pluginApi.GET("/jobs/:jobId", h.adminServer.GetPluginJobAPI)
pluginApi.GET("/jobs/:jobId/detail", h.adminServer.GetPluginJobDetailAPI)
pluginApi.GET("/activities", h.adminServer.GetPluginActivitiesAPI)
pluginApi.GET("/scheduler-states", h.adminServer.GetPluginSchedulerStatesAPI)
pluginApi.GET("/job-types/:jobType/descriptor", h.adminServer.GetPluginJobTypeDescriptorAPI)
pluginApi.POST("/job-types/:jobType/schema", h.adminServer.RequestPluginJobTypeSchemaAPI)
pluginApi.GET("/job-types/:jobType/config", h.adminServer.GetPluginJobTypeConfigAPI)
pluginApi.PUT("/job-types/:jobType/config", h.adminServer.UpdatePluginJobTypeConfigAPI)
pluginApi.GET("/job-types/:jobType/runs", h.adminServer.GetPluginRunHistoryAPI)
pluginApi.POST("/job-types/:jobType/detect", h.adminServer.TriggerPluginDetectionAPI)
pluginApi.POST("/job-types/:jobType/run", h.adminServer.RunPluginJobTypeAPI)
pluginApi.POST("/jobs/execute", h.adminServer.ExecutePluginJobAPI)
}
// Message Queue API routes
mqApi := api.Group("/mq")
{
mqApi.GET("/topics/:namespace/:topic", h.mqHandlers.GetTopicDetailsAPI)
mqApi.POST("/topics/create", h.mqHandlers.CreateTopicAPI)
mqApi.POST("/topics/retention/update", h.mqHandlers.UpdateTopicRetentionAPI)
mqApi.POST("/retention/purge", h.adminServer.TriggerTopicRetentionPurgeAPI)
}
}
api := r.PathPrefix("/api").Subrouter()
api.Use(dash.RequireAuthAPI(h.sessionStore))
h.registerAPIRoutes(api, true)
return
}
// No authentication required - all routes are public
h.registerUIRoutes(r)
api := r.PathPrefix("/api").Subrouter()
h.registerAPIRoutes(api, false)
}
func (h *AdminHandlers) registerUIRoutes(r *mux.Router) {
// Main admin interface routes
r.HandleFunc("/", h.ShowDashboard).Methods(http.MethodGet)
r.HandleFunc("/admin", h.ShowDashboard).Methods(http.MethodGet)
// Object Store management routes
r.HandleFunc("/object-store/buckets", h.ShowS3Buckets).Methods(http.MethodGet)
r.HandleFunc("/object-store/buckets/{bucket}", h.ShowBucketDetails).Methods(http.MethodGet)
r.HandleFunc("/object-store/users", h.userHandlers.ShowObjectStoreUsers).Methods(http.MethodGet)
r.HandleFunc("/object-store/policies", h.policyHandlers.ShowPolicies).Methods(http.MethodGet)
r.HandleFunc("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts).Methods(http.MethodGet)
r.HandleFunc("/object-store/s3tables/buckets", h.ShowS3TablesBuckets).Methods(http.MethodGet)
r.HandleFunc("/object-store/s3tables/buckets/{bucket}/namespaces", h.ShowS3TablesNamespaces).Methods(http.MethodGet)
r.HandleFunc("/object-store/s3tables/buckets/{bucket}/namespaces/{namespace}/tables", h.ShowS3TablesTables).Methods(http.MethodGet)
r.HandleFunc("/object-store/s3tables/buckets/{bucket}/namespaces/{namespace}/tables/{table}", h.ShowS3TablesTableDetails).Methods(http.MethodGet)
r.HandleFunc("/object-store/iceberg", h.ShowIcebergCatalog).Methods(http.MethodGet)
r.HandleFunc("/object-store/iceberg/{catalog}/namespaces", h.ShowIcebergNamespaces).Methods(http.MethodGet)
r.HandleFunc("/object-store/iceberg/{catalog}/namespaces/{namespace}/tables", h.ShowIcebergTables).Methods(http.MethodGet)
r.HandleFunc("/object-store/iceberg/{catalog}/namespaces/{namespace}/tables/{table}", h.ShowIcebergTableDetails).Methods(http.MethodGet)
// File browser routes
r.HandleFunc("/files", h.fileBrowserHandlers.ShowFileBrowser).Methods(http.MethodGet)
// Cluster management routes
r.HandleFunc("/cluster/masters", h.clusterHandlers.ShowClusterMasters).Methods(http.MethodGet)
r.HandleFunc("/cluster/filers", h.clusterHandlers.ShowClusterFilers).Methods(http.MethodGet)
r.HandleFunc("/cluster/volume-servers", h.clusterHandlers.ShowClusterVolumeServers).Methods(http.MethodGet)
// Storage management routes
r.HandleFunc("/storage/volumes", h.clusterHandlers.ShowClusterVolumes).Methods(http.MethodGet)
r.HandleFunc("/storage/volumes/{id}/{server}", h.clusterHandlers.ShowVolumeDetails).Methods(http.MethodGet)
r.HandleFunc("/storage/collections", h.clusterHandlers.ShowClusterCollections).Methods(http.MethodGet)
r.HandleFunc("/storage/collections/{name}", h.clusterHandlers.ShowCollectionDetails).Methods(http.MethodGet)
r.HandleFunc("/storage/ec-shards", h.clusterHandlers.ShowClusterEcShards).Methods(http.MethodGet)
r.HandleFunc("/storage/ec-volumes/{id}", h.clusterHandlers.ShowEcVolumeDetails).Methods(http.MethodGet)
// Message Queue management routes
r.HandleFunc("/mq/brokers", h.mqHandlers.ShowBrokers).Methods(http.MethodGet)
r.HandleFunc("/mq/topics", h.mqHandlers.ShowTopics).Methods(http.MethodGet)
r.HandleFunc("/mq/topics/{namespace}/{topic}", h.mqHandlers.ShowTopicDetails).Methods(http.MethodGet)
// Plugin pages
r.HandleFunc("/plugin", h.pluginHandlers.ShowPlugin).Methods(http.MethodGet)
r.HandleFunc("/plugin/configuration", h.pluginHandlers.ShowPluginConfiguration).Methods(http.MethodGet)
r.HandleFunc("/plugin/queue", h.pluginHandlers.ShowPluginQueue).Methods(http.MethodGet)
r.HandleFunc("/plugin/detection", h.pluginHandlers.ShowPluginDetection).Methods(http.MethodGet)
r.HandleFunc("/plugin/execution", h.pluginHandlers.ShowPluginExecution).Methods(http.MethodGet)
r.HandleFunc("/plugin/monitoring", h.pluginHandlers.ShowPluginMonitoring).Methods(http.MethodGet)
}
func (h *AdminHandlers) registerAPIRoutes(api *mux.Router, enforceWrite bool) {
wrapWrite := func(handler http.HandlerFunc) http.Handler {
if !enforceWrite {
return handler
}
return dash.RequireWriteAccess()(handler)
}
api.HandleFunc("/cluster/topology", h.clusterHandlers.GetClusterTopology).Methods(http.MethodGet)
api.HandleFunc("/cluster/masters", h.clusterHandlers.GetMasters).Methods(http.MethodGet)
api.HandleFunc("/cluster/volumes", h.clusterHandlers.GetVolumeServers).Methods(http.MethodGet)
api.HandleFunc("/admin", h.adminServer.ShowAdmin).Methods(http.MethodGet)
api.HandleFunc("/config", h.adminServer.GetConfigInfo).Methods(http.MethodGet)
s3Api := api.PathPrefix("/s3").Subrouter()
s3Api.HandleFunc("/buckets", h.adminServer.ListBucketsAPI).Methods(http.MethodGet)
s3Api.Handle("/buckets", wrapWrite(h.adminServer.CreateBucket)).Methods(http.MethodPost)
s3Api.Handle("/buckets/{bucket}", wrapWrite(h.adminServer.DeleteBucket)).Methods(http.MethodDelete)
s3Api.HandleFunc("/buckets/{bucket}", h.adminServer.ShowBucketDetails).Methods(http.MethodGet)
s3Api.Handle("/buckets/{bucket}/quota", wrapWrite(h.adminServer.UpdateBucketQuota)).Methods(http.MethodPut)
s3Api.Handle("/buckets/{bucket}/owner", wrapWrite(h.adminServer.UpdateBucketOwner)).Methods(http.MethodPut)
usersApi := api.PathPrefix("/users").Subrouter()
usersApi.HandleFunc("", h.userHandlers.GetUsers).Methods(http.MethodGet)
usersApi.Handle("", wrapWrite(h.userHandlers.CreateUser)).Methods(http.MethodPost)
usersApi.HandleFunc("/{username}", h.userHandlers.GetUserDetails).Methods(http.MethodGet)
usersApi.Handle("/{username}", wrapWrite(h.userHandlers.UpdateUser)).Methods(http.MethodPut)
usersApi.Handle("/{username}", wrapWrite(h.userHandlers.DeleteUser)).Methods(http.MethodDelete)
usersApi.Handle("/{username}/access-keys", wrapWrite(h.userHandlers.CreateAccessKey)).Methods(http.MethodPost)
usersApi.Handle("/{username}/access-keys/{accessKeyId}", wrapWrite(h.userHandlers.DeleteAccessKey)).Methods(http.MethodDelete)
usersApi.Handle("/{username}/access-keys/{accessKeyId}/status", wrapWrite(h.userHandlers.UpdateAccessKeyStatus)).Methods(http.MethodPut)
usersApi.HandleFunc("/{username}/policies", h.userHandlers.GetUserPolicies).Methods(http.MethodGet)
usersApi.Handle("/{username}/policies", wrapWrite(h.userHandlers.UpdateUserPolicies)).Methods(http.MethodPut)
saApi := api.PathPrefix("/service-accounts").Subrouter()
saApi.HandleFunc("", h.serviceAccountHandlers.GetServiceAccounts).Methods(http.MethodGet)
saApi.Handle("", wrapWrite(h.serviceAccountHandlers.CreateServiceAccount)).Methods(http.MethodPost)
saApi.HandleFunc("/{id}", h.serviceAccountHandlers.GetServiceAccountDetails).Methods(http.MethodGet)
saApi.Handle("/{id}", wrapWrite(h.serviceAccountHandlers.UpdateServiceAccount)).Methods(http.MethodPut)
saApi.Handle("/{id}", wrapWrite(h.serviceAccountHandlers.DeleteServiceAccount)).Methods(http.MethodDelete)
policyApi := api.PathPrefix("/object-store/policies").Subrouter()
policyApi.HandleFunc("", h.policyHandlers.GetPolicies).Methods(http.MethodGet)
policyApi.Handle("", wrapWrite(h.policyHandlers.CreatePolicy)).Methods(http.MethodPost)
policyApi.HandleFunc("/{name}", h.policyHandlers.GetPolicy).Methods(http.MethodGet)
policyApi.Handle("/{name}", wrapWrite(h.policyHandlers.UpdatePolicy)).Methods(http.MethodPut)
policyApi.Handle("/{name}", wrapWrite(h.policyHandlers.DeletePolicy)).Methods(http.MethodDelete)
policyApi.HandleFunc("/validate", h.policyHandlers.ValidatePolicy).Methods(http.MethodPost)
s3TablesApi := api.PathPrefix("/s3tables").Subrouter()
s3TablesApi.HandleFunc("/buckets", h.adminServer.ListS3TablesBucketsAPI).Methods(http.MethodGet)
s3TablesApi.Handle("/buckets", wrapWrite(h.adminServer.CreateS3TablesBucket)).Methods(http.MethodPost)
s3TablesApi.Handle("/buckets", wrapWrite(h.adminServer.DeleteS3TablesBucket)).Methods(http.MethodDelete)
s3TablesApi.HandleFunc("/namespaces", h.adminServer.ListS3TablesNamespacesAPI).Methods(http.MethodGet)
s3TablesApi.Handle("/namespaces", wrapWrite(h.adminServer.CreateS3TablesNamespace)).Methods(http.MethodPost)
s3TablesApi.Handle("/namespaces", wrapWrite(h.adminServer.DeleteS3TablesNamespace)).Methods(http.MethodDelete)
s3TablesApi.HandleFunc("/tables", h.adminServer.ListS3TablesTablesAPI).Methods(http.MethodGet)
s3TablesApi.Handle("/tables", wrapWrite(h.adminServer.CreateS3TablesTable)).Methods(http.MethodPost)
s3TablesApi.Handle("/tables", wrapWrite(h.adminServer.DeleteS3TablesTable)).Methods(http.MethodDelete)
s3TablesApi.Handle("/bucket-policy", wrapWrite(h.adminServer.PutS3TablesBucketPolicy)).Methods(http.MethodPut)
s3TablesApi.HandleFunc("/bucket-policy", h.adminServer.GetS3TablesBucketPolicy).Methods(http.MethodGet)
s3TablesApi.Handle("/bucket-policy", wrapWrite(h.adminServer.DeleteS3TablesBucketPolicy)).Methods(http.MethodDelete)
s3TablesApi.Handle("/table-policy", wrapWrite(h.adminServer.PutS3TablesTablePolicy)).Methods(http.MethodPut)
s3TablesApi.HandleFunc("/table-policy", h.adminServer.GetS3TablesTablePolicy).Methods(http.MethodGet)
s3TablesApi.Handle("/table-policy", wrapWrite(h.adminServer.DeleteS3TablesTablePolicy)).Methods(http.MethodDelete)
s3TablesApi.Handle("/tags", wrapWrite(h.adminServer.TagS3TablesResource)).Methods(http.MethodPut)
s3TablesApi.HandleFunc("/tags", h.adminServer.ListS3TablesTags).Methods(http.MethodGet)
s3TablesApi.Handle("/tags", wrapWrite(h.adminServer.UntagS3TablesResource)).Methods(http.MethodDelete)
filesApi := api.PathPrefix("/files").Subrouter()
filesApi.Handle("/delete", wrapWrite(h.fileBrowserHandlers.DeleteFile)).Methods(http.MethodDelete)
filesApi.Handle("/delete-multiple", wrapWrite(h.fileBrowserHandlers.DeleteMultipleFiles)).Methods(http.MethodDelete)
filesApi.Handle("/create-folder", wrapWrite(h.fileBrowserHandlers.CreateFolder)).Methods(http.MethodPost)
filesApi.Handle("/upload", wrapWrite(h.fileBrowserHandlers.UploadFile)).Methods(http.MethodPost)
filesApi.HandleFunc("/download", h.fileBrowserHandlers.DownloadFile).Methods(http.MethodGet)
filesApi.HandleFunc("/view", h.fileBrowserHandlers.ViewFile).Methods(http.MethodGet)
filesApi.HandleFunc("/properties", h.fileBrowserHandlers.GetFileProperties).Methods(http.MethodGet)
volumeApi := api.PathPrefix("/volumes").Subrouter()
volumeApi.Handle("/{id}/{server}/vacuum", wrapWrite(h.clusterHandlers.VacuumVolume)).Methods(http.MethodPost)
pluginApi := api.PathPrefix("/plugin").Subrouter()
pluginApi.HandleFunc("/status", h.adminServer.GetPluginStatusAPI).Methods(http.MethodGet)
pluginApi.HandleFunc("/workers", h.adminServer.GetPluginWorkersAPI).Methods(http.MethodGet)
pluginApi.HandleFunc("/job-types", h.adminServer.GetPluginJobTypesAPI).Methods(http.MethodGet)
pluginApi.HandleFunc("/jobs", h.adminServer.GetPluginJobsAPI).Methods(http.MethodGet)
pluginApi.HandleFunc("/jobs/{jobId}", h.adminServer.GetPluginJobAPI).Methods(http.MethodGet)
pluginApi.HandleFunc("/jobs/{jobId}/detail", h.adminServer.GetPluginJobDetailAPI).Methods(http.MethodGet)
pluginApi.HandleFunc("/activities", h.adminServer.GetPluginActivitiesAPI).Methods(http.MethodGet)
pluginApi.HandleFunc("/scheduler-states", h.adminServer.GetPluginSchedulerStatesAPI).Methods(http.MethodGet)
pluginApi.HandleFunc("/job-types/{jobType}/descriptor", h.adminServer.GetPluginJobTypeDescriptorAPI).Methods(http.MethodGet)
pluginApi.HandleFunc("/job-types/{jobType}/schema", h.adminServer.RequestPluginJobTypeSchemaAPI).Methods(http.MethodPost)
pluginApi.HandleFunc("/job-types/{jobType}/config", h.adminServer.GetPluginJobTypeConfigAPI).Methods(http.MethodGet)
pluginApi.Handle("/job-types/{jobType}/config", wrapWrite(h.adminServer.UpdatePluginJobTypeConfigAPI)).Methods(http.MethodPut)
pluginApi.HandleFunc("/job-types/{jobType}/runs", h.adminServer.GetPluginRunHistoryAPI).Methods(http.MethodGet)
pluginApi.Handle("/job-types/{jobType}/detect", wrapWrite(h.adminServer.TriggerPluginDetectionAPI)).Methods(http.MethodPost)
pluginApi.Handle("/job-types/{jobType}/run", wrapWrite(h.adminServer.RunPluginJobTypeAPI)).Methods(http.MethodPost)
pluginApi.Handle("/jobs/execute", wrapWrite(h.adminServer.ExecutePluginJobAPI)).Methods(http.MethodPost)
mqApi := api.PathPrefix("/mq").Subrouter()
mqApi.HandleFunc("/topics/{namespace}/{topic}", h.mqHandlers.GetTopicDetailsAPI).Methods(http.MethodGet)
mqApi.Handle("/topics/create", wrapWrite(h.mqHandlers.CreateTopicAPI)).Methods(http.MethodPost)
mqApi.Handle("/topics/retention/update", wrapWrite(h.mqHandlers.UpdateTopicRetentionAPI)).Methods(http.MethodPost)
mqApi.Handle("/retention/purge", wrapWrite(h.adminServer.TriggerTopicRetentionPurgeAPI)).Methods(http.MethodPost)
}
// HealthCheck returns the health status of the admin interface
func (h *AdminHandlers) HealthCheck(c *gin.Context) {
c.JSON(200, gin.H{"health": "ok"})
func (h *AdminHandlers) HealthCheck(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"health": "ok"})
}
// ShowDashboard renders the main admin dashboard
func (h *AdminHandlers) ShowDashboard(c *gin.Context) {
func (h *AdminHandlers) ShowDashboard(w http.ResponseWriter, r *http.Request) {
// Get admin data from the server
adminData := h.getAdminData(c)
adminData := h.getAdminData(r)
username := h.getUsername(r)
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
adminComponent := app.Admin(adminData)
layoutComponent := layout.Layout(c, adminComponent)
err := layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, adminComponent)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// ShowS3Buckets renders the Object Store buckets management page
func (h *AdminHandlers) ShowS3Buckets(c *gin.Context) {
func (h *AdminHandlers) ShowS3Buckets(w http.ResponseWriter, r *http.Request) {
// Get Object Store buckets data from the server
s3Data := h.getS3BucketsData(c)
s3Data := h.getS3BucketsData(r)
username := h.getUsername(r)
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
s3Component := app.S3Buckets(s3Data)
layoutComponent := layout.Layout(c, s3Component)
err := layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, s3Component)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// ShowS3TablesBuckets renders the S3 Tables buckets page
func (h *AdminHandlers) ShowS3TablesBuckets(c *gin.Context) {
username := h.getUsername(c)
func (h *AdminHandlers) ShowS3TablesBuckets(w http.ResponseWriter, r *http.Request) {
username := h.getUsername(r)
data, err := h.adminServer.GetS3TablesBucketsData(c.Request.Context())
data, err := h.adminServer.GetS3TablesBucketsData(r.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get S3 Tables buckets: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get S3 Tables buckets: "+err.Error())
return
}
data.Username = username
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
component := app.S3TablesBuckets(data)
layoutComponent := layout.Layout(c, component)
if err := layoutComponent.Render(c.Request.Context(), c.Writer); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, component)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
}
}
// ShowS3TablesNamespaces renders namespaces for a table bucket
func (h *AdminHandlers) ShowS3TablesNamespaces(c *gin.Context) {
username := h.getUsername(c)
func (h *AdminHandlers) ShowS3TablesNamespaces(w http.ResponseWriter, r *http.Request) {
username := h.getUsername(r)
bucketName := c.Param("bucket")
bucketName := mux.Vars(r)["bucket"]
arn, err := buildS3TablesBucketArn(bucketName)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
data, err := h.adminServer.GetS3TablesNamespacesData(c.Request.Context(), arn)
data, err := h.adminServer.GetS3TablesNamespacesData(r.Context(), arn)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get S3 Tables namespaces: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get S3 Tables namespaces: "+err.Error())
return
}
data.Username = username
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
component := app.S3TablesNamespaces(data)
layoutComponent := layout.Layout(c, component)
if err := layoutComponent.Render(c.Request.Context(), c.Writer); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, component)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
}
}
// ShowS3TablesTables renders tables for a namespace
func (h *AdminHandlers) ShowS3TablesTables(c *gin.Context) {
username := h.getUsername(c)
func (h *AdminHandlers) ShowS3TablesTables(w http.ResponseWriter, r *http.Request) {
username := h.getUsername(r)
bucketName := c.Param("bucket")
namespace := c.Param("namespace")
bucketName := mux.Vars(r)["bucket"]
namespace := mux.Vars(r)["namespace"]
arn, err := buildS3TablesBucketArn(bucketName)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
data, err := h.adminServer.GetS3TablesTablesData(c.Request.Context(), arn, namespace)
data, err := h.adminServer.GetS3TablesTablesData(r.Context(), arn, namespace)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get S3 Tables tables: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get S3 Tables tables: "+err.Error())
return
}
data.Username = username
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
component := app.S3TablesTables(data)
layoutComponent := layout.Layout(c, component)
if err := layoutComponent.Render(c.Request.Context(), c.Writer); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, component)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
}
}
// ShowS3TablesTableDetails renders Iceberg table metadata and snapshot details on the merged S3 Tables path.
func (h *AdminHandlers) ShowS3TablesTableDetails(c *gin.Context) {
bucketName := c.Param("bucket")
namespace := c.Param("namespace")
tableName := c.Param("table")
func (h *AdminHandlers) ShowS3TablesTableDetails(w http.ResponseWriter, r *http.Request) {
bucketName := mux.Vars(r)["bucket"]
namespace := mux.Vars(r)["namespace"]
tableName := mux.Vars(r)["table"]
arn, err := buildS3TablesBucketArn(bucketName)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
data, err := h.adminServer.GetIcebergTableDetailsData(c.Request.Context(), bucketName, arn, namespace, tableName)
username := h.getUsername(r)
data, err := h.adminServer.GetIcebergTableDetailsData(r.Context(), bucketName, arn, namespace, tableName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get table details: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get table details: "+err.Error())
return
}
data.Username = h.getUsername(c)
data.Username = username
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
component := app.IcebergTableDetails(data)
layoutComponent := layout.Layout(c, component)
if err := layoutComponent.Render(c.Request.Context(), c.Writer); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, component)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
}
}
@@ -572,8 +397,8 @@ func buildS3TablesBucketArn(bucketName string) (string, error) {
}
// getUsername returns the username from context, defaulting to "admin" if not set
func (h *AdminHandlers) getUsername(c *gin.Context) string {
username := c.GetString("username")
func (h *AdminHandlers) getUsername(r *http.Request) string {
username := dash.UsernameFromContext(r.Context())
if username == "" {
username = "admin"
}
@@ -581,45 +406,45 @@ func (h *AdminHandlers) getUsername(c *gin.Context) string {
}
// ShowIcebergCatalog redirects legacy Iceberg catalog URL to the merged S3 Tables buckets page.
func (h *AdminHandlers) ShowIcebergCatalog(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/object-store/s3tables/buckets")
func (h *AdminHandlers) ShowIcebergCatalog(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/object-store/s3tables/buckets", http.StatusMovedPermanently)
}
// ShowIcebergNamespaces redirects legacy Iceberg namespaces URL to the merged S3 Tables namespaces page.
func (h *AdminHandlers) ShowIcebergNamespaces(c *gin.Context) {
catalogName := c.Param("catalog")
c.Redirect(http.StatusMovedPermanently, "/object-store/s3tables/buckets/"+url.PathEscape(catalogName)+"/namespaces")
func (h *AdminHandlers) ShowIcebergNamespaces(w http.ResponseWriter, r *http.Request) {
catalogName := mux.Vars(r)["catalog"]
http.Redirect(w, r, "/object-store/s3tables/buckets/"+url.PathEscape(catalogName)+"/namespaces", http.StatusMovedPermanently)
}
// ShowIcebergTables redirects legacy Iceberg tables URL to the merged S3 Tables tables page.
func (h *AdminHandlers) ShowIcebergTables(c *gin.Context) {
catalogName := c.Param("catalog")
namespace := c.Param("namespace")
c.Redirect(http.StatusMovedPermanently, "/object-store/s3tables/buckets/"+url.PathEscape(catalogName)+"/namespaces/"+url.PathEscape(namespace)+"/tables")
func (h *AdminHandlers) ShowIcebergTables(w http.ResponseWriter, r *http.Request) {
catalogName := mux.Vars(r)["catalog"]
namespace := mux.Vars(r)["namespace"]
http.Redirect(w, r, "/object-store/s3tables/buckets/"+url.PathEscape(catalogName)+"/namespaces/"+url.PathEscape(namespace)+"/tables", http.StatusMovedPermanently)
}
// ShowIcebergTableDetails redirects legacy Iceberg table details URL to the merged S3 Tables details page.
func (h *AdminHandlers) ShowIcebergTableDetails(c *gin.Context) {
catalogName := c.Param("catalog")
namespace := c.Param("namespace")
tableName := c.Param("table")
c.Redirect(http.StatusMovedPermanently, "/object-store/s3tables/buckets/"+url.PathEscape(catalogName)+"/namespaces/"+url.PathEscape(namespace)+"/tables/"+url.PathEscape(tableName))
func (h *AdminHandlers) ShowIcebergTableDetails(w http.ResponseWriter, r *http.Request) {
catalogName := mux.Vars(r)["catalog"]
namespace := mux.Vars(r)["namespace"]
tableName := mux.Vars(r)["table"]
http.Redirect(w, r, "/object-store/s3tables/buckets/"+url.PathEscape(catalogName)+"/namespaces/"+url.PathEscape(namespace)+"/tables/"+url.PathEscape(tableName), http.StatusMovedPermanently)
}
// ShowBucketDetails returns detailed information about a specific bucket
func (h *AdminHandlers) ShowBucketDetails(c *gin.Context) {
bucketName := c.Param("bucket")
func (h *AdminHandlers) ShowBucketDetails(w http.ResponseWriter, r *http.Request) {
bucketName := mux.Vars(r)["bucket"]
details, err := h.adminServer.GetBucketDetails(bucketName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get bucket details: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get bucket details: "+err.Error())
return
}
c.JSON(http.StatusOK, details)
writeJSON(w, http.StatusOK, details)
}
// getS3BucketsData retrieves Object Store buckets data from the server
func (h *AdminHandlers) getS3BucketsData(c *gin.Context) dash.S3BucketsData {
username := c.GetString("username")
func (h *AdminHandlers) getS3BucketsData(r *http.Request) dash.S3BucketsData {
username := dash.UsernameFromContext(r.Context())
if username == "" {
username = "admin"
}
@@ -642,8 +467,8 @@ func (h *AdminHandlers) getS3BucketsData(c *gin.Context) dash.S3BucketsData {
}
// getAdminData retrieves admin data from the server (now uses consolidated method)
func (h *AdminHandlers) getAdminData(c *gin.Context) dash.AdminData {
username := c.GetString("username")
func (h *AdminHandlers) getAdminData(r *http.Request) dash.AdminData {
username := dash.UsernameFromContext(r.Context())
// Use the consolidated GetAdminData method from AdminServer
adminData, err := h.adminServer.GetAdminData(username)
@@ -1,73 +1,74 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
func TestSetupRoutes_RegistersPluginSchedulerStatesAPI_NoAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router := mux.NewRouter()
newRouteTestAdminHandlers().SetupRoutes(router, false, "", "", "", "", true)
if !hasRoute(router, "GET", "/api/plugin/scheduler-states") {
if !hasRoute(router, http.MethodGet, "/api/plugin/scheduler-states") {
t.Fatalf("expected GET /api/plugin/scheduler-states to be registered in no-auth mode")
}
if !hasRoute(router, "GET", "/api/plugin/jobs/:jobId/detail") {
if !hasRoute(router, http.MethodGet, "/api/plugin/jobs/example/detail") {
t.Fatalf("expected GET /api/plugin/jobs/:jobId/detail to be registered in no-auth mode")
}
}
func TestSetupRoutes_RegistersPluginSchedulerStatesAPI_WithAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router := mux.NewRouter()
newRouteTestAdminHandlers().SetupRoutes(router, true, "admin", "password", "", "", true)
if !hasRoute(router, "GET", "/api/plugin/scheduler-states") {
if !hasRoute(router, http.MethodGet, "/api/plugin/scheduler-states") {
t.Fatalf("expected GET /api/plugin/scheduler-states to be registered in auth mode")
}
if !hasRoute(router, "GET", "/api/plugin/jobs/:jobId/detail") {
if !hasRoute(router, http.MethodGet, "/api/plugin/jobs/example/detail") {
t.Fatalf("expected GET /api/plugin/jobs/:jobId/detail to be registered in auth mode")
}
}
func TestSetupRoutes_RegistersPluginPages_NoAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router := mux.NewRouter()
newRouteTestAdminHandlers().SetupRoutes(router, false, "", "", "", "", true)
assertHasRoute(t, router, "GET", "/plugin")
assertHasRoute(t, router, "GET", "/plugin/configuration")
assertHasRoute(t, router, "GET", "/plugin/queue")
assertHasRoute(t, router, "GET", "/plugin/detection")
assertHasRoute(t, router, "GET", "/plugin/execution")
assertHasRoute(t, router, "GET", "/plugin/monitoring")
assertHasRoute(t, router, http.MethodGet, "/plugin")
assertHasRoute(t, router, http.MethodGet, "/plugin/configuration")
assertHasRoute(t, router, http.MethodGet, "/plugin/queue")
assertHasRoute(t, router, http.MethodGet, "/plugin/detection")
assertHasRoute(t, router, http.MethodGet, "/plugin/execution")
assertHasRoute(t, router, http.MethodGet, "/plugin/monitoring")
}
func TestSetupRoutes_RegistersPluginPages_WithAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router := mux.NewRouter()
newRouteTestAdminHandlers().SetupRoutes(router, true, "admin", "password", "", "", true)
assertHasRoute(t, router, "GET", "/plugin")
assertHasRoute(t, router, "GET", "/plugin/configuration")
assertHasRoute(t, router, "GET", "/plugin/queue")
assertHasRoute(t, router, "GET", "/plugin/detection")
assertHasRoute(t, router, "GET", "/plugin/execution")
assertHasRoute(t, router, "GET", "/plugin/monitoring")
assertHasRoute(t, router, http.MethodGet, "/plugin")
assertHasRoute(t, router, http.MethodGet, "/plugin/configuration")
assertHasRoute(t, router, http.MethodGet, "/plugin/queue")
assertHasRoute(t, router, http.MethodGet, "/plugin/detection")
assertHasRoute(t, router, http.MethodGet, "/plugin/execution")
assertHasRoute(t, router, http.MethodGet, "/plugin/monitoring")
}
func newRouteTestAdminHandlers() *AdminHandlers {
adminServer := &dash.AdminServer{}
store := sessions.NewCookieStore([]byte("test-session-key"))
return &AdminHandlers{
adminServer: adminServer,
authHandlers: &AuthHandlers{adminServer: adminServer},
sessionStore: store,
authHandlers: &AuthHandlers{adminServer: adminServer, sessionStore: store},
clusterHandlers: &ClusterHandlers{adminServer: adminServer},
fileBrowserHandlers: &FileBrowserHandlers{adminServer: adminServer},
userHandlers: &UserHandlers{adminServer: adminServer},
@@ -78,16 +79,13 @@ func newRouteTestAdminHandlers() *AdminHandlers {
}
}
func hasRoute(router *gin.Engine, method string, path string) bool {
for _, route := range router.Routes() {
if route.Method == method && route.Path == path {
return true
}
}
return false
func hasRoute(router *mux.Router, method string, path string) bool {
req := httptest.NewRequest(method, path, nil)
var match mux.RouteMatch
return router.Match(req, &match)
}
func assertHasRoute(t *testing.T, router *gin.Engine, method string, path string) {
func assertHasRoute(t *testing.T, router *mux.Router, method string, path string) {
t.Helper()
if !hasRoute(router, method, path) {
t.Fatalf("expected %s %s to be registered", method, path)
+35 -22
View File
@@ -3,52 +3,65 @@ package handlers
import (
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
// AuthHandlers contains authentication-related HTTP handlers
type AuthHandlers struct {
adminServer *dash.AdminServer
adminServer *dash.AdminServer
sessionStore sessions.Store
}
// NewAuthHandlers creates a new instance of AuthHandlers
func NewAuthHandlers(adminServer *dash.AdminServer) *AuthHandlers {
func NewAuthHandlers(adminServer *dash.AdminServer, store sessions.Store) *AuthHandlers {
return &AuthHandlers{
adminServer: adminServer,
adminServer: adminServer,
sessionStore: store,
}
}
// ShowLogin displays the login page
func (a *AuthHandlers) ShowLogin(c *gin.Context) {
session := sessions.Default(c)
// If already authenticated, redirect to admin
if session.Get("authenticated") == true {
c.Redirect(http.StatusSeeOther, "/admin")
return
func (a *AuthHandlers) ShowLogin(w http.ResponseWriter, r *http.Request) {
session, err := a.sessionStore.Get(r, dash.SessionName())
var csrfToken string
if err == nil {
if authenticated, _ := session.Values["authenticated"].(bool); authenticated {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
} else {
glog.V(1).Infof("Failed to load session for login page: %v", err)
}
errorMessage := c.Query("error")
if session != nil {
token, tokenErr := dash.EnsureSessionCSRFToken(session, r, w)
if tokenErr != nil {
glog.V(1).Infof("Failed to ensure CSRF token for login page: %v", tokenErr)
} else {
csrfToken = token
}
}
errorMessage := r.URL.Query().Get("error")
// Render login template
c.Header("Content-Type", "text/html")
loginComponent := layout.LoginForm(c, "SeaweedFS Admin", errorMessage)
err := loginComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render login template: " + err.Error()})
w.Header().Set("Content-Type", "text/html")
loginComponent := layout.LoginForm("SeaweedFS Admin", errorMessage, csrfToken)
if err := loginComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render login template: "+err.Error())
return
}
}
// HandleLogin handles login form submission
func (a *AuthHandlers) HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword string) gin.HandlerFunc {
return a.adminServer.HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword)
func (a *AuthHandlers) HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword string) http.HandlerFunc {
return a.adminServer.HandleLogin(a.sessionStore, adminUser, adminPassword, readOnlyUser, readOnlyPassword)
}
// HandleLogout handles user logout
func (a *AuthHandlers) HandleLogout(c *gin.Context) {
a.adminServer.HandleLogout(c)
func (a *AuthHandlers) HandleLogout(w http.ResponseWriter, r *http.Request) {
a.adminServer.HandleLogout(a.sessionStore, w, r)
}
+146 -161
View File
@@ -5,7 +5,7 @@ import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
@@ -24,402 +24,387 @@ func NewClusterHandlers(adminServer *dash.AdminServer) *ClusterHandlers {
}
// ShowClusterVolumeServers renders the cluster volume servers page
func (h *ClusterHandlers) ShowClusterVolumeServers(c *gin.Context) {
func (h *ClusterHandlers) ShowClusterVolumeServers(w http.ResponseWriter, r *http.Request) {
// Get cluster volume servers data
volumeServersData, err := h.adminServer.GetClusterVolumeServers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster volume servers: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster volume servers: "+err.Error())
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
username := usernameOrDefault(r)
volumeServersData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
volumeServersComponent := app.ClusterVolumeServers(*volumeServersData)
layoutComponent := layout.Layout(c, volumeServersComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, volumeServersComponent)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// ShowClusterVolumes renders the cluster volumes page
func (h *ClusterHandlers) ShowClusterVolumes(c *gin.Context) {
func (h *ClusterHandlers) ShowClusterVolumes(w http.ResponseWriter, r *http.Request) {
// Get pagination and sorting parameters from query string
page := 1
if p := c.Query("page"); p != "" {
if p := r.URL.Query().Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
pageSize := 100
if ps := c.Query("pageSize"); ps != "" {
if ps := r.URL.Query().Get("pageSize"); ps != "" {
if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 1000 {
pageSize = parsed
}
}
sortBy := c.DefaultQuery("sortBy", "id")
sortOrder := c.DefaultQuery("sortOrder", "asc")
collection := c.Query("collection") // Optional collection filter
sortBy := defaultQuery(r.URL.Query().Get("sortBy"), "id")
sortOrder := defaultQuery(r.URL.Query().Get("sortOrder"), "asc")
collection := r.URL.Query().Get("collection") // Optional collection filter
// Get cluster volumes data
volumesData, err := h.adminServer.GetClusterVolumes(page, pageSize, sortBy, sortOrder, collection)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster volumes: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster volumes: "+err.Error())
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
username := usernameOrDefault(r)
volumesData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
volumesComponent := app.ClusterVolumes(*volumesData)
layoutComponent := layout.Layout(c, volumesComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, volumesComponent)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// ShowVolumeDetails renders the volume details page
func (h *ClusterHandlers) ShowVolumeDetails(c *gin.Context) {
volumeIDStr := c.Param("id")
server := c.Param("server")
func (h *ClusterHandlers) ShowVolumeDetails(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
volumeIDStr := vars["id"]
server := vars["server"]
if volumeIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Volume ID is required"})
writeJSONError(w, http.StatusBadRequest, "Volume ID is required")
return
}
if server == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Server is required"})
writeJSONError(w, http.StatusBadRequest, "Server is required")
return
}
volumeID, err := strconv.Atoi(volumeIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid volume ID"})
writeJSONError(w, http.StatusBadRequest, "Invalid volume ID")
return
}
// Get volume details
volumeDetails, err := h.adminServer.GetVolumeDetails(volumeID, server)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get volume details: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get volume details: "+err.Error())
return
}
username := usernameOrDefault(r)
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
volumeDetailsComponent := app.VolumeDetails(*volumeDetails)
layoutComponent := layout.Layout(c, volumeDetailsComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, volumeDetailsComponent)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// ShowClusterCollections renders the cluster collections page
func (h *ClusterHandlers) ShowClusterCollections(c *gin.Context) {
func (h *ClusterHandlers) ShowClusterCollections(w http.ResponseWriter, r *http.Request) {
// Get cluster collections data
collectionsData, err := h.adminServer.GetClusterCollections()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster collections: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster collections: "+err.Error())
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
username := usernameOrDefault(r)
collectionsData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
collectionsComponent := app.ClusterCollections(*collectionsData)
layoutComponent := layout.Layout(c, collectionsComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, collectionsComponent)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// ShowCollectionDetails renders the collection detail page
func (h *ClusterHandlers) ShowCollectionDetails(c *gin.Context) {
collectionName := c.Param("name")
func (h *ClusterHandlers) ShowCollectionDetails(w http.ResponseWriter, r *http.Request) {
collectionName := mux.Vars(r)["name"]
if collectionName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Collection name is required"})
writeJSONError(w, http.StatusBadRequest, "Collection name is required")
return
}
// Parse query parameters
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "25"))
sortBy := c.DefaultQuery("sort_by", "volume_id")
sortOrder := c.DefaultQuery("sort_order", "asc")
query := r.URL.Query()
page, _ := strconv.Atoi(defaultQuery(query.Get("page"), "1"))
pageSize, _ := strconv.Atoi(defaultQuery(query.Get("page_size"), "25"))
sortBy := defaultQuery(query.Get("sort_by"), "volume_id")
sortOrder := defaultQuery(query.Get("sort_order"), "asc")
// Get collection details data (volumes and EC volumes)
collectionDetailsData, err := h.adminServer.GetCollectionDetails(collectionName, page, pageSize, sortBy, sortOrder)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get collection details: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get collection details: "+err.Error())
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
username := usernameOrDefault(r)
collectionDetailsData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
collectionDetailsComponent := app.CollectionDetails(*collectionDetailsData)
layoutComponent := layout.Layout(c, collectionDetailsComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, collectionDetailsComponent)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// ShowClusterEcShards handles the cluster EC shards page (individual shards view)
func (h *ClusterHandlers) ShowClusterEcShards(c *gin.Context) {
func (h *ClusterHandlers) ShowClusterEcShards(w http.ResponseWriter, r *http.Request) {
// Parse query parameters
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100"))
sortBy := c.DefaultQuery("sort_by", "volume_id")
sortOrder := c.DefaultQuery("sort_order", "asc")
collection := c.DefaultQuery("collection", "")
query := r.URL.Query()
page, _ := strconv.Atoi(defaultQuery(query.Get("page"), "1"))
pageSize, _ := strconv.Atoi(defaultQuery(query.Get("page_size"), "100"))
sortBy := defaultQuery(query.Get("sort_by"), "volume_id")
sortOrder := defaultQuery(query.Get("sort_order"), "asc")
collection := defaultQuery(query.Get("collection"), "")
// Get data from admin server
data, err := h.adminServer.GetClusterEcVolumes(page, pageSize, sortBy, sortOrder, collection)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
username := usernameOrDefault(r)
data.Username = username
// Render template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
ecVolumesComponent := app.ClusterEcVolumes(*data)
layoutComponent := layout.Layout(c, ecVolumesComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, ecVolumesComponent)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
}
// ShowEcVolumeDetails renders the EC volume details page
func (h *ClusterHandlers) ShowEcVolumeDetails(c *gin.Context) {
volumeIDStr := c.Param("id")
func (h *ClusterHandlers) ShowEcVolumeDetails(w http.ResponseWriter, r *http.Request) {
volumeIDStr := mux.Vars(r)["id"]
if volumeIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Volume ID is required"})
writeJSONError(w, http.StatusBadRequest, "Volume ID is required")
return
}
volumeID, err := strconv.Atoi(volumeIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid volume ID"})
writeJSONError(w, http.StatusBadRequest, "Invalid volume ID")
return
}
// Check that volumeID is within uint32 range
if volumeID < 0 || uint64(volumeID) > math.MaxUint32 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Volume ID out of range"})
writeJSONError(w, http.StatusBadRequest, "Volume ID out of range")
return
}
// Parse sorting parameters
sortBy := c.DefaultQuery("sort_by", "shard_id")
sortOrder := c.DefaultQuery("sort_order", "asc")
query := r.URL.Query()
sortBy := defaultQuery(query.Get("sort_by"), "shard_id")
sortOrder := defaultQuery(query.Get("sort_order"), "asc")
// Get EC volume details
ecVolumeDetails, err := h.adminServer.GetEcVolumeDetails(uint32(volumeID), sortBy, sortOrder)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get EC volume details: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get EC volume details: "+err.Error())
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
username := usernameOrDefault(r)
ecVolumeDetails.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
ecVolumeDetailsComponent := app.EcVolumeDetails(*ecVolumeDetails)
layoutComponent := layout.Layout(c, ecVolumeDetailsComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, ecVolumeDetailsComponent)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// ShowClusterMasters renders the cluster masters page
func (h *ClusterHandlers) ShowClusterMasters(c *gin.Context) {
func (h *ClusterHandlers) ShowClusterMasters(w http.ResponseWriter, r *http.Request) {
// Get cluster masters data
mastersData, err := h.adminServer.GetClusterMasters()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster masters: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster masters: "+err.Error())
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
username := usernameOrDefault(r)
mastersData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
mastersComponent := app.ClusterMasters(*mastersData)
layoutComponent := layout.Layout(c, mastersComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, mastersComponent)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// ShowClusterFilers renders the cluster filers page
func (h *ClusterHandlers) ShowClusterFilers(c *gin.Context) {
func (h *ClusterHandlers) ShowClusterFilers(w http.ResponseWriter, r *http.Request) {
// Get cluster filers data
filersData, err := h.adminServer.GetClusterFilers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster filers: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster filers: "+err.Error())
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
username := usernameOrDefault(r)
filersData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
filersComponent := app.ClusterFilers(*filersData)
layoutComponent := layout.Layout(c, filersComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, filersComponent)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// ShowClusterBrokers renders the cluster message brokers page
func (h *ClusterHandlers) ShowClusterBrokers(c *gin.Context) {
func (h *ClusterHandlers) ShowClusterBrokers(w http.ResponseWriter, r *http.Request) {
// Get cluster brokers data
brokersData, err := h.adminServer.GetClusterBrokers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster brokers: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster brokers: "+err.Error())
return
}
// Set username
username := c.GetString("username")
if username == "" {
username = "admin"
}
username := usernameOrDefault(r)
brokersData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
brokersComponent := app.ClusterBrokers(*brokersData)
layoutComponent := layout.Layout(c, brokersComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, brokersComponent)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// GetClusterTopology returns the cluster topology as JSON
func (h *ClusterHandlers) GetClusterTopology(c *gin.Context) {
func (h *ClusterHandlers) GetClusterTopology(w http.ResponseWriter, r *http.Request) {
topology, err := h.adminServer.GetClusterTopology()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, topology)
writeJSON(w, http.StatusOK, topology)
}
// GetMasters returns master node information
func (h *ClusterHandlers) GetMasters(c *gin.Context) {
// Simple master info
c.JSON(http.StatusOK, gin.H{"masters": []gin.H{{"address": "localhost:9333"}}})
func (h *ClusterHandlers) GetMasters(w http.ResponseWriter, r *http.Request) {
mastersData, err := h.adminServer.GetClusterMasters()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster masters: "+err.Error())
return
}
writeJSON(w, http.StatusOK, mastersData)
}
// GetVolumeServers returns volume server information
func (h *ClusterHandlers) GetVolumeServers(c *gin.Context) {
func (h *ClusterHandlers) GetVolumeServers(w http.ResponseWriter, r *http.Request) {
topology, err := h.adminServer.GetClusterTopology()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"volume_servers": topology.VolumeServers})
writeJSON(w, http.StatusOK, map[string]interface{}{"volume_servers": topology.VolumeServers})
}
// VacuumVolume handles volume vacuum requests via API
func (h *ClusterHandlers) VacuumVolume(c *gin.Context) {
volumeIDStr := c.Param("id")
server := c.Param("server")
func (h *ClusterHandlers) VacuumVolume(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
volumeIDStr := vars["id"]
server := vars["server"]
if volumeIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Volume ID is required"})
writeJSONError(w, http.StatusBadRequest, "Volume ID is required")
return
}
volumeID, err := strconv.Atoi(volumeIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid volume ID"})
writeJSONError(w, http.StatusBadRequest, "Invalid volume ID")
return
}
if server == "" {
writeJSONError(w, http.StatusBadRequest, "Server is required")
return
}
// Perform vacuum operation
err = h.adminServer.VacuumVolume(volumeID, server)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to vacuum volume: " + err.Error(),
})
writeJSONError(w, http.StatusInternalServerError, "Failed to vacuum volume: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "Volume vacuum started successfully",
"volume_id": volumeID,
"server": server,
})
}
func usernameOrDefault(r *http.Request) string {
username := dash.UsernameFromContext(r.Context())
if username == "" {
return "admin"
}
return username
}
+82 -75
View File
@@ -16,7 +16,6 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
@@ -59,16 +58,16 @@ func (h *FileBrowserHandlers) newClientWithTimeout(timeout time.Duration) http.C
}
// ShowFileBrowser renders the file browser page
func (h *FileBrowserHandlers) ShowFileBrowser(c *gin.Context) {
func (h *FileBrowserHandlers) ShowFileBrowser(w http.ResponseWriter, r *http.Request) {
// Get path from query parameter, default to root
path := c.DefaultQuery("path", "/")
path := defaultQuery(r.URL.Query().Get("path"), "/")
// Normalize Windows-style paths for consistency
path = util.CleanWindowsPath(path)
// Get pagination parameters
lastFileName := c.DefaultQuery("lastFileName", "")
lastFileName := r.URL.Query().Get("lastFileName")
pageSize, err := strconv.Atoi(c.DefaultQuery("limit", "20"))
pageSize, err := strconv.Atoi(defaultQuery(r.URL.Query().Get("limit"), "20"))
if err != nil || pageSize < 1 {
pageSize = 20
}
@@ -79,36 +78,42 @@ func (h *FileBrowserHandlers) ShowFileBrowser(c *gin.Context) {
// Get file browser data with cursor-based pagination
browserData, err := h.adminServer.GetFileBrowser(path, lastFileName, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get file browser data: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get file browser data: "+err.Error())
return
}
// Set username
username := c.GetString("username")
username := dash.UsernameFromContext(r.Context())
if username == "" {
username = "admin"
}
browserData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
browserComponent := app.FileBrowser(*browserData)
layoutComponent := layout.Layout(c, browserComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, browserComponent)
err = layoutComponent.Render(r.Context(), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// DeleteFile handles file deletion API requests
func (h *FileBrowserHandlers) DeleteFile(c *gin.Context) {
func (h *FileBrowserHandlers) DeleteFile(w http.ResponseWriter, r *http.Request) {
var request struct {
Path string `json:"path" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &request); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if strings.TrimSpace(request.Path) == "" {
writeJSONError(w, http.StatusBadRequest, "path is required")
return
}
@@ -124,29 +129,36 @@ func (h *FileBrowserHandlers) DeleteFile(c *gin.Context) {
return err
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to delete file: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "File deleted successfully"})
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "File deleted successfully"})
}
// DeleteMultipleFiles handles multiple file deletion API requests
func (h *FileBrowserHandlers) DeleteMultipleFiles(c *gin.Context) {
func (h *FileBrowserHandlers) DeleteMultipleFiles(w http.ResponseWriter, r *http.Request) {
var request struct {
Paths []string `json:"paths" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &request); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if len(request.Paths) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No paths provided"})
writeJSONError(w, http.StatusBadRequest, "No paths provided")
return
}
for _, path := range request.Paths {
if strings.TrimSpace(path) == "" {
writeJSONError(w, http.StatusBadRequest, "path is required")
return
}
}
var deletedCount int
var failedCount int
var errors []string
@@ -189,37 +201,40 @@ func (h *FileBrowserHandlers) DeleteMultipleFiles(c *gin.Context) {
} else {
response["message"] = fmt.Sprintf("Deleted %d item(s), failed to delete %d item(s)", deletedCount, failedCount)
}
c.JSON(http.StatusOK, response)
writeJSON(w, http.StatusOK, response)
} else {
response["message"] = "Failed to delete all selected items"
c.JSON(http.StatusInternalServerError, response)
writeJSON(w, http.StatusInternalServerError, response)
}
}
// CreateFolder handles folder creation requests
func (h *FileBrowserHandlers) CreateFolder(c *gin.Context) {
func (h *FileBrowserHandlers) CreateFolder(w http.ResponseWriter, r *http.Request) {
var request struct {
Path string `json:"path" binding:"required"`
FolderName string `json:"folder_name" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &request); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if strings.TrimSpace(request.Path) == "" {
writeJSONError(w, http.StatusBadRequest, "path is required")
return
}
// Clean and validate folder name
folderName := strings.TrimSpace(request.FolderName)
if folderName == "" || strings.Contains(folderName, "/") || strings.Contains(folderName, "\\") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid folder name"})
writeJSONError(w, http.StatusBadRequest, "Invalid folder name")
return
}
// Create full path for new folder
fullPath := filepath.Join(request.Path, folderName)
if !strings.HasPrefix(fullPath, "/") {
fullPath = "/" + fullPath
}
base := "/" + strings.TrimPrefix(request.Path, "/")
fullPath := path.Join(base, folderName)
// Create folder via filer
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
@@ -241,32 +256,32 @@ func (h *FileBrowserHandlers) CreateFolder(c *gin.Context) {
return err
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create folder: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to create folder: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Folder created successfully"})
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Folder created successfully"})
}
// UploadFile handles file upload requests
func (h *FileBrowserHandlers) UploadFile(c *gin.Context) {
func (h *FileBrowserHandlers) UploadFile(w http.ResponseWriter, r *http.Request) {
// Get the current path
currentPath := c.PostForm("path")
currentPath := r.FormValue("path")
if currentPath == "" {
currentPath = "/"
}
// Parse multipart form
err := c.Request.ParseMultipartForm(1 << 30) // 1GB max memory for large file uploads
err := r.ParseMultipartForm(1 << 30) // 1GB max memory for large file uploads
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse multipart form: " + err.Error()})
writeJSONError(w, http.StatusBadRequest, "Failed to parse multipart form: "+err.Error())
return
}
// Get uploaded files (supports multiple files)
files := c.Request.MultipartForm.File["files"]
files := r.MultipartForm.File["files"]
if len(files) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No files uploaded"})
writeJSONError(w, http.StatusBadRequest, "No files uploaded")
return
}
@@ -292,16 +307,8 @@ func (h *FileBrowserHandlers) UploadFile(c *gin.Context) {
fullPath = "/" + fullPath
}
// Open the file
file, err := fileHeader.Open()
if err != nil {
failedUploads = append(failedUploads, fmt.Sprintf("%s: %v", fileName, err))
continue
}
// Upload file to filer
err = h.uploadFileToFiler(fullPath, fileHeader)
file.Close()
if err != nil {
failedUploads = append(failedUploads, fmt.Sprintf("%s: %v", fileName, err))
@@ -331,10 +338,10 @@ func (h *FileBrowserHandlers) UploadFile(c *gin.Context) {
} else {
response["message"] = fmt.Sprintf("Uploaded %d file(s), %d failed", len(uploadResults), len(failedUploads))
}
c.JSON(http.StatusOK, response)
writeJSON(w, http.StatusOK, response)
} else {
response["message"] = "All file uploads failed"
c.JSON(http.StatusInternalServerError, response)
writeJSON(w, http.StatusInternalServerError, response)
}
}
@@ -561,23 +568,23 @@ func (h *FileBrowserHandlers) fetchFileContent(filePath string, timeout time.Dur
// DownloadFile handles file download requests by proxying through the Admin UI server
// This ensures mTLS works correctly since the Admin UI server has the client certificates
func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) {
filePath := c.Query("path")
func (h *FileBrowserHandlers) DownloadFile(w http.ResponseWriter, r *http.Request) {
filePath := r.URL.Query().Get("path")
if filePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "File path is required"})
writeJSONError(w, http.StatusBadRequest, "File path is required")
return
}
// Get filer address
filerAddress := h.adminServer.GetFilerAddress()
if filerAddress == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Filer address not configured"})
writeJSONError(w, http.StatusInternalServerError, "Filer address not configured")
return
}
// Validate filer address to prevent SSRF
if err := h.validateFilerAddress(filerAddress); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid filer address configuration"})
writeJSONError(w, http.StatusInternalServerError, "Invalid filer address configuration")
return
}
filerHttpAddress := pb.ServerAddress(filerAddress).ToHttpAddress()
@@ -585,7 +592,7 @@ func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) {
// Validate and sanitize the file path
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file path: " + err.Error()})
writeJSONError(w, http.StatusBadRequest, "Invalid file path: "+err.Error())
return
}
@@ -593,7 +600,7 @@ func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) {
downloadURL := fmt.Sprintf("%s%s", filerHttpAddress, cleanFilePath)
downloadURL, err = h.httpClient.NormalizeHttpScheme(downloadURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to construct download URL: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to construct download URL: "+err.Error())
return
}
@@ -602,9 +609,9 @@ func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) {
// Safe: filerAddress validated by validateFilerAddress() to match configured filer
// Safe: cleanFilePath validated and cleaned by validateAndCleanFilePath() to prevent path traversal
// Use request context so download is cancelled when client disconnects
req, err := http.NewRequestWithContext(c.Request.Context(), "GET", downloadURL, nil)
req, err := http.NewRequestWithContext(r.Context(), "GET", downloadURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to create request: "+err.Error())
return
}
client := h.newClientWithTimeout(5 * time.Minute) // Longer timeout for large file downloads
@@ -613,7 +620,7 @@ func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) {
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch file from filer: " + err.Error()})
writeJSONError(w, http.StatusBadGateway, "Failed to fetch file from filer: "+err.Error())
return
}
defer resp.Body.Close()
@@ -621,10 +628,10 @@ func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) {
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(resp.StatusCode, gin.H{"error": fmt.Sprintf("Filer returned status %d but failed to read response body: %v", resp.StatusCode, err)})
writeJSONError(w, resp.StatusCode, fmt.Sprintf("Filer returned status %d but failed to read response body: %v", resp.StatusCode, err))
return
}
c.JSON(resp.StatusCode, gin.H{"error": fmt.Sprintf("Filer returned status %d: %s", resp.StatusCode, string(body))})
writeJSONError(w, resp.StatusCode, fmt.Sprintf("Filer returned status %d: %s", resp.StatusCode, string(body)))
return
}
@@ -632,33 +639,33 @@ func (h *FileBrowserHandlers) DownloadFile(c *gin.Context) {
fileName := filepath.Base(cleanFilePath)
// Use mime.FormatMediaType for RFC 6266 compliant Content-Disposition,
// properly handling non-ASCII characters and special characters
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": fileName}))
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": fileName}))
// Use content type from filer response, or default to octet-stream
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
c.Header("Content-Type", contentType)
w.Header().Set("Content-Type", contentType)
// Set content length if available
if resp.ContentLength > 0 {
c.Header("Content-Length", fmt.Sprintf("%d", resp.ContentLength))
w.Header().Set("Content-Length", fmt.Sprintf("%d", resp.ContentLength))
}
// Stream the response body to the client
c.Status(http.StatusOK)
_, err = io.Copy(c.Writer, resp.Body)
w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, resp.Body)
if err != nil {
glog.Errorf("Error streaming file download: %v", err)
}
}
// ViewFile handles file viewing requests (for text files, images, etc.)
func (h *FileBrowserHandlers) ViewFile(c *gin.Context) {
filePath := c.Query("path")
func (h *FileBrowserHandlers) ViewFile(w http.ResponseWriter, r *http.Request) {
filePath := r.URL.Query().Get("path")
if filePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "File path is required"})
writeJSONError(w, http.StatusBadRequest, "File path is required")
return
}
@@ -704,7 +711,7 @@ func (h *FileBrowserHandlers) ViewFile(c *gin.Context) {
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get file metadata: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get file metadata: "+err.Error())
return
}
@@ -752,7 +759,7 @@ func (h *FileBrowserHandlers) ViewFile(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"file": fileEntry,
"content": content,
"viewable": viewable,
@@ -761,10 +768,10 @@ func (h *FileBrowserHandlers) ViewFile(c *gin.Context) {
}
// GetFileProperties handles file properties requests
func (h *FileBrowserHandlers) GetFileProperties(c *gin.Context) {
filePath := c.Query("path")
func (h *FileBrowserHandlers) GetFileProperties(w http.ResponseWriter, r *http.Request) {
filePath := r.URL.Query().Get("path")
if filePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "File path is required"})
writeJSONError(w, http.StatusBadRequest, "File path is required")
return
}
@@ -853,11 +860,11 @@ func (h *FileBrowserHandlers) GetFileProperties(c *gin.Context) {
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get file properties: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get file properties: "+err.Error())
return
}
c.JSON(http.StatusOK, properties)
writeJSON(w, http.StatusOK, properties)
}
// Helper function to format bytes
+28
View File
@@ -0,0 +1,28 @@
package handlers
import (
"io"
"net/http"
"github.com/seaweedfs/seaweedfs/weed/admin/internal/httputil"
)
func writeJSON(w http.ResponseWriter, status int, payload interface{}) {
httputil.WriteJSON(w, status, payload)
}
func writeJSONError(w http.ResponseWriter, status int, message string) {
httputil.WriteJSONError(w, status, message)
}
func decodeJSONBody(r io.Reader, v interface{}) error {
return httputil.DecodeJSONBody(r, v)
}
func newJSONMaxReader(w http.ResponseWriter, r *http.Request) io.Reader {
return httputil.NewJSONMaxReader(w, r)
}
func defaultQuery(value, fallback string) string {
return httputil.DefaultQuery(value, fallback)
}
+57 -51
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
@@ -23,146 +23,152 @@ func NewMessageQueueHandlers(adminServer *dash.AdminServer) *MessageQueueHandler
}
// ShowBrokers renders the message queue brokers page
func (h *MessageQueueHandlers) ShowBrokers(c *gin.Context) {
func (h *MessageQueueHandlers) ShowBrokers(w http.ResponseWriter, r *http.Request) {
// Get cluster brokers data
brokersData, err := h.adminServer.GetClusterBrokers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster brokers: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get cluster brokers: "+err.Error())
return
}
// Set username
username := c.GetString("username")
username := dash.UsernameFromContext(r.Context())
if username == "" {
username = "admin"
}
brokersData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
brokersComponent := app.ClusterBrokers(*brokersData)
layoutComponent := layout.Layout(c, brokersComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, brokersComponent)
err = layoutComponent.Render(r.Context(), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// ShowTopics renders the message queue topics page
func (h *MessageQueueHandlers) ShowTopics(c *gin.Context) {
func (h *MessageQueueHandlers) ShowTopics(w http.ResponseWriter, r *http.Request) {
// Get topics data
topicsData, err := h.adminServer.GetTopics()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get topics: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get topics: "+err.Error())
return
}
// Set username
username := c.GetString("username")
username := dash.UsernameFromContext(r.Context())
if username == "" {
username = "admin"
}
topicsData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
topicsComponent := app.Topics(*topicsData)
layoutComponent := layout.Layout(c, topicsComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, topicsComponent)
err = layoutComponent.Render(r.Context(), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// ShowSubscribers renders the message queue subscribers page
func (h *MessageQueueHandlers) ShowSubscribers(c *gin.Context) {
func (h *MessageQueueHandlers) ShowSubscribers(w http.ResponseWriter, r *http.Request) {
// Get subscribers data
subscribersData, err := h.adminServer.GetSubscribers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscribers: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get subscribers: "+err.Error())
return
}
// Set username
username := c.GetString("username")
username := dash.UsernameFromContext(r.Context())
if username == "" {
username = "admin"
}
subscribersData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
subscribersComponent := app.Subscribers(*subscribersData)
layoutComponent := layout.Layout(c, subscribersComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, subscribersComponent)
err = layoutComponent.Render(r.Context(), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// ShowTopicDetails renders the topic details page
func (h *MessageQueueHandlers) ShowTopicDetails(c *gin.Context) {
func (h *MessageQueueHandlers) ShowTopicDetails(w http.ResponseWriter, r *http.Request) {
// Get topic parameters from URL
namespace := c.Param("namespace")
topicName := c.Param("topic")
vars := mux.Vars(r)
namespace := vars["namespace"]
topicName := vars["topic"]
if namespace == "" || topicName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing namespace or topic name"})
writeJSONError(w, http.StatusBadRequest, "Missing namespace or topic name")
return
}
// Get topic details data
topicDetailsData, err := h.adminServer.GetTopicDetails(namespace, topicName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get topic details: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get topic details: "+err.Error())
return
}
// Set username
username := c.GetString("username")
username := dash.UsernameFromContext(r.Context())
if username == "" {
username = "admin"
}
topicDetailsData.Username = username
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
topicDetailsComponent := app.TopicDetails(*topicDetailsData)
layoutComponent := layout.Layout(c, topicDetailsComponent)
err = layoutComponent.Render(c.Request.Context(), c.Writer)
viewCtx := layout.NewViewContext(r, username, dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, topicDetailsComponent)
err = layoutComponent.Render(r.Context(), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// GetTopicDetailsAPI returns topic details as JSON for AJAX calls
func (h *MessageQueueHandlers) GetTopicDetailsAPI(c *gin.Context) {
func (h *MessageQueueHandlers) GetTopicDetailsAPI(w http.ResponseWriter, r *http.Request) {
// Get topic parameters from URL
namespace := c.Param("namespace")
topicName := c.Param("topic")
vars := mux.Vars(r)
namespace := vars["namespace"]
topicName := vars["topic"]
if namespace == "" || topicName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing namespace or topic name"})
writeJSONError(w, http.StatusBadRequest, "Missing namespace or topic name")
return
}
// Get topic details data
topicDetailsData, err := h.adminServer.GetTopicDetails(namespace, topicName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get topic details: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get topic details: "+err.Error())
return
}
// Return JSON data
c.JSON(http.StatusOK, topicDetailsData)
writeJSON(w, http.StatusOK, topicDetailsData)
}
// CreateTopicAPI creates a new topic with retention configuration
func (h *MessageQueueHandlers) CreateTopicAPI(c *gin.Context) {
func (h *MessageQueueHandlers) CreateTopicAPI(w http.ResponseWriter, r *http.Request) {
var req struct {
Namespace string `json:"namespace" binding:"required"`
Name string `json:"name" binding:"required"`
@@ -173,30 +179,30 @@ func (h *MessageQueueHandlers) CreateTopicAPI(c *gin.Context) {
} `json:"retention"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
// Validate inputs
if req.PartitionCount < 1 || req.PartitionCount > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Partition count must be between 1 and 100"})
writeJSONError(w, http.StatusBadRequest, "Partition count must be between 1 and 100")
return
}
if req.Retention.Enabled && req.Retention.RetentionSeconds <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Retention seconds must be positive when retention is enabled"})
writeJSONError(w, http.StatusBadRequest, "Retention seconds must be positive when retention is enabled")
return
}
// Create the topic via admin server
err := h.adminServer.CreateTopicWithRetention(req.Namespace, req.Name, req.PartitionCount, req.Retention.Enabled, req.Retention.RetentionSeconds)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create topic: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to create topic: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "Topic created successfully",
"topic": fmt.Sprintf("%s.%s", req.Namespace, req.Name),
})
@@ -211,27 +217,27 @@ type UpdateTopicRetentionRequest struct {
} `json:"retention"`
}
func (h *MessageQueueHandlers) UpdateTopicRetentionAPI(c *gin.Context) {
func (h *MessageQueueHandlers) UpdateTopicRetentionAPI(w http.ResponseWriter, r *http.Request) {
var request UpdateTopicRetentionRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &request); err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
// Validate required fields
if request.Namespace == "" || request.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "namespace and name are required"})
writeJSONError(w, http.StatusBadRequest, "namespace and name are required")
return
}
// Update the topic retention
err := h.adminServer.UpdateTopicRetention(request.Namespace, request.Name, request.Retention.Enabled, request.Retention.RetentionSeconds)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "Topic retention updated successfully",
"topic": request.Namespace + "." + request.Name,
})
+20 -18
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"net/http"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
@@ -23,45 +22,48 @@ func NewPluginHandlers(adminServer *dash.AdminServer) *PluginHandlers {
}
// ShowPlugin displays plugin overview page.
func (h *PluginHandlers) ShowPlugin(c *gin.Context) {
h.renderPluginPage(c, "overview")
func (h *PluginHandlers) ShowPlugin(w http.ResponseWriter, r *http.Request) {
h.renderPluginPage(w, r, "overview")
}
// ShowPluginConfiguration displays plugin configuration page.
func (h *PluginHandlers) ShowPluginConfiguration(c *gin.Context) {
h.renderPluginPage(c, "configuration")
func (h *PluginHandlers) ShowPluginConfiguration(w http.ResponseWriter, r *http.Request) {
h.renderPluginPage(w, r, "configuration")
}
// ShowPluginDetection displays plugin detection jobs page.
func (h *PluginHandlers) ShowPluginDetection(c *gin.Context) {
h.renderPluginPage(c, "detection")
func (h *PluginHandlers) ShowPluginDetection(w http.ResponseWriter, r *http.Request) {
h.renderPluginPage(w, r, "detection")
}
// ShowPluginQueue displays plugin job queue page.
func (h *PluginHandlers) ShowPluginQueue(c *gin.Context) {
h.renderPluginPage(c, "queue")
func (h *PluginHandlers) ShowPluginQueue(w http.ResponseWriter, r *http.Request) {
h.renderPluginPage(w, r, "queue")
}
// ShowPluginExecution displays plugin execution jobs page.
func (h *PluginHandlers) ShowPluginExecution(c *gin.Context) {
h.renderPluginPage(c, "execution")
func (h *PluginHandlers) ShowPluginExecution(w http.ResponseWriter, r *http.Request) {
h.renderPluginPage(w, r, "execution")
}
// ShowPluginMonitoring displays plugin monitoring page.
func (h *PluginHandlers) ShowPluginMonitoring(c *gin.Context) {
func (h *PluginHandlers) ShowPluginMonitoring(w http.ResponseWriter, r *http.Request) {
// Backward-compatible alias for the old monitoring URL.
h.renderPluginPage(c, "detection")
h.renderPluginPage(w, r, "detection")
}
func (h *PluginHandlers) renderPluginPage(c *gin.Context, page string) {
func (h *PluginHandlers) renderPluginPage(w http.ResponseWriter, r *http.Request, page string) {
component := app.Plugin(page)
layoutComponent := layout.Layout(c, component)
viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, component)
var buf bytes.Buffer
if err := layoutComponent.Render(c.Request.Context(), &buf); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
if err := layoutComponent.Render(r.Context(), &buf); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(buf.Bytes())
}
+53 -59
View File
@@ -5,7 +5,7 @@ import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
@@ -26,53 +26,53 @@ func NewPolicyHandlers(adminServer *dash.AdminServer) *PolicyHandlers {
}
// ShowPolicies renders the policies management page
func (h *PolicyHandlers) ShowPolicies(c *gin.Context) {
func (h *PolicyHandlers) ShowPolicies(w http.ResponseWriter, r *http.Request) {
// Get policies data from the server
policiesData := h.getPoliciesData(c)
policiesData := h.getPoliciesData(r)
// Render HTML template
c.Header("Content-Type", "text/html")
w.Header().Set("Content-Type", "text/html")
policiesComponent := app.Policies(policiesData)
layoutComponent := layout.Layout(c, policiesComponent)
err := layoutComponent.Render(c.Request.Context(), c.Writer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, policiesComponent)
if err := layoutComponent.Render(r.Context(), w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// GetPolicies returns the list of policies as JSON
func (h *PolicyHandlers) GetPolicies(c *gin.Context) {
func (h *PolicyHandlers) GetPolicies(w http.ResponseWriter, r *http.Request) {
policies, err := h.adminServer.GetPolicies()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policies: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get policies: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"policies": policies})
writeJSON(w, http.StatusOK, map[string]interface{}{"policies": policies})
}
// CreatePolicy handles policy creation
func (h *PolicyHandlers) CreatePolicy(c *gin.Context) {
func (h *PolicyHandlers) CreatePolicy(w http.ResponseWriter, r *http.Request) {
var req dash.CreatePolicyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
// Validate policy name
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"})
writeJSONError(w, http.StatusBadRequest, "Policy name is required")
return
}
// Check if policy already exists
existingPolicy, err := h.adminServer.GetPolicy(req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to check existing policy: "+err.Error())
return
}
if existingPolicy != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Policy with this name already exists"})
writeJSONError(w, http.StatusConflict, "Policy with this name already exists")
return
}
@@ -80,11 +80,11 @@ func (h *PolicyHandlers) CreatePolicy(c *gin.Context) {
err = h.adminServer.CreatePolicy(req.Name, req.Document)
if err != nil {
glog.Errorf("Failed to create policy %s: %v", req.Name, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create policy: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to create policy: "+err.Error())
return
}
c.JSON(http.StatusCreated, gin.H{
writeJSON(w, http.StatusCreated, map[string]interface{}{
"success": true,
"message": "Policy created successfully",
"policy": req.Name,
@@ -92,49 +92,49 @@ func (h *PolicyHandlers) CreatePolicy(c *gin.Context) {
}
// GetPolicy returns a specific policy
func (h *PolicyHandlers) GetPolicy(c *gin.Context) {
policyName := c.Param("name")
func (h *PolicyHandlers) GetPolicy(w http.ResponseWriter, r *http.Request) {
policyName := mux.Vars(r)["name"]
if policyName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"})
writeJSONError(w, http.StatusBadRequest, "Policy name is required")
return
}
policy, err := h.adminServer.GetPolicy(policyName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policy: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get policy: "+err.Error())
return
}
if policy == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"})
writeJSONError(w, http.StatusNotFound, "Policy not found")
return
}
c.JSON(http.StatusOK, policy)
writeJSON(w, http.StatusOK, policy)
}
// UpdatePolicy handles policy updates
func (h *PolicyHandlers) UpdatePolicy(c *gin.Context) {
policyName := c.Param("name")
func (h *PolicyHandlers) UpdatePolicy(w http.ResponseWriter, r *http.Request) {
policyName := mux.Vars(r)["name"]
if policyName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"})
writeJSONError(w, http.StatusBadRequest, "Policy name is required")
return
}
var req dash.UpdatePolicyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
// Check if policy exists
existingPolicy, err := h.adminServer.GetPolicy(policyName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to check existing policy: "+err.Error())
return
}
if existingPolicy == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"})
writeJSONError(w, http.StatusNotFound, "Policy not found")
return
}
@@ -142,11 +142,11 @@ func (h *PolicyHandlers) UpdatePolicy(c *gin.Context) {
err = h.adminServer.UpdatePolicy(policyName, req.Document)
if err != nil {
glog.Errorf("Failed to update policy %s: %v", policyName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update policy: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to update policy: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Policy updated successfully",
"policy": policyName,
@@ -154,21 +154,21 @@ func (h *PolicyHandlers) UpdatePolicy(c *gin.Context) {
}
// DeletePolicy handles policy deletion
func (h *PolicyHandlers) DeletePolicy(c *gin.Context) {
policyName := c.Param("name")
func (h *PolicyHandlers) DeletePolicy(w http.ResponseWriter, r *http.Request) {
policyName := mux.Vars(r)["name"]
if policyName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Policy name is required"})
writeJSONError(w, http.StatusBadRequest, "Policy name is required")
return
}
// Check if policy exists
existingPolicy, err := h.adminServer.GetPolicy(policyName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing policy: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to check existing policy: "+err.Error())
return
}
if existingPolicy == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"})
writeJSONError(w, http.StatusNotFound, "Policy not found")
return
}
@@ -176,11 +176,11 @@ func (h *PolicyHandlers) DeletePolicy(c *gin.Context) {
err = h.adminServer.DeletePolicy(policyName)
if err != nil {
glog.Errorf("Failed to delete policy %s: %v", policyName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete policy: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to delete policy: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Policy deleted successfully",
"policy": policyName,
@@ -188,60 +188,54 @@ func (h *PolicyHandlers) DeletePolicy(c *gin.Context) {
}
// ValidatePolicy validates a policy document without saving it
func (h *PolicyHandlers) ValidatePolicy(c *gin.Context) {
func (h *PolicyHandlers) ValidatePolicy(w http.ResponseWriter, r *http.Request) {
var req struct {
Document policy_engine.PolicyDocument `json:"document" binding:"required"`
Document policy_engine.PolicyDocument `json:"document"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
// Basic validation
if req.Document.Version == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Policy version is required"})
writeJSONError(w, http.StatusBadRequest, "Policy version is required")
return
}
if len(req.Document.Statement) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Policy must have at least one statement"})
writeJSONError(w, http.StatusBadRequest, "Policy must have at least one statement")
return
}
// Validate each statement
for i, statement := range req.Document.Statement {
if statement.Effect != "Allow" && statement.Effect != "Deny" {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Statement %d: Effect must be 'Allow' or 'Deny'", i+1),
})
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Statement %d: Effect must be 'Allow' or 'Deny'", i+1))
return
}
if len(statement.Action.Strings()) == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Statement %d: Action is required", i+1),
})
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Statement %d: Action is required", i+1))
return
}
if len(statement.Resource.Strings()) == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Statement %d: Resource is required", i+1),
})
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Statement %d: Resource is required", i+1))
return
}
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"valid": true,
"message": "Policy document is valid",
})
}
// getPoliciesData retrieves policies data from the server
func (h *PolicyHandlers) getPoliciesData(c *gin.Context) dash.PoliciesData {
username := c.GetString("username")
func (h *PolicyHandlers) getPoliciesData(r *http.Request) dash.PoliciesData {
username := dash.UsernameFromContext(r.Context())
if username == "" {
username = "admin"
}
+48 -47
View File
@@ -6,7 +6,7 @@ import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
@@ -26,153 +26,154 @@ func NewServiceAccountHandlers(adminServer *dash.AdminServer) *ServiceAccountHan
}
// ShowServiceAccounts renders the service accounts management page
func (h *ServiceAccountHandlers) ShowServiceAccounts(c *gin.Context) {
data := h.getServiceAccountsData(c)
func (h *ServiceAccountHandlers) ShowServiceAccounts(w http.ResponseWriter, r *http.Request) {
data := h.getServiceAccountsData(r)
// Render to buffer first to avoid partial writes on error
var buf bytes.Buffer
component := app.ServiceAccounts(data)
layoutComponent := layout.Layout(c, component)
err := layoutComponent.Render(c.Request.Context(), &buf)
viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, component)
err := layoutComponent.Render(r.Context(), &buf)
if err != nil {
glog.Errorf("Failed to render service accounts template: %v", err)
c.AbortWithStatus(http.StatusInternalServerError)
w.WriteHeader(http.StatusInternalServerError)
return
}
// Only write to response if render succeeded
c.Header("Content-Type", "text/html")
c.Writer.Write(buf.Bytes())
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write(buf.Bytes())
}
// GetServiceAccounts returns the list of service accounts as JSON
func (h *ServiceAccountHandlers) GetServiceAccounts(c *gin.Context) {
parentUser := c.Query("parent_user")
func (h *ServiceAccountHandlers) GetServiceAccounts(w http.ResponseWriter, r *http.Request) {
parentUser := r.URL.Query().Get("parent_user")
accounts, err := h.adminServer.GetServiceAccounts(c.Request.Context(), parentUser)
accounts, err := h.adminServer.GetServiceAccounts(r.Context(), parentUser)
if err != nil {
glog.Errorf("Failed to get service accounts: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get service accounts"})
writeJSONError(w, http.StatusInternalServerError, "Failed to get service accounts")
return
}
c.JSON(http.StatusOK, gin.H{"service_accounts": accounts})
writeJSON(w, http.StatusOK, map[string]interface{}{"service_accounts": accounts})
}
// CreateServiceAccount handles service account creation
func (h *ServiceAccountHandlers) CreateServiceAccount(c *gin.Context) {
func (h *ServiceAccountHandlers) CreateServiceAccount(w http.ResponseWriter, r *http.Request) {
var req dash.CreateServiceAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if req.ParentUser == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ParentUser is required"})
writeJSONError(w, http.StatusBadRequest, "ParentUser is required")
return
}
sa, err := h.adminServer.CreateServiceAccount(c.Request.Context(), req)
sa, err := h.adminServer.CreateServiceAccount(r.Context(), req)
if err != nil {
glog.Errorf("Failed to create service account for user %s: %v", req.ParentUser, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service account"})
writeJSONError(w, http.StatusInternalServerError, "Failed to create service account")
return
}
c.JSON(http.StatusCreated, gin.H{
writeJSON(w, http.StatusCreated, map[string]interface{}{
"message": "Service account created successfully",
"service_account": sa,
})
}
// GetServiceAccountDetails returns detailed information about a service account
func (h *ServiceAccountHandlers) GetServiceAccountDetails(c *gin.Context) {
id := c.Param("id")
func (h *ServiceAccountHandlers) GetServiceAccountDetails(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Service account ID is required"})
writeJSONError(w, http.StatusBadRequest, "Service account ID is required")
return
}
sa, err := h.adminServer.GetServiceAccountDetails(c.Request.Context(), id)
sa, err := h.adminServer.GetServiceAccountDetails(r.Context(), id)
if err != nil {
// Distinguish not-found errors from internal errors
if errors.Is(err, dash.ErrServiceAccountNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Service account not found: " + err.Error()})
writeJSONError(w, http.StatusNotFound, "Service account not found: "+err.Error())
} else {
glog.Errorf("Failed to get service account details for %s: %v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get service account details"})
writeJSONError(w, http.StatusInternalServerError, "Failed to get service account details")
}
return
}
c.JSON(http.StatusOK, sa)
writeJSON(w, http.StatusOK, sa)
}
// UpdateServiceAccount handles service account updates
func (h *ServiceAccountHandlers) UpdateServiceAccount(c *gin.Context) {
id := c.Param("id")
func (h *ServiceAccountHandlers) UpdateServiceAccount(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Service account ID is required"})
writeJSONError(w, http.StatusBadRequest, "Service account ID is required")
return
}
var req dash.UpdateServiceAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
sa, err := h.adminServer.UpdateServiceAccount(c.Request.Context(), id, req)
sa, err := h.adminServer.UpdateServiceAccount(r.Context(), id, req)
if err != nil {
// Distinguish not-found errors from internal errors
if errors.Is(err, dash.ErrServiceAccountNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Service account not found"})
writeJSONError(w, http.StatusNotFound, "Service account not found")
} else {
glog.Errorf("Failed to update service account %s: %v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service account"})
writeJSONError(w, http.StatusInternalServerError, "Failed to update service account")
}
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "Service account updated successfully",
"service_account": sa,
})
}
// DeleteServiceAccount handles service account deletion
func (h *ServiceAccountHandlers) DeleteServiceAccount(c *gin.Context) {
id := c.Param("id")
func (h *ServiceAccountHandlers) DeleteServiceAccount(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Service account ID is required"})
writeJSONError(w, http.StatusBadRequest, "Service account ID is required")
return
}
err := h.adminServer.DeleteServiceAccount(c.Request.Context(), id)
err := h.adminServer.DeleteServiceAccount(r.Context(), id)
if err != nil {
// Distinguish not-found errors from internal errors
if errors.Is(err, dash.ErrServiceAccountNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Service account not found"})
writeJSONError(w, http.StatusNotFound, "Service account not found")
} else {
glog.Errorf("Failed to delete service account %s: %v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete service account"})
writeJSONError(w, http.StatusInternalServerError, "Failed to delete service account")
}
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "Service account deleted successfully",
})
}
// getServiceAccountsData retrieves service accounts data for the template
func (h *ServiceAccountHandlers) getServiceAccountsData(c *gin.Context) dash.ServiceAccountsData {
username := c.GetString("username")
func (h *ServiceAccountHandlers) getServiceAccountsData(r *http.Request) dash.ServiceAccountsData {
username := dash.UsernameFromContext(r.Context())
if username == "" {
username = "admin"
}
// Get all service accounts
accounts, err := h.adminServer.GetServiceAccounts(c.Request.Context(), "")
accounts, err := h.adminServer.GetServiceAccounts(r.Context(), "")
if err != nil {
glog.Errorf("Failed to get service accounts: %v", err)
return dash.ServiceAccountsData{
@@ -193,7 +194,7 @@ func (h *ServiceAccountHandlers) getServiceAccountsData(c *gin.Context) dash.Ser
// Get available users for dropdown
var availableUsers []string
users, err := h.adminServer.GetObjectStoreUsers(c.Request.Context())
users, err := h.adminServer.GetObjectStoreUsers(r.Context())
if err != nil {
glog.Errorf("Failed to get users for dropdown: %v", err)
} else {
+76 -73
View File
@@ -5,7 +5,7 @@ import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
@@ -25,256 +25,259 @@ func NewUserHandlers(adminServer *dash.AdminServer) *UserHandlers {
}
// ShowObjectStoreUsers renders the object store users management page
func (h *UserHandlers) ShowObjectStoreUsers(c *gin.Context) {
func (h *UserHandlers) ShowObjectStoreUsers(w http.ResponseWriter, r *http.Request) {
// Get object store users data from the server
usersData := h.getObjectStoreUsersData(c)
usersData := h.getObjectStoreUsersData(r)
// Render HTML template
// Add cache-control headers to prevent browser caching of inline JavaScript
c.Header("Content-Type", "text/html")
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Header("ETag", fmt.Sprintf("\"%d\"", time.Now().Unix()))
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Header().Set("ETag", fmt.Sprintf("\"%d\"", time.Now().Unix()))
usersComponent := app.ObjectStoreUsers(usersData)
layoutComponent := layout.Layout(c, usersComponent)
err := layoutComponent.Render(c.Request.Context(), c.Writer)
viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, usersComponent)
err := layoutComponent.Render(r.Context(), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to render template: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to render template: "+err.Error())
return
}
}
// GetUsers returns the list of users as JSON
func (h *UserHandlers) GetUsers(c *gin.Context) {
users, err := h.adminServer.GetObjectStoreUsers(c.Request.Context())
func (h *UserHandlers) GetUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.adminServer.GetObjectStoreUsers(r.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get users: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get users: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"users": users})
writeJSON(w, http.StatusOK, map[string]interface{}{"users": users})
}
// CreateUser handles user creation
func (h *UserHandlers) CreateUser(c *gin.Context) {
func (h *UserHandlers) CreateUser(w http.ResponseWriter, r *http.Request) {
var req dash.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
// Validate required fields
if req.Username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
writeJSONError(w, http.StatusBadRequest, "Username is required")
return
}
user, err := h.adminServer.CreateObjectStoreUser(req)
if err != nil {
glog.Errorf("Failed to create user %s: %v", req.Username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to create user: "+err.Error())
return
}
c.JSON(http.StatusCreated, gin.H{
writeJSON(w, http.StatusCreated, map[string]interface{}{
"message": "User created successfully",
"user": user,
})
}
// UpdateUser handles user updates
func (h *UserHandlers) UpdateUser(c *gin.Context) {
username := c.Param("username")
func (h *UserHandlers) UpdateUser(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
writeJSONError(w, http.StatusBadRequest, "Username is required")
return
}
var req dash.UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
user, err := h.adminServer.UpdateObjectStoreUser(username, req)
if err != nil {
glog.Errorf("Failed to update user %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to update user: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "User updated successfully",
"user": user,
})
}
// DeleteUser handles user deletion
func (h *UserHandlers) DeleteUser(c *gin.Context) {
username := c.Param("username")
func (h *UserHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
writeJSONError(w, http.StatusBadRequest, "Username is required")
return
}
err := h.adminServer.DeleteObjectStoreUser(username)
if err != nil {
glog.Errorf("Failed to delete user %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to delete user: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "User deleted successfully",
})
}
// GetUserDetails returns detailed information about a specific user
func (h *UserHandlers) GetUserDetails(c *gin.Context) {
username := c.Param("username")
func (h *UserHandlers) GetUserDetails(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
writeJSONError(w, http.StatusBadRequest, "Username is required")
return
}
user, err := h.adminServer.GetObjectStoreUserDetails(username)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found: " + err.Error()})
writeJSONError(w, http.StatusNotFound, "User not found: "+err.Error())
return
}
c.JSON(http.StatusOK, user)
writeJSON(w, http.StatusOK, user)
}
// CreateAccessKey creates a new access key for a user
func (h *UserHandlers) CreateAccessKey(c *gin.Context) {
username := c.Param("username")
func (h *UserHandlers) CreateAccessKey(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
writeJSONError(w, http.StatusBadRequest, "Username is required")
return
}
accessKey, err := h.adminServer.CreateAccessKey(username)
if err != nil {
glog.Errorf("Failed to create access key for user %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create access key: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to create access key: "+err.Error())
return
}
c.JSON(http.StatusCreated, gin.H{
writeJSON(w, http.StatusCreated, map[string]interface{}{
"message": "Access key created successfully",
"access_key": accessKey,
})
}
// DeleteAccessKey deletes an access key for a user
func (h *UserHandlers) DeleteAccessKey(c *gin.Context) {
username := c.Param("username")
accessKeyId := c.Param("accessKeyId")
func (h *UserHandlers) DeleteAccessKey(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
username := vars["username"]
accessKeyId := vars["accessKeyId"]
if username == "" || accessKeyId == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username and access key ID are required"})
writeJSONError(w, http.StatusBadRequest, "Username and access key ID are required")
return
}
err := h.adminServer.DeleteAccessKey(username, accessKeyId)
if err != nil {
glog.Errorf("Failed to delete access key %s for user %s: %v", accessKeyId, username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete access key: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to delete access key: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "Access key deleted successfully",
})
}
// UpdateAccessKeyStatus updates the status of an access key for a user
func (h *UserHandlers) UpdateAccessKeyStatus(c *gin.Context) {
username := c.Param("username")
accessKeyId := c.Param("accessKeyId")
func (h *UserHandlers) UpdateAccessKeyStatus(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
username := vars["username"]
accessKeyId := vars["accessKeyId"]
if username == "" || accessKeyId == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username and access key ID are required"})
writeJSONError(w, http.StatusBadRequest, "Username and access key ID are required")
return
}
var req dash.UpdateAccessKeyStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
// Validate status
if req.Status != dash.AccessKeyStatusActive && req.Status != dash.AccessKeyStatusInactive {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Status must be '%s' or '%s'", dash.AccessKeyStatusActive, dash.AccessKeyStatusInactive)})
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Status must be '%s' or '%s'", dash.AccessKeyStatusActive, dash.AccessKeyStatusInactive))
return
}
err := h.adminServer.UpdateAccessKeyStatus(username, accessKeyId, req.Status)
if err != nil {
glog.Errorf("Failed to update access key status %s for user %s: %v", accessKeyId, username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update access key status: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to update access key status: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "Access key updated successfully",
})
}
// GetUserPolicies returns the policies for a user
func (h *UserHandlers) GetUserPolicies(c *gin.Context) {
username := c.Param("username")
func (h *UserHandlers) GetUserPolicies(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
writeJSONError(w, http.StatusBadRequest, "Username is required")
return
}
policies, err := h.adminServer.GetUserPolicies(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user policies: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to get user policies: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"policies": policies})
writeJSON(w, http.StatusOK, map[string]interface{}{"policies": policies})
}
// UpdateUserPolicies updates the policies for a user
func (h *UserHandlers) UpdateUserPolicies(c *gin.Context) {
username := c.Param("username")
func (h *UserHandlers) UpdateUserPolicies(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username is required"})
writeJSONError(w, http.StatusBadRequest, "Username is required")
return
}
var req dash.UpdateUserPoliciesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
err := h.adminServer.UpdateUserPolicies(username, req.Actions)
if err != nil {
glog.Errorf("Failed to update policies for user %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user policies: " + err.Error()})
writeJSONError(w, http.StatusInternalServerError, "Failed to update user policies: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "User policies updated successfully",
})
}
// getObjectStoreUsersData retrieves object store users data from the server
func (h *UserHandlers) getObjectStoreUsersData(c *gin.Context) dash.ObjectStoreUsersData {
username := c.GetString("username")
func (h *UserHandlers) getObjectStoreUsersData(r *http.Request) dash.ObjectStoreUsersData {
username := dash.UsernameFromContext(r.Context())
if username == "" {
username = "admin"
}
// Get object store users
users, err := h.adminServer.GetObjectStoreUsers(c.Request.Context())
users, err := h.adminServer.GetObjectStoreUsers(r.Context())
if err != nil {
glog.Errorf("Failed to get object store users: %v", err)
// Return empty data on error
+42
View File
@@ -0,0 +1,42 @@
package httputil
import (
"encoding/json"
"io"
"net/http"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
const MaxJSONBodyBytes = 1 << 20
func NewJSONMaxReader(w http.ResponseWriter, r *http.Request) io.Reader {
return http.MaxBytesReader(w, r.Body, MaxJSONBodyBytes)
}
func DecodeJSONBody(r io.Reader, v interface{}) error {
decoder := json.NewDecoder(r)
return decoder.Decode(v)
}
func WriteJSON(w http.ResponseWriter, status int, payload interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if payload == nil {
return
}
if err := json.NewEncoder(w).Encode(payload); err != nil {
glog.Errorf("failed to encode JSON response (status=%d, payload=%T): %v", status, payload, err)
}
}
func WriteJSONError(w http.ResponseWriter, status int, message string) {
WriteJSON(w, status, map[string]string{"error": message})
}
func DefaultQuery(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
+151 -59
View File
@@ -130,10 +130,10 @@ func (mq *MaintenanceQueue) cleanupCompletedTasks() {
// AddTask adds a new maintenance task to the queue with deduplication
func (mq *MaintenanceQueue) AddTask(task *MaintenanceTask) {
mq.mutex.Lock()
defer mq.mutex.Unlock()
// Check for duplicate tasks (same type + volume + not completed)
if mq.hasDuplicateTask(task) {
mq.mutex.Unlock()
glog.V(1).Infof("Task skipped (duplicate): %s for volume %d on %s (already queued or running)",
task.Type, task.VolumeID, task.Server)
return
@@ -169,16 +169,23 @@ func (mq *MaintenanceQueue) AddTask(task *MaintenanceTask) {
return mq.pendingTasks[i].ScheduledAt.Before(mq.pendingTasks[j].ScheduledAt)
})
// Save task state to persistence
mq.saveTaskState(task)
scheduleInfo := ""
if !task.ScheduledAt.IsZero() && time.Until(task.ScheduledAt) > time.Minute {
scheduleInfo = fmt.Sprintf(", scheduled for %v", task.ScheduledAt.Format("15:04:05"))
}
// Snapshot task state while lock is still held to avoid data race;
// also capture log fields from the snapshot so the live task pointer
// is not accessed after mq.mutex is released.
taskSnapshot := snapshotTask(task)
mq.mutex.Unlock()
// Save task state to persistence outside the lock to avoid blocking
// RegisterWorker and HTTP handlers (GetTasks) during disk I/O
mq.saveTaskState(taskSnapshot)
glog.Infof("Task queued: %s (%s) volume %d on %s, priority %d%s, reason: %s",
task.ID, task.Type, task.VolumeID, task.Server, task.Priority, scheduleInfo, task.Reason)
taskSnapshot.ID, taskSnapshot.Type, taskSnapshot.VolumeID, taskSnapshot.Server, taskSnapshot.Priority, scheduleInfo, taskSnapshot.Reason)
}
// hasDuplicateTask checks if a similar task already exists (same type, volume, and not completed)
@@ -286,11 +293,14 @@ func (mq *MaintenanceQueue) GetNextTask(workerID string, capabilities []Maintena
// Now acquire write lock to actually assign the task
mq.mutex.Lock()
defer mq.mutex.Unlock()
// Capture ID before the re-check so it is available for logging after unlock.
selectedTaskID := selectedTask.ID
// Re-check that the task is still available (it might have been assigned to another worker)
if selectedIndex >= len(mq.pendingTasks) || mq.pendingTasks[selectedIndex].ID != selectedTask.ID {
glog.V(2).Infof("Task %s no longer available for worker %s: assigned to another worker", selectedTask.ID, workerID)
if selectedIndex >= len(mq.pendingTasks) || mq.pendingTasks[selectedIndex].ID != selectedTaskID {
mq.mutex.Unlock()
glog.V(2).Infof("Task %s no longer available for worker %s: assigned to another worker", selectedTaskID, workerID)
return nil
}
@@ -331,6 +341,7 @@ func (mq *MaintenanceQueue) GetNextTask(workerID string, capabilities []Maintena
if len(selectedTask.AssignmentHistory) > 0 {
selectedTask.AssignmentHistory = selectedTask.AssignmentHistory[:len(selectedTask.AssignmentHistory)-1]
}
mq.mutex.Unlock()
// Return nil so the task is not removed from pendingTasks and not returned to the worker
return nil
}
@@ -348,11 +359,15 @@ func (mq *MaintenanceQueue) GetNextTask(workerID string, capabilities []Maintena
// Track pending operation
mq.trackPendingOperation(selectedTask)
// Save task state after assignment
mq.saveTaskState(selectedTask)
// Snapshot task state while lock is still held to avoid data race
selectedSnapshot := snapshotTask(selectedTask)
mq.mutex.Unlock()
// Save task state to persistence outside the lock
mq.saveTaskState(selectedSnapshot)
glog.Infof("Task assigned: %s (%s) → worker %s (volume %d, server %s)",
selectedTask.ID, selectedTask.Type, workerID, selectedTask.VolumeID, selectedTask.Server)
selectedSnapshot.ID, selectedSnapshot.Type, workerID, selectedSnapshot.VolumeID, selectedSnapshot.Server)
return selectedTask
}
@@ -360,10 +375,10 @@ func (mq *MaintenanceQueue) GetNextTask(workerID string, capabilities []Maintena
// CompleteTask marks a task as completed
func (mq *MaintenanceQueue) CompleteTask(taskID string, error string) {
mq.mutex.Lock()
defer mq.mutex.Unlock()
task, exists := mq.tasks[taskID]
if !exists {
mq.mutex.Unlock()
glog.Warningf("Attempted to complete non-existent task: %s", taskID)
return
}
@@ -388,6 +403,12 @@ func (mq *MaintenanceQueue) CompleteTask(taskID string, error string) {
duration = completedTime.Sub(*task.StartedAt)
}
// Capture workerID before it may be cleared during retry
originalWorkerID := task.WorkerID
var taskToSave *MaintenanceTask
var logFn func()
if error != "" {
task.Status = TaskStatusFailed
task.Error = error
@@ -420,10 +441,12 @@ func (mq *MaintenanceQueue) CompleteTask(taskID string, error string) {
mq.integration.SyncTask(task)
}
// Save task state after retry setup
mq.saveTaskState(task)
glog.Warningf("Task failed, scheduling retry: %s (%s) attempt %d/%d, worker %s, duration %v, error: %s",
taskID, task.Type, task.RetryCount, task.MaxRetries, task.WorkerID, duration, error)
taskToSave = task
retryCount, maxRetries := task.RetryCount, task.MaxRetries
logFn = func() {
glog.Warningf("Task failed, scheduling retry: %s (%s) attempt %d/%d, worker %s, duration %v, error: %s",
taskID, task.Type, retryCount, maxRetries, originalWorkerID, duration, error)
}
} else {
// Record unassignment due to permanent failure
if task.WorkerID != "" && len(task.AssignmentHistory) > 0 {
@@ -435,23 +458,27 @@ func (mq *MaintenanceQueue) CompleteTask(taskID string, error string) {
}
}
// Save task state after permanent failure
mq.saveTaskState(task)
glog.Errorf("Task failed permanently: %s (%s) worker %s, duration %v, after %d retries: %s",
taskID, task.Type, task.WorkerID, duration, task.MaxRetries, error)
taskToSave = task
maxRetries := task.MaxRetries
logFn = func() {
glog.Errorf("Task failed permanently: %s (%s) worker %s, duration %v, after %d retries: %s",
taskID, task.Type, originalWorkerID, duration, maxRetries, error)
}
}
} else {
task.Status = TaskStatusCompleted
task.Progress = 100
// Save task state after successful completion
mq.saveTaskState(task)
glog.Infof("Task completed: %s (%s) worker %s, duration %v, volume %d",
taskID, task.Type, task.WorkerID, duration, task.VolumeID)
taskToSave = task
volumeID := task.VolumeID
logFn = func() {
glog.Infof("Task completed: %s (%s) worker %s, duration %v, volume %d",
taskID, task.Type, originalWorkerID, duration, volumeID)
}
}
// Update worker
if task.WorkerID != "" {
if worker, exists := mq.workers[task.WorkerID]; exists {
// Update worker load and capture state before releasing lock
if originalWorkerID != "" {
if worker, exists := mq.workers[originalWorkerID]; exists {
worker.CurrentTask = nil
worker.CurrentLoad--
if worker.CurrentLoad == 0 {
@@ -459,16 +486,32 @@ func (mq *MaintenanceQueue) CompleteTask(taskID string, error string) {
}
}
}
taskStatus := task.Status
taskCount := len(mq.tasks)
// Snapshot task state while lock is still held to avoid data race
var taskToSaveSnapshot *MaintenanceTask
if taskToSave != nil {
taskToSaveSnapshot = snapshotTask(taskToSave)
}
mq.mutex.Unlock()
// Save task state to persistence outside the lock
if taskToSaveSnapshot != nil {
mq.saveTaskState(taskToSaveSnapshot)
}
if logFn != nil {
logFn()
}
// Remove pending operation (unless it's being retried)
if task.Status != TaskStatusPending {
if taskStatus != TaskStatusPending {
mq.removePendingOperation(taskID)
}
// Periodically cleanup old completed tasks (every 10th completion)
if task.Status == TaskStatusCompleted {
// Simple counter-based trigger for cleanup
if len(mq.tasks)%10 == 0 {
// Periodically cleanup old completed tasks (when total task count is a multiple of 10)
if taskStatus == TaskStatusCompleted {
if taskCount%10 == 0 {
go mq.cleanupCompletedTasks()
}
}
@@ -476,35 +519,46 @@ func (mq *MaintenanceQueue) CompleteTask(taskID string, error string) {
// UpdateTaskProgress updates the progress of a running task
func (mq *MaintenanceQueue) UpdateTaskProgress(taskID string, progress float64) {
mq.mutex.RLock()
defer mq.mutex.RUnlock()
mq.mutex.Lock()
if task, exists := mq.tasks[taskID]; exists {
oldProgress := task.Progress
task.Progress = progress
task.Status = TaskStatusInProgress
// Update pending operation status
mq.updatePendingOperationStatus(taskID, "in_progress")
// Log progress at significant milestones or changes
if progress == 0 {
glog.V(1).Infof("Task started: %s (%s) worker %s, volume %d",
taskID, task.Type, task.WorkerID, task.VolumeID)
} else if progress >= 100 {
glog.V(1).Infof("Task progress: %s (%s) worker %s, %.1f%% complete",
taskID, task.Type, task.WorkerID, progress)
} else if progress-oldProgress >= 25 { // Log every 25% increment
glog.V(1).Infof("Task progress: %s (%s) worker %s, %.1f%% complete",
taskID, task.Type, task.WorkerID, progress)
}
// Save task state after progress update
if progress == 0 || progress >= 100 || progress-oldProgress >= 10 {
mq.saveTaskState(task)
}
} else {
task, exists := mq.tasks[taskID]
if !exists {
mq.mutex.Unlock()
glog.V(2).Infof("Progress update for unknown task: %s (%.1f%%)", taskID, progress)
return
}
oldProgress := task.Progress
task.Progress = progress
task.Status = TaskStatusInProgress
// Update pending operation status while lock is held
mq.updatePendingOperationStatus(taskID, "in_progress")
// Determine whether to persist and capture log fields before unlocking
shouldSave := progress == 0 || progress >= 100 || progress-oldProgress >= 10
var taskSnapshot *MaintenanceTask
if shouldSave {
taskSnapshot = snapshotTask(task)
}
taskType, workerID, volumeID := task.Type, task.WorkerID, task.VolumeID
mq.mutex.Unlock()
// Log progress at significant milestones or changes
if progress == 0 {
glog.V(1).Infof("Task started: %s (%s) worker %s, volume %d",
taskID, taskType, workerID, volumeID)
} else if progress >= 100 {
glog.V(1).Infof("Task progress: %s (%s) worker %s, %.1f%% complete",
taskID, taskType, workerID, progress)
} else if progress-oldProgress >= 25 { // Log every 25% increment
glog.V(1).Infof("Task progress: %s (%s) worker %s, %.1f%% complete",
taskID, taskType, workerID, progress)
}
// Save task state outside the lock to avoid blocking readers
if taskSnapshot != nil {
mq.saveTaskState(taskSnapshot)
}
}
@@ -1004,3 +1058,41 @@ func (mq *MaintenanceQueue) updatePendingOperationStatus(taskID string, status s
pendingOps.UpdateOperationStatus(taskID, status)
}
// snapshotTask returns a shallow copy of t with slice and map fields deep-copied
// so that the snapshot can be safely passed to saveTaskState after mq.mutex is
// released without racing against concurrent mutations of the live task struct.
// Must be called with mq.mutex held.
func snapshotTask(t *MaintenanceTask) *MaintenanceTask {
cp := *t // copy all scalar / pointer-sized fields
// Deep-copy AssignmentHistory: the slice header and each record pointer.
// Records themselves are never mutated after being appended, so copying
// the pointers is sufficient.
if t.AssignmentHistory != nil {
cp.AssignmentHistory = make([]*TaskAssignmentRecord, len(t.AssignmentHistory))
copy(cp.AssignmentHistory, t.AssignmentHistory)
}
// Deep-copy Tags map to avoid concurrent map read/write.
if t.Tags != nil {
cp.Tags = make(map[string]string, len(t.Tags))
for k, v := range t.Tags {
cp.Tags[k] = v
}
}
// Copy optional time pointers so a concurrent nil-assignment (e.g. retry
// path clearing StartedAt) does not race with maintenanceTaskToProtobuf
// reading the pointed-to value.
if t.StartedAt != nil {
ts := *t.StartedAt
cp.StartedAt = &ts
}
if t.CompletedAt != nil {
tc := *t.CompletedAt
cp.CompletedAt = &tc
}
return &cp
}
+19
View File
@@ -0,0 +1,19 @@
package layout
import "net/http"
// ViewContext contains per-request metadata needed by layout templates.
type ViewContext struct {
Request *http.Request
Username string
CSRFToken string
}
// NewViewContext builds a ViewContext from request metadata.
func NewViewContext(r *http.Request, username, csrfToken string) ViewContext {
return ViewContext{
Request: r,
Username: username,
CSRFToken: csrfToken,
}
}
+10 -7
View File
@@ -4,19 +4,21 @@ import (
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/util/version"
)
templ Layout(c *gin.Context, content templ.Component) {
templ Layout(view ViewContext, content templ.Component) {
{{
username := c.GetString("username")
username := view.Username
if username == "" {
username = "admin"
username = "unknown"
}
csrfToken := c.GetString("csrf_token")
csrfToken := view.CSRFToken
currentPath := c.Request.URL.Path
currentPath := ""
if view.Request != nil {
currentPath = view.Request.URL.Path
}
// Detect if we're on a message queue page to keep submenu expanded
isMQPage := strings.HasPrefix(currentPath, "/mq/")
@@ -357,7 +359,7 @@ templ Layout(c *gin.Context, content templ.Component) {
</html>
}
templ LoginForm(c *gin.Context, title string, errorMessage string) {
templ LoginForm(title string, errorMessage string, csrfToken string) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -388,6 +390,7 @@ templ LoginForm(c *gin.Context, title string, errorMessage string) {
}
<form method="POST" action="/login">
<input type="hidden" name="csrf_token" value={ csrfToken }>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<div class="input-group">
+32 -17
View File
@@ -10,13 +10,12 @@ import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/util/version"
"strings"
"time"
)
func Layout(c *gin.Context, content templ.Component) templ.Component {
func Layout(view ViewContext, content templ.Component) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -37,13 +36,16 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
username := c.GetString("username")
username := view.Username
if username == "" {
username = "admin"
username = "unknown"
}
csrfToken := c.GetString("csrf_token")
csrfToken := view.CSRFToken
currentPath := c.Request.URL.Path
currentPath := ""
if view.Request != nil {
currentPath = view.Request.URL.Path
}
// Detect if we're on a message queue page to keep submenu expanded
isMQPage := strings.HasPrefix(currentPath, "/mq/")
@@ -63,7 +65,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 39, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 41, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -76,7 +78,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 70, Col: 73}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 72, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -111,7 +113,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", isClusterPage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 97, Col: 207}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 99, Col: 207}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -168,7 +170,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", isStoragePage))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 122, Col: 207}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 124, Col: 207}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -342,7 +344,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year()))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 337, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 339, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -355,7 +357,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 337, Col: 102}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 339, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -379,7 +381,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
})
}
func LoginForm(c *gin.Context, title string, errorMessage string) templ.Component {
func LoginForm(title string, errorMessage string, csrfToken string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -407,7 +409,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 365, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 367, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
@@ -420,7 +422,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 379, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 381, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -438,7 +440,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 386, Col: 45}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 388, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -449,7 +451,20 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<form method=\"POST\" action=\"/login\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-user\"></i></span> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div></div><div class=\"mb-4\"><label for=\"password\" class=\"form-label\">Password</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-lock\"></i></span> <input type=\"password\" class=\"form-control\" id=\"password\" name=\"password\" required></div></div><button type=\"submit\" class=\"btn btn-primary w-100\"><i class=\"fas fa-sign-in-alt me-2\"></i>Sign In</button></form></div></div></div></div></div><script src=\"/static/js/bootstrap.bundle.min.js\"></script></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<form method=\"POST\" action=\"/login\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(csrfToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 393, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\"><div class=\"mb-3\"><label for=\"username\" class=\"form-label\">Username</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-user\"></i></span> <input type=\"text\" class=\"form-control\" id=\"username\" name=\"username\" required></div></div><div class=\"mb-4\"><label for=\"password\" class=\"form-label\">Password</label><div class=\"input-group\"><span class=\"input-group-text\"><i class=\"fas fa-lock\"></i></span> <input type=\"password\" class=\"form-control\" id=\"password\" name=\"password\" required></div></div><button type=\"submit\" class=\"btn btn-primary w-100\"><i class=\"fas fa-sign-in-alt me-2\"></i>Sign In</button></form></div></div></div></div></div><script src=\"/static/js/bootstrap.bundle.min.js\"></script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+2 -1
View File
@@ -3,6 +3,7 @@ package cluster
import (
"context"
"github.com/dustin/go-humanize/english"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
@@ -20,7 +21,7 @@ func ListExistingPeerUpdates(master pb.ServerAddress, grpcDialOption grpc.DialOp
return err
}
glog.V(0).Infof("the cluster has %d %s\n", len(resp.ClusterNodes), clientType)
glog.V(0).Infof("the cluster has %d %s\n", len(resp.ClusterNodes), english.PluralWord(len(resp.ClusterNodes), clientType, ""))
for _, node := range resp.ClusterNodes {
existingNodes = append(existingNodes, &master_pb.ClusterNodeUpdate{
NodeType: FilerType,
+150 -60
View File
@@ -1,22 +1,24 @@
package command
import (
"bufio"
"context"
"crypto/rand"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"os/user"
"path/filepath"
"runtime/debug"
"strings"
"syscall"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/spf13/viper"
"github.com/seaweedfs/seaweedfs/weed/admin"
@@ -232,25 +234,10 @@ func runAdmin(cmd *Command, args []string) bool {
// startAdminServer starts the actual admin server
func startAdminServer(ctx context.Context, options AdminOptions, enableUI bool, icebergPort int) error {
// Set Gin mode
gin.SetMode(gin.ReleaseMode)
// Create router
r := gin.New()
r.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
if param.StatusCode == 200 {
return ""
}
return fmt.Sprintf("[GIN] %v | %3d | %13v | %15s | %-7s %s\n%s",
param.TimeStamp.Format("2006/01/02 - 15:04:05"),
param.StatusCode,
param.Latency,
param.ClientIP,
param.Method,
param.Path,
param.ErrorMessage,
)
}), gin.Recovery())
r := mux.NewRouter()
r.Use(loggingMiddleware)
r.Use(recoveryMiddleware)
// Create data directory first if specified (needed for session key storage)
var dataDir string
@@ -276,30 +263,30 @@ func startAdminServer(ctx context.Context, options AdminOptions, enableUI bool,
// Detect TLS configuration to set Secure cookie flag
cookieSecure := viper.GetString("https.admin.key") != ""
// Session store - load or generate session key
sessionKeyBytes, err := loadOrGenerateSessionKey(dataDir)
// Session store - load or generate session keys
authKey, encKey, err := loadOrGenerateSessionKeys(dataDir)
if err != nil {
return fmt.Errorf("failed to get session key: %w", err)
}
store := cookie.NewStore(sessionKeyBytes)
store := sessions.NewCookieStore(authKey, encKey)
// Configure session options to ensure cookies are properly saved
store.Options(sessions.Options{
store.Options = &sessions.Options{
Path: "/",
MaxAge: 3600 * 24, // 24 hours
HttpOnly: true, // Prevent JavaScript access
Secure: cookieSecure, // Set based on actual TLS configuration
SameSite: http.SameSiteLaxMode,
})
r.Use(sessions.Sessions("admin-session", store))
}
// Static files - serve from embedded filesystem
staticFS, err := admin.GetStaticFS()
if err != nil {
log.Printf("Warning: Failed to load embedded static files: %v", err)
} else {
r.StaticFS("/static", http.FS(staticFS))
staticHandler := http.FileServer(http.FS(staticFS))
r.Handle("/static", http.RedirectHandler("/static/", http.StatusMovedPermanently))
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", staticHandler))
}
// Create admin server (plugin is always enabled)
@@ -328,7 +315,7 @@ func startAdminServer(ctx context.Context, options AdminOptions, enableUI bool,
// Create handlers and setup routes
authRequired := *options.adminPassword != ""
adminHandlers := handlers.NewAdminHandlers(adminServer)
adminHandlers := handlers.NewAdminHandlers(adminServer, store)
adminHandlers.SetupRoutes(r, authRequired, *options.adminUser, *options.adminPassword, *options.readOnlyUser, *options.readOnlyPassword, enableUI)
// Server configuration
@@ -395,49 +382,156 @@ func startAdminServer(ctx context.Context, options AdminOptions, enableUI bool,
return nil
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
return r.ResponseWriter.Write(b)
}
func (r *statusRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := r.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, http.ErrNotSupported
}
func (r *statusRecorder) Push(target string, opts *http.PushOptions) error {
if p, ok := r.ResponseWriter.(http.Pusher); ok {
return p.Push(target, opts)
}
return http.ErrNotSupported
}
func (r *statusRecorder) Unwrap() http.ResponseWriter {
return r.ResponseWriter
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
recorder := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(recorder, r)
status := recorder.status
if status == 0 {
status = http.StatusOK
}
if status >= 200 && status < 300 {
return
}
log.Printf("[HTTP] %v | %3d | %13v | %15s | %-7s %s",
time.Now().Format("2006/01/02 - 15:04:05"),
status,
time.Since(start),
r.RemoteAddr,
r.Method,
r.URL.Path,
)
})
}
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic: %v\n%s", err, debug.Stack())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// GetAdminOptions returns the admin command options for testing
func GetAdminOptions() *AdminOptions {
return &AdminOptions{}
}
// loadOrGenerateSessionKey loads an existing session key from dataDir or generates a new one
func loadOrGenerateSessionKey(dataDir string) ([]byte, error) {
const sessionKeyLength = 32
// loadOrGenerateSessionKeys loads or creates authentication/encryption keys for session cookies.
func loadOrGenerateSessionKeys(dataDir string) ([]byte, []byte, error) {
const keyLen = 32
if dataDir == "" {
// No persistence, generate random key
log.Println("No dataDir specified, generating ephemeral session key")
key := make([]byte, sessionKeyLength)
_, err := rand.Read(key)
return key, err
// No persistence, generate ephemeral keys
log.Println("No dataDir specified, generating ephemeral session keys")
authKey := make([]byte, keyLen)
encKey := make([]byte, keyLen)
if _, err := rand.Read(authKey); err != nil {
return nil, nil, err
}
if _, err := rand.Read(encKey); err != nil {
return nil, nil, err
}
return authKey, encKey, nil
}
sessionKeyPath := filepath.Join(dataDir, ".session_key")
// Try to load existing key
if data, err := os.ReadFile(sessionKeyPath); err == nil {
if len(data) == sessionKeyLength {
switch len(data) {
case keyLen:
authKey := make([]byte, keyLen)
copy(authKey, data)
encKey := make([]byte, keyLen)
if _, err := rand.Read(encKey); err != nil {
return nil, nil, err
}
log.Printf("Warning: Upgrading session key at %s by adding an encryption key; existing cookies will be invalidated", sessionKeyPath)
combined := append(authKey, encKey...)
if err := os.WriteFile(sessionKeyPath, combined, 0600); err != nil {
log.Printf("Warning: Failed to persist upgraded session key: %v", err)
} else {
log.Printf("Upgraded session key file to include encryption key: %s", sessionKeyPath)
}
return authKey, encKey, nil
case 2 * keyLen:
authKey := make([]byte, keyLen)
encKey := make([]byte, keyLen)
copy(authKey, data[:keyLen])
copy(encKey, data[keyLen:])
log.Printf("Loaded persisted session key from %s", sessionKeyPath)
return data, nil
return authKey, encKey, nil
default:
log.Printf("Warning: Invalid session key file (expected %d or %d bytes, got %d), generating new key", keyLen, 2*keyLen, len(data))
}
log.Printf("Warning: Invalid session key file (expected %d bytes, got %d), generating new key", sessionKeyLength, len(data))
} else if !os.IsNotExist(err) {
log.Printf("Warning: Failed to read session key from %s: %v. A new key will be generated.", sessionKeyPath, err)
}
// Generate new key
key := make([]byte, sessionKeyLength)
key := make([]byte, 2*keyLen)
if _, err := rand.Read(key); err != nil {
return nil, err
return nil, nil, err
}
// Save key for future use
if err := os.WriteFile(sessionKeyPath, key, 0600); err != nil {
log.Printf("Warning: Failed to persist session key: %v", err)
} else {
log.Printf("Generated and persisted new session key to %s", sessionKeyPath)
}
return key, nil
return key[:keyLen], key[keyLen:], nil
}
// expandHomeDir expands the tilde (~) in a path to the user's home directory
@@ -466,20 +560,16 @@ func expandHomeDir(path string) (string, error) {
}
// Handle ~username/ patterns
if strings.HasPrefix(path, "~") {
parts := strings.SplitN(path[1:], "/", 2)
username := parts[0]
parts := strings.SplitN(path[1:], "/", 2)
username := parts[0]
targetUser, err := user.Lookup(username)
if err != nil {
return "", fmt.Errorf("user %s not found: %v", username, err)
}
if len(parts) == 1 {
return targetUser.HomeDir, nil
}
return filepath.Join(targetUser.HomeDir, parts[1]), nil
targetUser, err := user.Lookup(username)
if err != nil {
return "", fmt.Errorf("user %s not found: %v", username, err)
}
return path, nil
if len(parts) == 1 {
return targetUser.HomeDir, nil
}
return filepath.Join(targetUser.HomeDir, parts[1]), nil
}
-2
View File
@@ -37,12 +37,10 @@ var Commands = []*Command{
cmdMqAgent,
cmdMqBroker,
cmdMqKafkaGateway,
cmdDB,
cmdS3,
cmdScaffold,
cmdServer,
cmdShell,
cmdSql,
cmdUpdate,
cmdUpload,
cmdVersion,
+45 -35
View File
@@ -25,8 +25,8 @@ import (
)
var (
copy CopyOptions
waitGroup sync.WaitGroup
copyOptions CopyOptions
waitGroup sync.WaitGroup
)
type CopyOptions struct {
@@ -50,17 +50,17 @@ type CopyOptions struct {
func init() {
cmdFilerCopy.Run = runCopy // break init cycle
cmdFilerCopy.IsDebug = cmdFilerCopy.Flag.Bool("debug", false, "verbose debug information")
copy.include = cmdFilerCopy.Flag.String("include", "", "pattens of files to copy, e.g., *.pdf, *.html, ab?d.txt, works together with -dir")
copy.replication = cmdFilerCopy.Flag.String("replication", "", "replication type")
copy.collection = cmdFilerCopy.Flag.String("collection", "", "optional collection name")
copy.ttl = cmdFilerCopy.Flag.String("ttl", "", "time to live, e.g.: 1m, 1h, 1d, 1M, 1y")
copy.diskType = cmdFilerCopy.Flag.String("disk", "", "[hdd|ssd|<tag>] hard drive or solid state drive or any tag")
copy.maxMB = cmdFilerCopy.Flag.Int("maxMB", 4, "split files larger than the limit")
copy.concurrentFiles = cmdFilerCopy.Flag.Int("c", 8, "concurrent file copy goroutines")
copy.concurrentChunks = cmdFilerCopy.Flag.Int("concurrentChunks", 8, "concurrent chunk copy goroutines for each file")
copy.checkSize = cmdFilerCopy.Flag.Bool("check.size", false, "copy when the target file size is different from the source file")
copy.verbose = cmdFilerCopy.Flag.Bool("verbose", false, "print out details during copying")
copy.volumeServerAccess = cmdFilerCopy.Flag.String("volumeServerAccess", "direct", "access volume servers by [direct|publicUrl]")
copyOptions.include = cmdFilerCopy.Flag.String("include", "", "patterns of files to copy, e.g., *.pdf, *.html, ab?d.txt, works together with -dir")
copyOptions.replication = cmdFilerCopy.Flag.String("replication", "", "replication type")
copyOptions.collection = cmdFilerCopy.Flag.String("collection", "", "optional collection name")
copyOptions.ttl = cmdFilerCopy.Flag.String("ttl", "", "time to live, e.g.: 1m, 1h, 1d, 1M, 1y")
copyOptions.diskType = cmdFilerCopy.Flag.String("disk", "", "[hdd|ssd|<tag>] hard drive or solid state drive or any tag")
copyOptions.maxMB = cmdFilerCopy.Flag.Int("maxMB", 4, "split files larger than the limit")
copyOptions.concurrentFiles = cmdFilerCopy.Flag.Int("c", 8, "concurrent file copy goroutines")
copyOptions.concurrentChunks = cmdFilerCopy.Flag.Int("concurrentChunks", 8, "concurrent chunk copy goroutines for each file")
copyOptions.checkSize = cmdFilerCopy.Flag.Bool("check.size", false, "copy when the target file size is different from the source file")
copyOptions.verbose = cmdFilerCopy.Flag.Bool("verbose", false, "print out details during copying")
copyOptions.volumeServerAccess = cmdFilerCopy.Flag.String("volumeServerAccess", "direct", "access volume servers by [direct|publicUrl]")
}
var cmdFilerCopy = &Command{
@@ -99,9 +99,9 @@ func runCopy(cmd *Command, args []string) bool {
return false
}
copy.grpcDialOption = security.LoadClientTLS(util.GetViper(), "grpc.client")
copyOptions.grpcDialOption = security.LoadClientTLS(util.GetViper(), "grpc.client")
masters, collection, replication, dirBuckets, maxMB, cipher, err := readFilerConfiguration(copy.grpcDialOption, filerAddress)
masters, collection, replication, dirBuckets, maxMB, cipher, err := readFilerConfiguration(copyOptions.grpcDialOption, filerAddress)
if err != nil {
fmt.Printf("read from filer %s: %v\n", filerAddress, err)
return false
@@ -110,38 +110,45 @@ func runCopy(cmd *Command, args []string) bool {
restPath := urlPath[len(dirBuckets)+1:]
if strings.Index(restPath, "/") > 0 {
expectedBucket := restPath[:strings.Index(restPath, "/")]
if *copy.collection == "" {
*copy.collection = expectedBucket
} else if *copy.collection != expectedBucket {
fmt.Printf("destination %s uses collection \"%s\": unexpected collection \"%v\"\n", urlPath, expectedBucket, *copy.collection)
if *copyOptions.collection == "" {
*copyOptions.collection = expectedBucket
} else if *copyOptions.collection != expectedBucket {
fmt.Printf("destination %s uses collection \"%s\": unexpected collection \"%v\"\n", urlPath, expectedBucket, *copyOptions.collection)
return true
}
}
}
if *copy.collection == "" {
*copy.collection = collection
if *copyOptions.collection == "" {
*copyOptions.collection = collection
}
if *copy.replication == "" {
*copy.replication = replication
if *copyOptions.replication == "" {
*copyOptions.replication = replication
}
if *copy.maxMB == 0 {
*copy.maxMB = int(maxMB)
if *copyOptions.maxMB == 0 {
*copyOptions.maxMB = int(maxMB)
}
copy.masters = masters
copy.cipher = cipher
copyOptions.masters = masters
copyOptions.cipher = cipher
ttl, err := needle.ReadTTL(*copy.ttl)
ttl, err := needle.ReadTTL(*copyOptions.ttl)
if err != nil {
fmt.Printf("parsing ttl %s: %v\n", *copy.ttl, err)
fmt.Printf("parsing ttl %s: %v\n", *copyOptions.ttl, err)
return false
}
copy.ttlSec = int32(ttl.Minutes()) * 60
copyOptions.ttlSec = int32(ttl.Minutes()) * 60
if *cmdFilerCopy.IsDebug {
grace.SetupProfiling("filer.copy.cpu.pprof", "filer.copy.mem.pprof")
grace.SetupProfiling("filer.copyOptions.cpu.pprof", "filer.copyOptions.mem.pprof")
}
fileCopyTaskChan := make(chan FileCopyTask, *copy.concurrentFiles)
concurrentFiles := *copyOptions.concurrentFiles
if concurrentFiles <= 0 {
fmt.Fprintf(os.Stderr, "Invalid concurrency %d; using at least 1 worker\n", concurrentFiles)
concurrentFiles = 1
}
*copyOptions.concurrentFiles = concurrentFiles
fileCopyTaskChan := make(chan FileCopyTask, concurrentFiles)
go func() {
defer close(fileCopyTaskChan)
@@ -152,12 +159,12 @@ func runCopy(cmd *Command, args []string) bool {
}
}
}()
for i := 0; i < *copy.concurrentFiles; i++ {
for i := 0; i < concurrentFiles; i++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
worker := FileCopyWorker{
options: &copy,
options: &copyOptions,
filerAddress: filerAddress,
signature: util.RandomInt32(),
}
@@ -481,8 +488,11 @@ func (worker *FileCopyWorker) uploadFileInChunks(task FileCopyTask, f *os.File,
for _, chunk := range chunks {
fileIds = append(fileIds, chunk.FileId)
}
if len(worker.options.masters) == 0 {
return fmt.Errorf("upload data %v: %w (cleanup skipped: no masters configured)", fileName, uploadError)
}
operation.DeleteFileIds(func(_ context.Context) pb.ServerAddress {
return pb.ServerAddress(copy.masters[0])
return pb.ServerAddress(worker.options.masters[0])
}, false, worker.options.grpcDialOption, fileIds)
return uploadError
}
+2 -2
View File
@@ -1186,7 +1186,7 @@ func startMiniPluginWorker(ctx context.Context) {
util.LoadConfiguration("security", false)
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.worker")
handlers, err := buildPluginWorkerHandlers(defaultMiniPluginJobTypes, grpcDialOption)
handlers, err := buildPluginWorkerHandlers(defaultMiniPluginJobTypes, grpcDialOption, int(pluginworker.DefaultMaxExecutionConcurrency), workerDir)
if err != nil {
glog.Fatalf("Failed to build mini plugin worker handlers: %v", err)
}
@@ -1204,7 +1204,7 @@ func startMiniPluginWorker(ctx context.Context) {
HeartbeatInterval: 15 * time.Second,
ReconnectDelay: 5 * time.Second,
MaxDetectionConcurrency: 1,
MaxExecutionConcurrency: 2,
MaxExecutionConcurrency: int(pluginworker.DefaultMaxExecutionConcurrency),
GrpcDialOption: grpcDialOption,
Handlers: handlers,
})
+15 -10
View File
@@ -9,6 +9,7 @@ import (
"strings"
"testing"
pluginworker "github.com/seaweedfs/seaweedfs/weed/plugin/worker"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
@@ -16,7 +17,9 @@ import (
func TestBuildPluginWorkerHandler(t *testing.T) {
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
handler, err := buildPluginWorkerHandler("vacuum", dialOption)
testMaxConcurrency := int(pluginworker.DefaultMaxExecutionConcurrency)
handler, err := buildPluginWorkerHandler("vacuum", dialOption, testMaxConcurrency, "")
if err != nil {
t.Fatalf("buildPluginWorkerHandler(vacuum) err = %v", err)
}
@@ -24,7 +27,7 @@ func TestBuildPluginWorkerHandler(t *testing.T) {
t.Fatalf("expected non-nil handler")
}
handler, err = buildPluginWorkerHandler("", dialOption)
handler, err = buildPluginWorkerHandler("", dialOption, testMaxConcurrency, "")
if err != nil {
t.Fatalf("buildPluginWorkerHandler(default) err = %v", err)
}
@@ -32,7 +35,7 @@ func TestBuildPluginWorkerHandler(t *testing.T) {
t.Fatalf("expected non-nil default handler")
}
handler, err = buildPluginWorkerHandler("volume_balance", dialOption)
handler, err = buildPluginWorkerHandler("volume_balance", dialOption, testMaxConcurrency, "")
if err != nil {
t.Fatalf("buildPluginWorkerHandler(volume_balance) err = %v", err)
}
@@ -40,7 +43,7 @@ func TestBuildPluginWorkerHandler(t *testing.T) {
t.Fatalf("expected non-nil volume_balance handler")
}
handler, err = buildPluginWorkerHandler("balance", dialOption)
handler, err = buildPluginWorkerHandler("balance", dialOption, testMaxConcurrency, "")
if err != nil {
t.Fatalf("buildPluginWorkerHandler(balance alias) err = %v", err)
}
@@ -48,7 +51,7 @@ func TestBuildPluginWorkerHandler(t *testing.T) {
t.Fatalf("expected non-nil balance alias handler")
}
handler, err = buildPluginWorkerHandler("erasure_coding", dialOption)
handler, err = buildPluginWorkerHandler("erasure_coding", dialOption, testMaxConcurrency, "")
if err != nil {
t.Fatalf("buildPluginWorkerHandler(erasure_coding) err = %v", err)
}
@@ -56,7 +59,7 @@ func TestBuildPluginWorkerHandler(t *testing.T) {
t.Fatalf("expected non-nil erasure_coding handler")
}
handler, err = buildPluginWorkerHandler("ec", dialOption)
handler, err = buildPluginWorkerHandler("ec", dialOption, testMaxConcurrency, "")
if err != nil {
t.Fatalf("buildPluginWorkerHandler(ec alias) err = %v", err)
}
@@ -64,7 +67,7 @@ func TestBuildPluginWorkerHandler(t *testing.T) {
t.Fatalf("expected non-nil ec alias handler")
}
_, err = buildPluginWorkerHandler("unknown", dialOption)
_, err = buildPluginWorkerHandler("unknown", dialOption, testMaxConcurrency, "")
if err == nil {
t.Fatalf("expected unsupported job type error")
}
@@ -73,7 +76,9 @@ func TestBuildPluginWorkerHandler(t *testing.T) {
func TestBuildPluginWorkerHandlers(t *testing.T) {
dialOption := grpc.WithTransportCredentials(insecure.NewCredentials())
handlers, err := buildPluginWorkerHandlers("vacuum,volume_balance,erasure_coding", dialOption)
testMaxConcurrency := int(pluginworker.DefaultMaxExecutionConcurrency)
handlers, err := buildPluginWorkerHandlers("vacuum,volume_balance,erasure_coding", dialOption, testMaxConcurrency, "")
if err != nil {
t.Fatalf("buildPluginWorkerHandlers(list) err = %v", err)
}
@@ -81,7 +86,7 @@ func TestBuildPluginWorkerHandlers(t *testing.T) {
t.Fatalf("expected 3 handlers, got %d", len(handlers))
}
handlers, err = buildPluginWorkerHandlers("balance,ec,vacuum,balance", dialOption)
handlers, err = buildPluginWorkerHandlers("balance,ec,vacuum,balance", dialOption, testMaxConcurrency, "")
if err != nil {
t.Fatalf("buildPluginWorkerHandlers(aliases) err = %v", err)
}
@@ -89,7 +94,7 @@ func TestBuildPluginWorkerHandlers(t *testing.T) {
t.Fatalf("expected deduped 3 handlers, got %d", len(handlers))
}
_, err = buildPluginWorkerHandlers("unknown,vacuum", dialOption)
_, err = buildPluginWorkerHandlers("unknown,vacuum", dialOption, testMaxConcurrency, "")
if err == nil {
t.Fatalf("expected unsupported job type error")
}
+21 -13
View File
@@ -101,6 +101,12 @@ func (sftpOpt *SftpOptions) startSftpServer() bool {
filerAddress := pb.ServerAddress(*sftpOpt.filer)
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.client")
// Load JWT configuration for filer signing
v := util.GetViper()
filerSigningKey := v.GetString("jwt.filer_signing.key")
v.SetDefault("jwt.filer_signing.expires_after_seconds", 600)
filerSigningExpiresAfter := v.GetInt("jwt.filer_signing.expires_after_seconds")
// metrics read from the filer
var metricsAddress string
var metricsIntervalSec int
@@ -137,19 +143,21 @@ func (sftpOpt *SftpOptions) startSftpServer() bool {
// Create a new SFTP service instance with all options
service := sftpd.NewSFTPService(&sftpd.SFTPServiceOptions{
GrpcDialOption: grpcDialOption,
DataCenter: *sftpOpt.dataCenter,
FilerGroup: filerGroup,
Filer: filerAddress,
SshPrivateKey: *sftpOpt.sshPrivateKey,
HostKeysFolder: *sftpOpt.hostKeysFolder,
AuthMethods: authMethods,
MaxAuthTries: *sftpOpt.maxAuthTries,
BannerMessage: *sftpOpt.bannerMessage,
LoginGraceTime: *sftpOpt.loginGraceTime,
ClientAliveInterval: *sftpOpt.clientAliveInterval,
ClientAliveCountMax: *sftpOpt.clientAliveCountMax,
UserStoreFile: *sftpOpt.userStoreFile,
GrpcDialOption: grpcDialOption,
DataCenter: *sftpOpt.dataCenter,
FilerGroup: filerGroup,
Filer: filerAddress,
SshPrivateKey: *sftpOpt.sshPrivateKey,
HostKeysFolder: *sftpOpt.hostKeysFolder,
AuthMethods: authMethods,
MaxAuthTries: *sftpOpt.maxAuthTries,
BannerMessage: *sftpOpt.bannerMessage,
LoginGraceTime: *sftpOpt.loginGraceTime,
ClientAliveInterval: *sftpOpt.clientAliveInterval,
ClientAliveCountMax: *sftpOpt.clientAliveCountMax,
UserStoreFile: *sftpOpt.userStoreFile,
FilerSigningKey: []byte(filerSigningKey),
FilerSigningExpiresAfter: filerSigningExpiresAfter,
})
// Register reload hook for HUP signal
+14 -6
View File
@@ -81,7 +81,7 @@ func runPluginWorkerWithOptions(options pluginWorkerRunOptions) bool {
return false
}
handlers, err := buildPluginWorkerHandlers(options.JobTypes, dialOption)
handlers, err := buildPluginWorkerHandlers(options.JobTypes, dialOption, options.MaxExecute, options.WorkingDir)
if err != nil {
glog.Errorf("Failed to build plugin worker handlers: %v", err)
return false
@@ -157,7 +157,13 @@ func resolvePluginWorkerID(explicitID string, workingDir string) (string, error)
return generated, nil
}
func buildPluginWorkerHandler(jobType string, dialOption grpc.DialOption) (pluginworker.JobHandler, error) {
// buildPluginWorkerHandler constructs the JobHandler for the given job type.
// maxExecute is forwarded to handlers that use it to report their own
// MaxExecutionConcurrency in Capability for consistency and future-proofing.
// The scheduler's effective per-worker MaxExecutionConcurrency is derived from
// the worker-level configuration (e.g. WorkerOptions.MaxExecutionConcurrency),
// not directly from the handler's Capability.
func buildPluginWorkerHandler(jobType string, dialOption grpc.DialOption, maxExecute int, workingDir string) (pluginworker.JobHandler, error) {
canonicalJobType, err := canonicalPluginWorkerJobType(jobType)
if err != nil {
return nil, err
@@ -165,17 +171,19 @@ func buildPluginWorkerHandler(jobType string, dialOption grpc.DialOption) (plugi
switch canonicalJobType {
case "vacuum":
return pluginworker.NewVacuumHandler(dialOption), nil
return pluginworker.NewVacuumHandler(dialOption, int32(maxExecute)), nil
case "volume_balance":
return pluginworker.NewVolumeBalanceHandler(dialOption), nil
case "erasure_coding":
return pluginworker.NewErasureCodingHandler(dialOption), nil
return pluginworker.NewErasureCodingHandler(dialOption, workingDir), nil
default:
return nil, fmt.Errorf("unsupported plugin job type %q", canonicalJobType)
}
}
func buildPluginWorkerHandlers(jobTypes string, dialOption grpc.DialOption) ([]pluginworker.JobHandler, error) {
// buildPluginWorkerHandlers constructs a deduplicated slice of JobHandlers for
// the comma-separated jobTypes string, forwarding maxExecute to each handler.
func buildPluginWorkerHandlers(jobTypes string, dialOption grpc.DialOption, maxExecute int, workingDir string) ([]pluginworker.JobHandler, error) {
parsedJobTypes, err := parsePluginWorkerJobTypes(jobTypes)
if err != nil {
return nil, err
@@ -183,7 +191,7 @@ func buildPluginWorkerHandlers(jobTypes string, dialOption grpc.DialOption) ([]p
handlers := make([]pluginworker.JobHandler, 0, len(parsedJobTypes))
for _, jobType := range parsedJobTypes {
handler, buildErr := buildPluginWorkerHandler(jobType, dialOption)
handler, buildErr := buildPluginWorkerHandler(jobType, dialOption, maxExecute, workingDir)
if buildErr != nil {
return nil, buildErr
}
+12
View File
@@ -248,6 +248,18 @@ func (m *IAMManager) CreateRole(ctx context.Context, filerAddress string, roleNa
return m.roleStore.StoreRole(ctx, "", roleName, roleDef)
}
// GetRole retrieves a role definition by name.
func (m *IAMManager) GetRole(ctx context.Context, roleName string) (*RoleDefinition, error) {
if !m.initialized {
return nil, fmt.Errorf("IAM manager not initialized")
}
if roleName == "" {
return nil, fmt.Errorf("role name cannot be empty")
}
return m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
}
// UpdateBucketPolicy updates the policy for a bucket
func (m *IAMManager) UpdateBucketPolicy(ctx context.Context, bucketName string, policyJSON []byte) error {
if !m.initialized {
+156 -11
View File
@@ -39,10 +39,72 @@ const (
accessKeyStatusInactive = iamlib.AccessKeyStatusInactive
)
var (
policyDocuments = map[string]*policy_engine.PolicyDocument{}
policyLock = sync.RWMutex{}
)
var policyLock = sync.RWMutex{}
// userPolicyKey returns a namespaced key for inline user policies to prevent collision with managed policies.
// getOrCreateUserPolicies returns the policy map for a user, creating it if needed.
// Returns a pointer to the user's policy map from Policies.InlinePolicies.
func (p *Policies) getOrCreateUserPolicies(userName string) map[string]policy_engine.PolicyDocument {
if p.InlinePolicies == nil {
p.InlinePolicies = make(map[string]map[string]policy_engine.PolicyDocument)
}
if p.InlinePolicies[userName] == nil {
p.InlinePolicies[userName] = make(map[string]policy_engine.PolicyDocument)
}
return p.InlinePolicies[userName]
}
// computeAggregatedActionsForUser computes the union of actions across all inline policies for a user.
// Directly accesses user's policies from Policies.InlinePolicies[userName] for O(1) lookup.
// If policies is non-nil, it uses that instead of fetching from storage (for I/O optimization).
// When policies is nil, it fetches from storage using GetPolicies.
//
// Performance: O(user_policies) instead of O(all_policies) with per-user index.
//
// Best-effort aggregation: If GetActions fails for a policy document, that policy is logged at Warning level
// but is NOT removed from persistent storage. This intentional choice ensures:
// - Stored policy documents survive even if they temporarily fail to parse
// - The policy data is preserved for potential future fixes or manual inspection
// - Only the runtime action set (ident.Actions) is affected when GetActions fails
// This keeps persistent state consistent while gracefully handling parsing errors.
func computeAggregatedActionsForUser(iama *IamApiServer, userName string, policies *Policies) ([]string, error) {
var aggregatedActions []string
actionSet := make(map[string]bool)
var policiesToUse Policies
if policies != nil {
// Use provided Policies (caller already fetched, avoids redundant I/O)
policiesToUse = *policies
} else {
// Fetch from storage
if err := iama.s3ApiConfig.GetPolicies(&policiesToUse); err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
return nil, err
}
}
// Direct O(1) access to user's policies using per-user index
userPolicies := policiesToUse.InlinePolicies[userName]
if len(userPolicies) == 0 {
return aggregatedActions, nil
}
for policyName, policyDocument := range userPolicies {
actions, err := GetActions(&policyDocument)
if err != nil {
// Best-effort: policy stored successfully but failed to parse; log and skip from aggregation
glog.Warningf("Failed to get actions from stored policy '%s' for user %s (policy retained in storage): %v", policyName, userName, err)
continue
}
for _, action := range actions {
if !actionSet[action] {
actionSet[action] = true
aggregatedActions = append(aggregatedActions, action)
}
}
}
return aggregatedActions, nil
}
// Helper function wrappers using shared package
func MapToStatementAction(action string) string {
@@ -54,7 +116,13 @@ func MapToIdentitiesAction(action string) string {
}
type Policies struct {
// Policies: managed policies (flat map, unchanged for backward compatibility)
Policies map[string]policy_engine.PolicyDocument `json:"policies"`
// InlinePolicies: user-indexed inline policies for O(1) lookup
// Structure: [userName][policyName] -> PolicyDocument
// Enables fast access without iterating all policies
InlinePolicies map[string]map[string]policy_engine.PolicyDocument `json:"inlinePolicies"`
}
func Hash(s *string) string {
@@ -197,18 +265,40 @@ func (iama *IamApiServer) PutUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values
if err != nil {
return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err}
}
policyDocuments[policyName] = &policyDocument
actions, err := GetActions(&policyDocument)
if err != nil {
return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err}
}
// Log the actions
glog.V(3).Infof("PutUserPolicy: actions=%v", actions)
// Persist inline policy to storage using per-user indexed structure
policies := Policies{}
if err = iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
// Get or create user's policy map
userPolicies := policies.getOrCreateUserPolicies(userName)
userPolicies[policyName] = policyDocument
// policies.InlinePolicies[userName] now contains the updated map
if err = iama.s3ApiConfig.PutPolicies(&policies); err != nil {
return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
// Compute aggregated actions from all user's inline policies, passing the local policies
// to avoid redundant I/O (reuses the just-written Policies map)
aggregatedActions, computeErr := computeAggregatedActionsForUser(iama, userName, &policies)
if computeErr != nil {
glog.Warningf("Failed to compute aggregated actions for user %s: %v", userName, computeErr)
aggregatedActions = actions // Fall back to current policy's actions
}
glog.V(3).Infof("PutUserPolicy: aggregated actions=%v", aggregatedActions)
for _, ident := range s3cfg.Identities {
if userName != ident.Name {
continue
}
ident.Actions = actions
ident.Actions = aggregatedActions
return resp, nil
}
return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("the user with name %s cannot be found", userName)}
@@ -224,6 +314,29 @@ func (iama *IamApiServer) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values
resp.GetUserPolicyResult.UserName = userName
resp.GetUserPolicyResult.PolicyName = policyName
// Try to retrieve stored inline policy from persistent storage using per-user index
policies := Policies{}
if err := iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
// Propagate storage errors
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
// Direct O(1) access to user's policy using per-user index
if userPolicies := policies.InlinePolicies[userName]; userPolicies != nil {
if policyDocument, exists := userPolicies[policyName]; exists {
policyDocumentJSON, err := json.Marshal(policyDocument)
if err != nil {
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
resp.GetUserPolicyResult.PolicyDocument = string(policyDocumentJSON)
return resp, nil
} else {
// User's inline policies exist but this specific policy does not
return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: errors.New("policy not found")}
}
}
if len(ident.Actions) == 0 {
return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: errors.New("no actions found")}
}
@@ -276,13 +389,45 @@ func (iama *IamApiServer) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values
// DeleteUserPolicy removes the inline policy from a user (clears their actions).
func (iama *IamApiServer) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp DeleteUserPolicyResponse, err *IamError) {
userName := values.Get("UserName")
policyName := values.Get("PolicyName")
// First, verify the user exists in identities before modifying storage
var targetIdent *iam_pb.Identity
for _, ident := range s3cfg.Identities {
if ident.Name == userName {
ident.Actions = nil
return resp, nil
targetIdent = ident
break
}
}
return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)}
if targetIdent == nil {
return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)}
}
// User exists; now proceed with removing the stored inline policy from persistent storage
policies := Policies{}
if err := iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
// Propagate storage errors immediately
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
// Direct O(1) access to user's policy map using per-user index
if userPolicies := policies.InlinePolicies[userName]; userPolicies != nil {
delete(userPolicies, policyName)
// Note: userPolicies is a map, so the delete modifies the map in policies.InlinePolicies[userName]
if err := iama.s3ApiConfig.PutPolicies(&policies); err != nil {
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
}
// Recompute aggregated actions from remaining inline policies (passing policies to avoid redundant GetPolicies)
aggregatedActions, computeErr := computeAggregatedActionsForUser(iama, userName, &policies)
if computeErr != nil {
glog.Warningf("Failed to recompute aggregated actions for user %s: %v", userName, computeErr)
}
// Update the found identity's actions
targetIdent.Actions = aggregatedActions
return resp, nil
}
func GetActions(policy *policy_engine.PolicyDocument) ([]string, error) {
@@ -1,12 +1,42 @@
package iamapi
import (
"encoding/json"
"net/url"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/stretchr/testify/assert"
)
// mockIamS3ApiConfig is a mock for testing
type mockIamS3ApiConfig struct {
policies Policies
}
func (m *mockIamS3ApiConfig) GetS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) (err error) {
return nil
}
func (m *mockIamS3ApiConfig) PutS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) (err error) {
return nil
}
func (m *mockIamS3ApiConfig) GetPolicies(policies *Policies) (err error) {
*policies = m.policies
if m.policies.Policies == nil {
return filer_pb.ErrNotFound
}
return nil
}
func (m *mockIamS3ApiConfig) PutPolicies(policies *Policies) (err error) {
m.policies = *policies
return nil
}
func TestGetActionsUserPath(t *testing.T) {
policyDocument := policy_engine.PolicyDocument{
@@ -72,3 +102,162 @@ func TestGetActionsInvalidAction(t *testing.T) {
assert.NotNil(t, err)
assert.Equal(t, "not a valid action: 'InvalidAction'", err.Error())
}
func TestPutGetUserPolicyPreservesStatements(t *testing.T) {
s3cfg := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}},
}
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": [
"arn:aws:s3:::my-bucket/*",
"arn:aws:s3:::test/*"
]
},
{
"Effect": "Allow",
"Action": [
"s3:PutObject"
],
"Resource": [
"arn:aws:s3:::my-bucket/*",
"arn:aws:s3:::test/*"
]
},
{
"Effect": "Allow",
"Action": [
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::my-bucket/*",
"arn:aws:s3:::test/*"
]
}
]
}`
iama := &IamApiServer{
s3ApiConfig: &mockIamS3ApiConfig{},
}
putValues := url.Values{
"UserName": []string{"alice"},
"PolicyName": []string{"inline-policy"},
"PolicyDocument": []string{policyJSON},
}
_, iamErr := iama.PutUserPolicy(s3cfg, putValues)
assert.Nil(t, iamErr)
getValues := url.Values{
"UserName": []string{"alice"},
"PolicyName": []string{"inline-policy"},
}
resp, iamErr := iama.GetUserPolicy(s3cfg, getValues)
assert.Nil(t, iamErr)
// Verify that key policy properties are preserved (not merged or lost)
var got policy_engine.PolicyDocument
assert.NoError(t, json.Unmarshal([]byte(resp.GetUserPolicyResult.PolicyDocument), &got))
// Assert we have exactly 3 statements (not merged into 1 or lost)
assert.Equal(t, 3, len(got.Statement))
// Assert that DeleteObject statement is present (was lost in the bug)
deleteObjectFound := false
for _, stmt := range got.Statement {
if len(stmt.Action.Strings()) > 0 {
for _, action := range stmt.Action.Strings() {
if action == "s3:DeleteObject" {
deleteObjectFound = true
break
}
}
}
}
assert.True(t, deleteObjectFound, "s3:DeleteObject action was lost")
}
func TestMultipleInlinePoliciesAggregateActions(t *testing.T) {
s3cfg := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}},
}
policy1JSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::bucket-a/*"]
}
]
}`
policy2JSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": ["arn:aws:s3:::bucket-b/*"]
}
]
}`
iama := &IamApiServer{
s3ApiConfig: &mockIamS3ApiConfig{},
}
// Put first inline policy
putValues1 := url.Values{
"UserName": []string{"alice"},
"PolicyName": []string{"policy-read"},
"PolicyDocument": []string{policy1JSON},
}
_, iamErr := iama.PutUserPolicy(s3cfg, putValues1)
assert.Nil(t, iamErr)
// Check that alice's actions include read operations
aliceIdent := s3cfg.Identities[0]
assert.Greater(t, len(aliceIdent.Actions), 0, "Actions should not be empty after first policy")
// Put second inline policy
putValues2 := url.Values{
"UserName": []string{"alice"},
"PolicyName": []string{"policy-write"},
"PolicyDocument": []string{policy2JSON},
}
_, iamErr = iama.PutUserPolicy(s3cfg, putValues2)
assert.Nil(t, iamErr)
// Check that alice now has aggregated actions from both policies
// Should include Read and List (from policy1) and Write (from policy2)
// with resource paths indicating which policy they came from
// Build a set of actual action strings for exact membership checks
actionSet := make(map[string]bool)
for _, action := range aliceIdent.Actions {
actionSet[action] = true
}
// Expected actions from both policies:
// - policy1: GetObject, ListBucket on bucket-a/* → "Read:bucket-a/*", "List:bucket-a/*"
// - policy2: PutObject on bucket-b/* → "Write:bucket-b/*"
expectedActions := []string{
"Read:bucket-a/*",
"List:bucket-a/*",
"Write:bucket-b/*",
}
for _, expectedAction := range expectedActions {
assert.True(t, actionSet[expectedAction], "Expected action '%s' not found in aggregated actions. Got: %v", expectedAction, aliceIdent.Actions)
}
}
+708
View File
@@ -0,0 +1,708 @@
package pluginworker
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/admin/topology"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/operation"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
ecrepair "github.com/seaweedfs/seaweedfs/weed/worker/tasks/ec_repair"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
const recentTaskWindowSize = 10
type ecRepairWorkerConfig struct {
MinIntervalSeconds int
}
// EcRepairHandler is the plugin job handler for EC shard repair.
type EcRepairHandler struct {
grpcDialOption grpc.DialOption
}
func NewEcRepairHandler(grpcDialOption grpc.DialOption) *EcRepairHandler {
return &EcRepairHandler{grpcDialOption: grpcDialOption}
}
func (h *EcRepairHandler) Capability() *plugin_pb.JobTypeCapability {
return &plugin_pb.JobTypeCapability{
JobType: "ec_repair",
CanDetect: true,
CanExecute: true,
MaxDetectionConcurrency: 1,
MaxExecutionConcurrency: 1,
DisplayName: "EC Repair",
Description: "Repairs missing or inconsistent EC shards",
}
}
func (h *EcRepairHandler) Descriptor() *plugin_pb.JobTypeDescriptor {
return &plugin_pb.JobTypeDescriptor{
JobType: "ec_repair",
DisplayName: "EC Repair",
Description: "Detect and repair missing or inconsistent erasure coding shards",
Icon: "fas fa-toolbox",
DescriptorVersion: 1,
AdminConfigForm: &plugin_pb.ConfigForm{
FormId: "ec-repair-admin",
Title: "EC Repair Admin Config",
Description: "Admin-side controls for EC repair detection scope.",
Sections: []*plugin_pb.ConfigSection{
{
SectionId: "scope",
Title: "Scope",
Description: "Optional filters applied before EC repair detection.",
Fields: []*plugin_pb.ConfigField{
{
Name: "collection_filter",
Label: "Collection Filter",
Description: "Only detect EC repairs for this collection when set.",
Placeholder: "all collections",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_STRING,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_TEXT,
},
},
},
},
DefaultValues: map[string]*plugin_pb.ConfigValue{
"collection_filter": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: ""},
},
},
},
WorkerConfigForm: &plugin_pb.ConfigForm{
FormId: "ec-repair-worker",
Title: "EC Repair Worker Config",
Description: "Worker-side EC repair controls.",
Sections: []*plugin_pb.ConfigSection{
{
SectionId: "interval",
Title: "Detection Interval",
Description: "Minimum interval between EC repair scans.",
Fields: []*plugin_pb.ConfigField{
{
Name: "min_interval_seconds",
Label: "Minimum Detection Interval (s)",
Description: "Skip detection if the last successful run is more recent than this interval.",
FieldType: plugin_pb.ConfigFieldType_CONFIG_FIELD_TYPE_INT64,
Widget: plugin_pb.ConfigWidget_CONFIG_WIDGET_NUMBER,
Required: true,
MinValue: &plugin_pb.ConfigValue{Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 0}},
},
},
},
},
DefaultValues: map[string]*plugin_pb.ConfigValue{
"min_interval_seconds": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 300},
},
},
},
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
Enabled: true,
DetectionIntervalSeconds: 10 * 60,
DetectionTimeoutSeconds: 300,
MaxJobsPerDetection: 500,
GlobalExecutionConcurrency: 8,
PerWorkerExecutionConcurrency: 2,
RetryLimit: 1,
RetryBackoffSeconds: 30,
},
WorkerDefaultValues: map[string]*plugin_pb.ConfigValue{
"min_interval_seconds": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: 300},
},
},
}
}
func (h *EcRepairHandler) Detect(ctx context.Context, request *plugin_pb.RunDetectionRequest, sender DetectionSender) error {
if request == nil {
return fmt.Errorf("run detection request is nil")
}
if sender == nil {
return fmt.Errorf("detection sender is nil")
}
if request.JobType != "" && request.JobType != "ec_repair" {
return fmt.Errorf("job type %q is not handled by ec_repair worker", request.JobType)
}
workerConfig := deriveEcRepairWorkerConfig(request.GetWorkerConfigValues())
if shouldSkipDetectionByInterval(request.GetLastSuccessfulRun(), workerConfig.MinIntervalSeconds) {
minInterval := time.Duration(workerConfig.MinIntervalSeconds) * time.Second
_ = sender.SendActivity(buildDetectorActivity(
"skipped_by_interval",
fmt.Sprintf("EC REPAIR: Detection skipped due to min interval (%s)", minInterval),
map[string]*plugin_pb.ConfigValue{
"min_interval_seconds": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(workerConfig.MinIntervalSeconds)},
},
},
))
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
JobType: "ec_repair",
Proposals: []*plugin_pb.JobProposal{},
HasMore: false,
}); err != nil {
return err
}
return sender.SendComplete(&plugin_pb.DetectionComplete{
JobType: "ec_repair",
Success: true,
TotalProposals: 0,
})
}
collectionFilter := strings.TrimSpace(readStringConfig(request.GetAdminConfigValues(), "collection_filter", ""))
masters := make([]string, 0)
if request.ClusterContext != nil {
masters = append(masters, request.ClusterContext.MasterGrpcAddresses...)
}
response, _, err := h.fetchTopology(ctx, masters)
if err != nil {
return err
}
maxResults := int(request.MaxResults)
candidates, hasMore, err := ecrepair.Detect(response.TopologyInfo, collectionFilter, maxResults)
if err != nil {
return err
}
proposals := make([]*plugin_pb.JobProposal, 0, len(candidates))
for _, candidate := range candidates {
proposal, proposalErr := buildEcRepairProposal(candidate)
if proposalErr != nil {
glog.Warningf("Plugin worker skip invalid ec_repair proposal: %v", proposalErr)
continue
}
proposals = append(proposals, proposal)
}
if err := sender.SendProposals(&plugin_pb.DetectionProposals{
JobType: "ec_repair",
Proposals: proposals,
HasMore: hasMore,
}); err != nil {
return err
}
return sender.SendComplete(&plugin_pb.DetectionComplete{
JobType: "ec_repair",
Success: true,
TotalProposals: int32(len(proposals)),
})
}
func (h *EcRepairHandler) Execute(ctx context.Context, request *plugin_pb.ExecuteJobRequest, sender ExecutionSender) error {
if request == nil || request.Job == nil {
return fmt.Errorf("execute request/job is nil")
}
if sender == nil {
return fmt.Errorf("execution sender is nil")
}
if request.Job.JobType != "" && request.Job.JobType != "ec_repair" {
return fmt.Errorf("job type %q is not handled by ec_repair worker", request.Job.JobType)
}
volumeID := readInt64Config(request.Job.Parameters, "volume_id", 0)
if volumeID <= 0 {
return fmt.Errorf("missing volume_id in job parameters")
}
collection := readStringConfig(request.Job.Parameters, "collection", "")
diskType := readStringConfig(request.Job.Parameters, "disk_type", "")
masters := make([]string, 0)
if request.ClusterContext != nil {
masters = append(masters, request.ClusterContext.MasterGrpcAddresses...)
}
if err := sendProgress(sender, request.Job, 0, "assigned", "ec repair job accepted"); err != nil {
return err
}
response, activeTopology, err := h.fetchTopology(ctx, masters)
if err != nil {
return err
}
plan, err := ecrepair.BuildRepairPlan(response.TopologyInfo, activeTopology, uint32(volumeID), collection, diskType)
if err != nil {
return err
}
if len(plan.MissingShards) == 0 && len(plan.DeleteByNode) == 0 {
resultSummary := fmt.Sprintf("EC repair skipped for volume %d (no issues found)", volumeID)
return sender.SendCompleted(&plugin_pb.JobCompleted{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
Success: true,
Result: &plugin_pb.JobResult{
Summary: resultSummary,
OutputValues: map[string]*plugin_pb.ConfigValue{
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: volumeID},
},
},
},
Activities: []*plugin_pb.ActivityEvent{
buildExecutorActivity("completed", resultSummary),
},
})
}
if err := h.executeRepairPlan(ctx, plan, request.Job, sender); err != nil {
_ = sendProgress(sender, request.Job, 100, "failed", err.Error())
return err
}
resultSummary := fmt.Sprintf("EC repair completed for volume %d", volumeID)
return sender.SendCompleted(&plugin_pb.JobCompleted{
JobId: request.Job.JobId,
JobType: request.Job.JobType,
Success: true,
Result: &plugin_pb.JobResult{
Summary: resultSummary,
OutputValues: map[string]*plugin_pb.ConfigValue{
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: volumeID},
},
"collection": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: plan.Collection},
},
"disk_type": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: plan.DiskType},
},
},
},
Activities: []*plugin_pb.ActivityEvent{
buildExecutorActivity("completed", resultSummary),
},
})
}
func (h *EcRepairHandler) executeRepairPlan(ctx context.Context, plan *ecrepair.RepairPlan, job *plugin_pb.JobSpec, sender ExecutionSender) error {
if plan == nil {
return fmt.Errorf("repair plan is nil")
}
if len(plan.MissingShards) > 0 {
if err := sendProgress(sender, job, 10, "copy_existing_shards", "copying EC shards to rebuilder"); err != nil {
return err
}
if err := h.copyShardsToRebuilder(ctx, plan); err != nil {
return err
}
if err := sendProgress(sender, job, 40, "rebuild_missing_shards", "rebuilding missing EC shards"); err != nil {
return err
}
rebuilt, err := h.rebuildMissingShards(ctx, plan)
if err != nil {
return err
}
if err := sendProgress(sender, job, 60, "distribute_shards", "distributing rebuilt EC shards"); err != nil {
return err
}
if err := h.distributeRebuiltShards(ctx, plan, rebuilt); err != nil {
return err
}
if err := sendProgress(sender, job, 75, "cleanup_rebuilder", "cleaning up rebuilder temporary shards"); err != nil {
return err
}
if err := h.cleanupRebuilder(ctx, plan, rebuilt); err != nil {
return err
}
}
if len(plan.DeleteByNode) > 0 {
if err := sendProgress(sender, job, 85, "delete_extra_shards", "deleting extra or mismatched shards"); err != nil {
return err
}
if err := h.deleteExtraShards(ctx, plan); err != nil {
return err
}
}
return sendProgress(sender, job, 100, "completed", "ec repair completed")
}
func (h *EcRepairHandler) copyShardsToRebuilder(ctx context.Context, plan *ecrepair.RepairPlan) error {
if plan.Rebuilder.NodeAddress == "" {
return fmt.Errorf("rebuilder node is required")
}
if len(plan.CopySources) == 0 {
return nil
}
localShardSet := make(map[uint32]struct{}, len(plan.Rebuilder.LocalShards))
for _, shardID := range plan.Rebuilder.LocalShards {
localShardSet[shardID] = struct{}{}
}
shardIDs := make([]uint32, 0, len(plan.CopySources))
for shardID := range plan.CopySources {
shardIDs = append(shardIDs, shardID)
}
sort.Slice(shardIDs, func(i, j int) bool { return shardIDs[i] < shardIDs[j] })
copyIndexFiles := true
for _, shardID := range shardIDs {
source := strings.TrimSpace(plan.CopySources[shardID])
if source == "" {
continue
}
if source == plan.Rebuilder.NodeAddress {
if _, ok := localShardSet[shardID]; ok {
continue
}
}
err := operation.WithVolumeServerClient(false, pb.ServerAddress(plan.Rebuilder.NodeAddress), h.grpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
_, err := client.VolumeEcShardsCopy(ctx, &volume_server_pb.VolumeEcShardsCopyRequest{
VolumeId: plan.VolumeID,
Collection: plan.Collection,
ShardIds: []uint32{shardID},
CopyEcxFile: copyIndexFiles,
CopyEcjFile: copyIndexFiles,
CopyVifFile: copyIndexFiles,
SourceDataNode: source,
DiskId: plan.Rebuilder.DiskID,
})
return err
})
if err != nil {
return err
}
copyIndexFiles = false
}
return nil
}
func (h *EcRepairHandler) rebuildMissingShards(ctx context.Context, plan *ecrepair.RepairPlan) ([]uint32, error) {
var rebuilt []uint32
if plan.Rebuilder.NodeAddress == "" {
return nil, fmt.Errorf("rebuilder node is required")
}
if err := operation.WithVolumeServerClient(false, pb.ServerAddress(plan.Rebuilder.NodeAddress), h.grpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
resp, err := client.VolumeEcShardsRebuild(ctx, &volume_server_pb.VolumeEcShardsRebuildRequest{
VolumeId: plan.VolumeID,
Collection: plan.Collection,
})
if err != nil {
return err
}
rebuilt = append(rebuilt, resp.RebuiltShardIds...)
return nil
}); err != nil {
return nil, err
}
if len(rebuilt) == 0 {
if len(plan.MissingShards) > 0 {
glog.V(1).Infof("EC Repair: resp.RebuiltShardIds empty; assuming all missing shards rebuilt for volume %d (%d shards)", plan.VolumeID, len(plan.MissingShards))
} else {
glog.V(1).Infof("EC Repair: resp.RebuiltShardIds empty for volume %d with no missing shards declared", plan.VolumeID)
}
rebuilt = append(rebuilt, plan.MissingShards...)
}
return rebuilt, nil
}
func (h *EcRepairHandler) distributeRebuiltShards(ctx context.Context, plan *ecrepair.RepairPlan, rebuilt []uint32) error {
if len(plan.Targets) == 0 {
return nil
}
if len(rebuilt) == 0 {
return nil
}
rebuiltSet := make(map[uint32]struct{}, len(rebuilt))
for _, shardID := range rebuilt {
rebuiltSet[shardID] = struct{}{}
}
targetCopyIndex := make(map[string]bool)
for _, target := range plan.Targets {
if target.NodeAddress == "" {
continue
}
var shardIDs []uint32
for _, shardID := range target.ShardIDs {
if _, ok := rebuiltSet[shardID]; ok {
shardIDs = append(shardIDs, shardID)
}
}
if len(shardIDs) == 0 {
continue
}
sort.Slice(shardIDs, func(i, j int) bool { return shardIDs[i] < shardIDs[j] })
copyIndex := !targetCopyIndex[target.NodeAddress]
targetCopyIndex[target.NodeAddress] = true
err := operation.WithVolumeServerClient(false, pb.ServerAddress(target.NodeAddress), h.grpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
if target.NodeAddress != plan.Rebuilder.NodeAddress {
_, err := client.VolumeEcShardsCopy(ctx, &volume_server_pb.VolumeEcShardsCopyRequest{
VolumeId: plan.VolumeID,
Collection: plan.Collection,
ShardIds: shardIDs,
CopyEcxFile: copyIndex,
CopyEcjFile: copyIndex,
CopyVifFile: copyIndex,
SourceDataNode: plan.Rebuilder.NodeAddress,
DiskId: target.DiskID,
})
if err != nil {
return err
}
}
_, err := client.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: plan.VolumeID,
Collection: plan.Collection,
ShardIds: shardIDs,
})
return err
})
if err != nil {
return err
}
}
return nil
}
func (h *EcRepairHandler) cleanupRebuilder(ctx context.Context, plan *ecrepair.RepairPlan, rebuilt []uint32) error {
if plan.Rebuilder.NodeAddress == "" || len(rebuilt) == 0 {
return nil
}
keep := make(map[uint32]struct{})
for _, shardID := range plan.Rebuilder.LocalShards {
keep[shardID] = struct{}{}
}
for _, target := range plan.Targets {
if target.NodeAddress != plan.Rebuilder.NodeAddress {
continue
}
for _, shardID := range target.ShardIDs {
keep[shardID] = struct{}{}
}
}
var toDelete []uint32
for _, shardID := range rebuilt {
if _, ok := keep[shardID]; ok {
continue
}
toDelete = append(toDelete, shardID)
}
if len(toDelete) == 0 {
return nil
}
return deleteShardIds(ctx, h.grpcDialOption, plan.Rebuilder.NodeAddress, plan.VolumeID, plan.Collection, toDelete)
}
func (h *EcRepairHandler) deleteExtraShards(ctx context.Context, plan *ecrepair.RepairPlan) error {
for nodeAddress, shardIDs := range plan.DeleteByNode {
if len(shardIDs) == 0 {
continue
}
if err := deleteShardIds(ctx, h.grpcDialOption, nodeAddress, plan.VolumeID, plan.Collection, shardIDs); err != nil {
return err
}
}
return nil
}
func deleteShardIds(ctx context.Context, dialOption grpc.DialOption, nodeAddress string, volumeID uint32, collection string, shardIDs []uint32) error {
sorted := make([]uint32, len(shardIDs))
copy(sorted, shardIDs)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
return operation.WithVolumeServerClient(false, pb.ServerAddress(nodeAddress), dialOption, func(client volume_server_pb.VolumeServerClient) error {
_, err := client.VolumeEcShardsUnmount(ctx, &volume_server_pb.VolumeEcShardsUnmountRequest{
VolumeId: volumeID,
ShardIds: sorted,
})
if err != nil {
return err
}
_, err = client.VolumeEcShardsDelete(ctx, &volume_server_pb.VolumeEcShardsDeleteRequest{
VolumeId: volumeID,
Collection: collection,
ShardIds: sorted,
})
return err
})
}
func (h *EcRepairHandler) fetchTopology(ctx context.Context, masterAddresses []string) (*master_pb.VolumeListResponse, *topology.ActiveTopology, error) {
if h.grpcDialOption == nil {
return nil, nil, fmt.Errorf("grpc dial option is not configured")
}
if len(masterAddresses) == 0 {
return nil, nil, fmt.Errorf("no master addresses provided in cluster context")
}
for _, masterAddress := range masterAddresses {
response, err := h.fetchVolumeList(ctx, masterAddress)
if err != nil {
glog.Warningf("Plugin worker failed master volume list at %s: %v", masterAddress, err)
continue
}
if response == nil || response.TopologyInfo == nil {
continue
}
activeTopology := topology.NewActiveTopology(recentTaskWindowSize)
if err := activeTopology.UpdateTopology(response.TopologyInfo); err != nil {
return nil, nil, err
}
return response, activeTopology, nil
}
return nil, nil, fmt.Errorf("failed to load topology from all provided masters")
}
func (h *EcRepairHandler) fetchVolumeList(ctx context.Context, address string) (*master_pb.VolumeListResponse, error) {
var lastErr error
for _, candidate := range masterAddressCandidates(address) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
dialCtx, cancelDial := context.WithTimeout(ctx, 5*time.Second)
conn, err := pb.GrpcDial(dialCtx, candidate, false, h.grpcDialOption)
cancelDial()
if err != nil {
lastErr = err
continue
}
client := master_pb.NewSeaweedClient(conn)
callCtx, cancelCall := context.WithTimeout(ctx, 10*time.Second)
response, callErr := client.VolumeList(callCtx, &master_pb.VolumeListRequest{})
cancelCall()
_ = conn.Close()
if callErr == nil {
return response, nil
}
lastErr = callErr
}
if lastErr == nil {
lastErr = fmt.Errorf("no valid master address candidate")
}
return nil, lastErr
}
func deriveEcRepairWorkerConfig(values map[string]*plugin_pb.ConfigValue) *ecRepairWorkerConfig {
return &ecRepairWorkerConfig{
MinIntervalSeconds: int(readInt64Config(values, "min_interval_seconds", 300)),
}
}
func buildEcRepairProposal(candidate *ecrepair.RepairCandidate) (*plugin_pb.JobProposal, error) {
if candidate == nil {
return nil, fmt.Errorf("repair candidate is nil")
}
params := &worker_pb.TaskParams{
VolumeId: candidate.VolumeID,
Collection: candidate.Collection,
}
payload, err := proto.Marshal(params)
if err != nil {
return nil, fmt.Errorf("marshal task params: %w", err)
}
proposalID := fmt.Sprintf("ec-repair-%d-%d", candidate.VolumeID, time.Now().UnixNano())
dedupeKey := fmt.Sprintf("ec_repair:%d", candidate.VolumeID)
if candidate.Collection != "" {
dedupeKey = dedupeKey + ":" + candidate.Collection
}
if candidate.DiskType != "" {
dedupeKey = dedupeKey + ":" + candidate.DiskType
}
summary := fmt.Sprintf("Repair EC volume %d", candidate.VolumeID)
if candidate.Collection != "" {
summary = summary + " (" + candidate.Collection + ")"
}
detail := fmt.Sprintf("missing shards=%d, extra shards=%d, mismatched shards=%d", candidate.MissingShards, candidate.ExtraShards, candidate.MismatchedShards)
return &plugin_pb.JobProposal{
ProposalId: proposalID,
DedupeKey: dedupeKey,
JobType: "ec_repair",
Priority: plugin_pb.JobPriority_JOB_PRIORITY_NORMAL,
Summary: summary,
Detail: detail,
Parameters: map[string]*plugin_pb.ConfigValue{
"task_params_pb": {
Kind: &plugin_pb.ConfigValue_BytesValue{BytesValue: payload},
},
"volume_id": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(candidate.VolumeID)},
},
"collection": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: candidate.Collection},
},
"disk_type": {
Kind: &plugin_pb.ConfigValue_StringValue{StringValue: candidate.DiskType},
},
"missing_shards": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(candidate.MissingShards)},
},
"extra_shards": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(candidate.ExtraShards)},
},
"mismatched_shards": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(candidate.MismatchedShards)},
},
},
Labels: map[string]string{
"task_type": "ec_repair",
"volume_id": fmt.Sprintf("%d", candidate.VolumeID),
"collection": candidate.Collection,
"disk_type": candidate.DiskType,
"missing_shards": fmt.Sprintf("%d", candidate.MissingShards),
"extra_shards": fmt.Sprintf("%d", candidate.ExtraShards),
"mismatched_shards": fmt.Sprintf("%d", candidate.MismatchedShards),
},
}, nil
}
func sendProgress(sender ExecutionSender, job *plugin_pb.JobSpec, percent float64, stage string, message string) error {
if sender == nil || job == nil {
return nil
}
return sender.SendProgress(&plugin_pb.JobProgressUpdate{
JobId: job.JobId,
JobType: job.JobType,
State: plugin_pb.JobState_JOB_STATE_RUNNING,
ProgressPercent: percent,
Stage: stage,
Message: message,
Activities: []*plugin_pb.ActivityEvent{
buildExecutorActivity(stage, message),
},
})
}
+38 -29
View File
@@ -3,7 +3,6 @@ package pluginworker
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@@ -27,10 +26,11 @@ type erasureCodingWorkerConfig struct {
// ErasureCodingHandler is the plugin job handler for erasure coding.
type ErasureCodingHandler struct {
grpcDialOption grpc.DialOption
workingDir string
}
func NewErasureCodingHandler(grpcDialOption grpc.DialOption) *ErasureCodingHandler {
return &ErasureCodingHandler{grpcDialOption: grpcDialOption}
func NewErasureCodingHandler(grpcDialOption grpc.DialOption, workingDir string) *ErasureCodingHandler {
return &ErasureCodingHandler{grpcDialOption: grpcDialOption, workingDir: strings.TrimSpace(workingDir)}
}
func (h *ErasureCodingHandler) Capability() *plugin_pb.JobTypeCapability {
@@ -228,24 +228,21 @@ func (h *ErasureCodingHandler) Detect(
}
clusterInfo := &workertypes.ClusterInfo{ActiveTopology: activeTopology}
results, err := erasurecodingtask.Detection(metrics, clusterInfo, workerConfig.TaskConfig)
maxResults := int(request.MaxResults)
if maxResults < 0 {
maxResults = 0
}
results, hasMore, err := erasurecodingtask.Detection(ctx, metrics, clusterInfo, workerConfig.TaskConfig, maxResults)
if err != nil {
return err
}
if traceErr := emitErasureCodingDetectionDecisionTrace(sender, metrics, workerConfig.TaskConfig, results); traceErr != nil {
if traceErr := emitErasureCodingDetectionDecisionTrace(sender, metrics, workerConfig.TaskConfig, results, maxResults, hasMore); traceErr != nil {
glog.Warningf("Plugin worker failed to emit erasure_coding detection trace: %v", traceErr)
}
maxResults := int(request.MaxResults)
hasMore := false
if maxResults > 0 && len(results) > maxResults {
hasMore = true
results = results[:maxResults]
}
proposals := make([]*plugin_pb.JobProposal, 0, len(results))
for _, result := range results {
proposal, proposalErr := buildErasureCodingProposal(result)
proposal, proposalErr := buildErasureCodingProposal(result, h.workingDir)
if proposalErr != nil {
glog.Warningf("Plugin worker skip invalid erasure_coding proposal: %v", proposalErr)
continue
@@ -273,6 +270,8 @@ func emitErasureCodingDetectionDecisionTrace(
metrics []*workertypes.VolumeHealthMetrics,
taskConfig *erasurecodingtask.Config,
results []*workertypes.TaskDetectionResult,
maxResults int,
hasMore bool,
) error {
if sender == nil || taskConfig == nil {
return nil
@@ -280,15 +279,7 @@ func emitErasureCodingDetectionDecisionTrace(
quietThreshold := time.Duration(taskConfig.QuietForSeconds) * time.Second
minSizeBytes := uint64(taskConfig.MinSizeMB) * 1024 * 1024
allowedCollections := make(map[string]bool)
if strings.TrimSpace(taskConfig.CollectionFilter) != "" {
for _, collection := range strings.Split(taskConfig.CollectionFilter, ",") {
trimmed := strings.TrimSpace(collection)
if trimmed != "" {
allowedCollections[trimmed] = true
}
}
}
allowedCollections := erasurecodingtask.ParseCollectionFilter(taskConfig.CollectionFilter)
volumeGroups := make(map[uint32][]*workertypes.VolumeHealthMetrics)
for _, metric := range metrics {
@@ -341,11 +332,16 @@ func emitErasureCodingDetectionDecisionTrace(
}
totalVolumes := len(metrics)
summarySuffix := ""
if hasMore {
summarySuffix = fmt.Sprintf(" (max_results=%d reached; remaining volumes not evaluated)", maxResults)
}
summaryMessage := ""
if len(results) == 0 {
summaryMessage = fmt.Sprintf(
"EC detection: No tasks created for %d volumes (skipped: %d already EC, %d too small, %d filtered, %d not quiet, %d not full)",
"EC detection: No tasks created for %d volumes%s (skipped: %d already EC, %d too small, %d filtered, %d not quiet, %d not full)",
totalVolumes,
summarySuffix,
skippedAlreadyEC,
skippedTooSmall,
skippedCollectionFilter,
@@ -354,8 +350,9 @@ func emitErasureCodingDetectionDecisionTrace(
)
} else {
summaryMessage = fmt.Sprintf(
"EC detection: Created %d task(s) from %d volumes (skipped: %d already EC, %d too small, %d filtered, %d not quiet, %d not full)",
"EC detection: Created %d task(s)%s from %d volumes (skipped: %d already EC, %d too small, %d filtered, %d not quiet, %d not full)",
len(results),
summarySuffix,
totalVolumes,
skippedAlreadyEC,
skippedTooSmall,
@@ -372,6 +369,12 @@ func emitErasureCodingDetectionDecisionTrace(
"selected_tasks": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(len(results))},
},
"max_results": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(maxResults)},
},
"has_more": {
Kind: &plugin_pb.ConfigValue_BoolValue{BoolValue: hasMore},
},
"skipped_already_ec": {
Kind: &plugin_pb.ConfigValue_Int64Value{Int64Value: int64(skippedAlreadyEC)},
},
@@ -470,7 +473,7 @@ func (h *ErasureCodingHandler) Execute(
return err
}
applyErasureCodingExecutionDefaults(params, request.GetClusterContext())
applyErasureCodingExecutionDefaults(params, request.GetClusterContext(), h.workingDir)
if len(params.Sources) == 0 || strings.TrimSpace(params.Sources[0].Node) == "" {
return fmt.Errorf("erasure coding source node is required")
@@ -607,6 +610,7 @@ func deriveErasureCodingWorkerConfig(values map[string]*plugin_pb.ConfigValue) *
func buildErasureCodingProposal(
result *workertypes.TaskDetectionResult,
baseWorkingDir string,
) (*plugin_pb.JobProposal, error) {
if result == nil {
return nil, fmt.Errorf("task detection result is nil")
@@ -615,7 +619,7 @@ func buildErasureCodingProposal(
return nil, fmt.Errorf("missing typed params for volume %d", result.VolumeID)
}
params := proto.Clone(result.TypedParams).(*worker_pb.TaskParams)
applyErasureCodingExecutionDefaults(params, nil)
applyErasureCodingExecutionDefaults(params, nil, baseWorkingDir)
paramsPayload, err := proto.Marshal(params)
if err != nil {
@@ -766,6 +770,7 @@ func decodeErasureCodingTaskParams(job *plugin_pb.JobSpec) (*worker_pb.TaskParam
func applyErasureCodingExecutionDefaults(
params *worker_pb.TaskParams,
clusterContext *plugin_pb.ClusterContext,
baseWorkingDir string,
) {
if params == nil {
return
@@ -786,7 +791,7 @@ func applyErasureCodingExecutionDefaults(
if ecParams.ParityShards <= 0 {
ecParams.ParityShards = ecstorage.ParityShardsCount
}
ecParams.WorkingDir = defaultErasureCodingWorkingDir()
ecParams.WorkingDir = defaultErasureCodingWorkingDir(baseWorkingDir)
ecParams.CleanupSource = true
if strings.TrimSpace(ecParams.MasterClient) == "" && clusterContext != nil && len(clusterContext.MasterGrpcAddresses) > 0 {
ecParams.MasterClient = clusterContext.MasterGrpcAddresses[0]
@@ -897,6 +902,10 @@ func assignECShardIDs(totalShards int, targetCount int) [][]uint32 {
return assignments
}
func defaultErasureCodingWorkingDir() string {
return filepath.Join(os.TempDir(), "seaweedfs-ec")
func defaultErasureCodingWorkingDir(baseWorkingDir string) string {
dir := strings.TrimSpace(baseWorkingDir)
if dir == "" {
return filepath.Join(".", "seaweedfs-ec")
}
return filepath.Join(dir, "seaweedfs-ec")
}
@@ -179,7 +179,7 @@ func TestBuildErasureCodingProposal(t *testing.T) {
TypedParams: params,
}
proposal, err := buildErasureCodingProposal(result)
proposal, err := buildErasureCodingProposal(result, "")
if err != nil {
t.Fatalf("buildErasureCodingProposal() err = %v", err)
}
@@ -195,7 +195,7 @@ func TestBuildErasureCodingProposal(t *testing.T) {
}
func TestErasureCodingHandlerRejectsUnsupportedJobType(t *testing.T) {
handler := NewErasureCodingHandler(nil)
handler := NewErasureCodingHandler(nil, "")
err := handler.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
JobType: "vacuum",
}, noopDetectionSender{})
@@ -212,7 +212,7 @@ func TestErasureCodingHandlerRejectsUnsupportedJobType(t *testing.T) {
}
func TestErasureCodingHandlerDetectSkipsByMinInterval(t *testing.T) {
handler := NewErasureCodingHandler(nil)
handler := NewErasureCodingHandler(nil, "")
sender := &recordingDetectionSender{}
err := handler.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
JobType: "erasure_coding",
@@ -269,7 +269,7 @@ func TestEmitErasureCodingDetectionDecisionTraceNoTasks(t *testing.T) {
},
}
if err := emitErasureCodingDetectionDecisionTrace(sender, metrics, config, nil); err != nil {
if err := emitErasureCodingDetectionDecisionTrace(sender, metrics, config, nil, 0, false); err != nil {
t.Fatalf("emitErasureCodingDetectionDecisionTrace error: %v", err)
}
if len(sender.events) < 4 {
@@ -288,7 +288,7 @@ func TestEmitErasureCodingDetectionDecisionTraceNoTasks(t *testing.T) {
}
func TestErasureCodingDescriptorOmitsLocalExecutionFields(t *testing.T) {
descriptor := NewErasureCodingHandler(nil).Descriptor()
descriptor := NewErasureCodingHandler(nil, "").Descriptor()
if descriptor == nil || descriptor.WorkerConfigForm == nil {
t.Fatalf("expected worker config form in descriptor")
}
@@ -301,6 +301,7 @@ func TestErasureCodingDescriptorOmitsLocalExecutionFields(t *testing.T) {
}
func TestApplyErasureCodingExecutionDefaultsForcesLocalFields(t *testing.T) {
baseWorkingDir := "/var/lib/seaweedfs-worker"
params := &worker_pb.TaskParams{
TaskId: "ec-test",
VolumeId: 100,
@@ -314,14 +315,14 @@ func TestApplyErasureCodingExecutionDefaultsForcesLocalFields(t *testing.T) {
},
}
applyErasureCodingExecutionDefaults(params, nil)
applyErasureCodingExecutionDefaults(params, nil, baseWorkingDir)
ecParams := params.GetErasureCodingParams()
if ecParams == nil {
t.Fatalf("expected erasure coding params")
}
if ecParams.WorkingDir != defaultErasureCodingWorkingDir() {
t.Fatalf("expected local working_dir %q, got %q", defaultErasureCodingWorkingDir(), ecParams.WorkingDir)
if ecParams.WorkingDir != defaultErasureCodingWorkingDir(baseWorkingDir) {
t.Fatalf("expected local working_dir %q, got %q", defaultErasureCodingWorkingDir(baseWorkingDir), ecParams.WorkingDir)
}
if !ecParams.CleanupSource {
t.Fatalf("expected cleanup_source true")
+17 -5
View File
@@ -22,25 +22,37 @@ import (
)
const (
defaultVacuumTaskBatchSize = int32(1000)
defaultVacuumTaskBatchSize = int32(1000)
DefaultMaxExecutionConcurrency = int32(2)
)
// VacuumHandler is the plugin job handler for vacuum job type.
type VacuumHandler struct {
grpcDialOption grpc.DialOption
grpcDialOption grpc.DialOption
maxExecutionConcurrency int32
}
func NewVacuumHandler(grpcDialOption grpc.DialOption) *VacuumHandler {
return &VacuumHandler{grpcDialOption: grpcDialOption}
// NewVacuumHandler creates a VacuumHandler with the given gRPC dial option and
// maximum execution concurrency. When maxExecutionConcurrency is zero or
// negative, DefaultMaxExecutionConcurrency is used as the fallback.
func NewVacuumHandler(grpcDialOption grpc.DialOption, maxExecutionConcurrency int32) *VacuumHandler {
return &VacuumHandler{grpcDialOption: grpcDialOption, maxExecutionConcurrency: maxExecutionConcurrency}
}
// Capability returns the job type capability for the vacuum handler.
// MaxExecutionConcurrency reflects the value passed at construction time,
// falling back to DefaultMaxExecutionConcurrency when unset.
func (h *VacuumHandler) Capability() *plugin_pb.JobTypeCapability {
maxExec := h.maxExecutionConcurrency
if maxExec <= 0 {
maxExec = DefaultMaxExecutionConcurrency
}
return &plugin_pb.JobTypeCapability{
JobType: "vacuum",
CanDetect: true,
CanExecute: true,
MaxDetectionConcurrency: 1,
MaxExecutionConcurrency: 2,
MaxExecutionConcurrency: maxExec,
DisplayName: "Volume Vacuum",
Description: "Reclaims disk space by removing deleted files from volumes",
}
+2 -2
View File
@@ -141,7 +141,7 @@ func TestShouldSkipDetectionByInterval(t *testing.T) {
}
func TestVacuumHandlerRejectsUnsupportedJobType(t *testing.T) {
handler := NewVacuumHandler(nil)
handler := NewVacuumHandler(nil, 0)
err := handler.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
JobType: "balance",
}, noopDetectionSender{})
@@ -158,7 +158,7 @@ func TestVacuumHandlerRejectsUnsupportedJobType(t *testing.T) {
}
func TestVacuumHandlerDetectSkipsByMinInterval(t *testing.T) {
handler := NewVacuumHandler(nil)
handler := NewVacuumHandler(nil, 0)
sender := &recordingDetectionSender{}
err := handler.Detect(context.Background(), &plugin_pb.RunDetectionRequest{
JobType: "vacuum",
@@ -41,9 +41,6 @@ const (
// with consistent retry configuration across the application.
// This centralizes the retry policy to ensure uniform behavior between
// remote storage and replication sink implementations.
//
// Related: Use DefaultAzureOpTimeout for context.WithTimeout when calling Azure operations
// to ensure the timeout accommodates all retry attempts configured here.
func DefaultAzBlobClientOptions() *azblob.ClientOptions {
return &azblob.ClientOptions{
ClientOptions: azcore.ClientOptions{
@@ -130,6 +127,32 @@ type azureRemoteStorageClient struct {
var _ = remote_storage.RemoteStorageClient(&azureRemoteStorageClient{})
func (az *azureRemoteStorageClient) StatFile(loc *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error) {
key := loc.Path[1:]
ctx, cancel := context.WithTimeout(context.Background(), DefaultAzureOpTimeout)
defer cancel()
resp, err := az.client.ServiceClient().NewContainerClient(loc.Bucket).NewBlobClient(key).GetProperties(ctx, nil)
if err != nil {
if bloberror.HasCode(err, bloberror.BlobNotFound) {
return nil, remote_storage.ErrRemoteObjectNotFound
}
return nil, fmt.Errorf("stat azure %s%s: %w", loc.Bucket, loc.Path, err)
}
remoteEntry = &filer_pb.RemoteEntry{
StorageName: az.conf.Name,
}
if resp.ContentLength != nil {
remoteEntry.RemoteSize = *resp.ContentLength
}
if resp.LastModified != nil {
remoteEntry.RemoteMtime = resp.LastModified.Unix()
}
if resp.ETag != nil {
remoteEntry.RemoteETag = string(*resp.ETag)
}
return remoteEntry, nil
}
func (az *azureRemoteStorageClient) Traverse(loc *remote_pb.RemoteStorageLocation, visitFn remote_storage.VisitFunc) (err error) {
pathKey := loc.Path[1:]
@@ -241,29 +264,7 @@ func (az *azureRemoteStorageClient) WriteFile(loc *remote_pb.RemoteStorageLocati
}
func (az *azureRemoteStorageClient) readFileRemoteEntry(loc *remote_pb.RemoteStorageLocation) (*filer_pb.RemoteEntry, error) {
key := loc.Path[1:]
blobClient := az.client.ServiceClient().NewContainerClient(loc.Bucket).NewBlockBlobClient(key)
props, err := blobClient.GetProperties(context.Background(), nil)
if err != nil {
return nil, err
}
remoteEntry := &filer_pb.RemoteEntry{
StorageName: az.conf.Name,
}
if props.LastModified != nil {
remoteEntry.RemoteMtime = props.LastModified.Unix()
}
if props.ContentLength != nil {
remoteEntry.RemoteSize = *props.ContentLength
}
if props.ETag != nil {
remoteEntry.RemoteETag = string(*props.ETag)
}
return remoteEntry, nil
return az.StatFile(loc)
}
func toMetadata(attributes map[string][]byte) map[string]*string {
@@ -10,7 +10,9 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/require"
)
// TestAzureStorageClientBasic tests basic Azure storage client operations
@@ -378,3 +380,12 @@ func TestAzureStorageClientErrors(t *testing.T) {
t.Log("Expected error with invalid credentials on ReadFile, but got none (might be cached)")
}
}
func TestAzureRemoteStorageClientImplementsInterface(t *testing.T) {
var _ remote_storage.RemoteStorageClient = (*azureRemoteStorageClient)(nil)
}
func TestAzureErrRemoteObjectNotFoundIsAccessible(t *testing.T) {
require.Error(t, remote_storage.ErrRemoteObjectNotFound)
require.Equal(t, "remote object not found", remote_storage.ErrRemoteObjectNotFound.Error())
}
+25 -14
View File
@@ -2,11 +2,13 @@ package gcs
import (
"context"
"errors"
"fmt"
"io"
"os"
"reflect"
"strings"
"time"
"cloud.google.com/go/storage"
"github.com/seaweedfs/seaweedfs/weed/glog"
@@ -126,6 +128,28 @@ func (gcs *gcsRemoteStorageClient) Traverse(loc *remote_pb.RemoteStorageLocation
}
return
}
const defaultGCSOpTimeout = 30 * time.Second
func (gcs *gcsRemoteStorageClient) StatFile(loc *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error) {
key := loc.Path[1:]
ctx, cancel := context.WithTimeout(context.Background(), defaultGCSOpTimeout)
defer cancel()
attr, err := gcs.client.Bucket(loc.Bucket).Object(key).Attrs(ctx)
if err != nil {
if errors.Is(err, storage.ErrObjectNotExist) {
return nil, remote_storage.ErrRemoteObjectNotFound
}
return nil, fmt.Errorf("stat gcs %s%s: %w", loc.Bucket, loc.Path, err)
}
return &filer_pb.RemoteEntry{
StorageName: gcs.conf.Name,
RemoteMtime: attr.Updated.Unix(),
RemoteSize: attr.Size,
RemoteETag: attr.Etag,
}, nil
}
func (gcs *gcsRemoteStorageClient) ReadFile(loc *remote_pb.RemoteStorageLocation, offset int64, size int64) (data []byte, err error) {
key := loc.Path[1:]
@@ -170,20 +194,7 @@ func (gcs *gcsRemoteStorageClient) WriteFile(loc *remote_pb.RemoteStorageLocatio
}
func (gcs *gcsRemoteStorageClient) readFileRemoteEntry(loc *remote_pb.RemoteStorageLocation) (*filer_pb.RemoteEntry, error) {
key := loc.Path[1:]
attr, err := gcs.client.Bucket(loc.Bucket).Object(key).Attrs(context.Background())
if err != nil {
return nil, err
}
return &filer_pb.RemoteEntry{
RemoteMtime: attr.Updated.Unix(),
RemoteSize: attr.Size,
RemoteETag: attr.Etag,
StorageName: gcs.conf.Name,
}, nil
return gcs.StatFile(loc)
}
func toMetadata(attributes map[string][]byte) map[string]string {
@@ -0,0 +1,17 @@
package gcs
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
"github.com/stretchr/testify/require"
)
func TestGCSRemoteStorageClientImplementsInterface(t *testing.T) {
var _ remote_storage.RemoteStorageClient = (*gcsRemoteStorageClient)(nil)
}
func TestGCSErrRemoteObjectNotFoundIsAccessible(t *testing.T) {
require.Error(t, remote_storage.ErrRemoteObjectNotFound)
require.Equal(t, "remote object not found", remote_storage.ErrRemoteObjectNotFound.Error())
}
+5
View File
@@ -1,6 +1,7 @@
package remote_storage
import (
"errors"
"fmt"
"io"
"sort"
@@ -69,8 +70,12 @@ type Bucket struct {
CreatedAt time.Time
}
// ErrRemoteObjectNotFound is returned by StatFile when the object does not exist in the remote storage backend.
var ErrRemoteObjectNotFound = errors.New("remote object not found")
type RemoteStorageClient interface {
Traverse(loc *remote_pb.RemoteStorageLocation, visitFn VisitFunc) error
StatFile(loc *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error)
ReadFile(loc *remote_pb.RemoteStorageLocation, offset int64, size int64) (data []byte, err error)
WriteDirectory(loc *remote_pb.RemoteStorageLocation, entry *filer_pb.Entry) (err error)
RemoveDirectory(loc *remote_pb.RemoteStorageLocation) (err error)
+30 -15
View File
@@ -3,9 +3,11 @@ package s3
import (
"fmt"
"io"
"net/http"
"reflect"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/aws/session"
@@ -119,6 +121,33 @@ func (s *s3RemoteStorageClient) Traverse(remote *remote_pb.RemoteStorageLocation
}
return
}
func (s *s3RemoteStorageClient) StatFile(loc *remote_pb.RemoteStorageLocation) (remoteEntry *filer_pb.RemoteEntry, err error) {
resp, err := s.conn.HeadObject(&s3.HeadObjectInput{
Bucket: aws.String(loc.Bucket),
Key: aws.String(loc.Path[1:]),
})
if err != nil {
if reqErr, ok := err.(awserr.RequestFailure); ok && reqErr.StatusCode() == http.StatusNotFound {
return nil, remote_storage.ErrRemoteObjectNotFound
}
return nil, fmt.Errorf("stat s3 %s%s: %w", loc.Bucket, loc.Path, err)
}
remoteEntry = &filer_pb.RemoteEntry{
StorageName: s.conf.Name,
}
if resp.ContentLength != nil {
remoteEntry.RemoteSize = *resp.ContentLength
}
if resp.LastModified != nil {
remoteEntry.RemoteMtime = resp.LastModified.Unix()
}
if resp.ETag != nil {
remoteEntry.RemoteETag = *resp.ETag
}
return remoteEntry, nil
}
func (s *s3RemoteStorageClient) ReadFile(loc *remote_pb.RemoteStorageLocation, offset int64, size int64) (data []byte, err error) {
downloader := s3manager.NewDownloaderWithClient(s.conn, func(u *s3manager.Downloader) {
u.PartSize = int64(4 * 1024 * 1024)
@@ -208,21 +237,7 @@ func toTagging(attributes map[string][]byte) *s3.Tagging {
}
func (s *s3RemoteStorageClient) readFileRemoteEntry(loc *remote_pb.RemoteStorageLocation) (*filer_pb.RemoteEntry, error) {
resp, err := s.conn.HeadObject(&s3.HeadObjectInput{
Bucket: aws.String(loc.Bucket),
Key: aws.String(loc.Path[1:]),
})
if err != nil {
return nil, err
}
return &filer_pb.RemoteEntry{
RemoteMtime: resp.LastModified.Unix(),
RemoteSize: *resp.ContentLength,
RemoteETag: *resp.ETag,
StorageName: s.conf.Name,
}, nil
return s.StatFile(loc)
}
func (s *s3RemoteStorageClient) UpdateFileMetadata(loc *remote_pb.RemoteStorageLocation, oldEntry *filer_pb.Entry, newEntry *filer_pb.Entry) (err error) {
@@ -6,6 +6,7 @@ import (
"github.com/aws/aws-sdk-go/aws/credentials"
awss3 "github.com/aws/aws-sdk-go/service/s3"
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
"github.com/stretchr/testify/require"
)
@@ -55,3 +56,12 @@ func TestS3MakeUsesStaticCredentialsWhenKeysAreProvided(t *testing.T) {
require.Equal(t, conf.S3AccessKey, credValue.AccessKeyID)
require.Equal(t, conf.S3SecretKey, credValue.SecretAccessKey)
}
func TestS3RemoteStorageClientImplementsInterface(t *testing.T) {
var _ remote_storage.RemoteStorageClient = (*s3RemoteStorageClient)(nil)
}
func TestS3ErrRemoteObjectNotFoundIsAccessible(t *testing.T) {
require.Error(t, remote_storage.ErrRemoteObjectNotFound)
require.Equal(t, "remote object not found", remote_storage.ErrRemoteObjectNotFound.Error())
}

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