Compare commits

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

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

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

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-16 01:52:08 -08:00
56 changed files with 473 additions and 15046 deletions
@@ -9,7 +9,6 @@ on:
- 'weed/storage/**'
- 'weed/pb/volume_server.proto'
- 'weed/pb/volume_server_pb/**'
- 'rust/volume_server/**'
- '.github/workflows/volume-server-integration-tests.yml'
push:
branches: [ master, main ]
@@ -19,7 +18,6 @@ on:
- 'weed/storage/**'
- 'weed/pb/volume_server.proto'
- 'weed/pb/volume_server_pb/**'
- 'rust/volume_server/**'
- '.github/workflows/volume-server-integration-tests.yml'
concurrency:
@@ -122,49 +120,3 @@ jobs:
echo "## Volume Server Integration Test Summary (${{ matrix.test-type }} - Shard ${{ matrix.shard }})" >> "$GITHUB_STEP_SUMMARY"
echo "- Suite: test/volume_server/${{ matrix.test-type }} (Pattern: ${TEST_PATTERN})" >> "$GITHUB_STEP_SUMMARY"
echo "- Command: go test -v -count=1 -timeout=${{ env.TEST_TIMEOUT }} ./test/volume_server/${{ matrix.test-type }}/... -run \"${TEST_PATTERN}\"" >> "$GITHUB_STEP_SUMMARY"
volume-server-rust-smoke:
name: Volume Server Rust Smoke
runs-on: ubuntu-22.04
timeout-minutes: 40
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go ${{ env.GO_VERSION }}
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Build SeaweedFS binary
run: |
cd weed
go build -o weed .
chmod +x weed
./weed version
- name: Run Rust-mode HTTP smoke tests
env:
WEED_BINARY: ${{ github.workspace }}/weed/weed
VOLUME_SERVER_IMPL: rust
run: |
go test -v -count=1 -timeout=25m ./test/volume_server/http/... -run "TestAdminStatusAndHealthz|TestUploadReadRangeHeadDeleteRoundTrip"
- name: Run Rust-mode gRPC smoke tests
env:
WEED_BINARY: ${{ github.workspace }}/weed/weed
VOLUME_SERVER_IMPL: rust
run: |
go test -v -count=1 -timeout=25m ./test/volume_server/grpc/... -run "TestStateAndStatusRPCs|TestVolumeSyncStatusAndReadVolumeFileStatus"
- name: Rust smoke summary
if: always()
run: |
echo "## Volume Server Rust Smoke Summary" >> "$GITHUB_STEP_SUMMARY"
echo "- Mode: Go master + Rust volume server launcher (VOLUME_SERVER_IMPL=rust)" >> "$GITHUB_STEP_SUMMARY"
echo "- HTTP Command: go test -v -count=1 -timeout=25m ./test/volume_server/http/... -run \"TestAdminStatusAndHealthz|TestUploadReadRangeHeadDeleteRoundTrip\"" >> "$GITHUB_STEP_SUMMARY"
echo "- gRPC Command: go test -v -count=1 -timeout=25m ./test/volume_server/grpc/... -run \"TestStateAndStatusRPCs|TestVolumeSyncStatusAndReadVolumeFileStatus\"" >> "$GITHUB_STEP_SUMMARY"
-7
View File
@@ -1,7 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "weed-volume-rs"
version = "0.1.0"
-6
View File
@@ -1,6 +0,0 @@
[package]
name = "weed-volume-rs"
version = "0.1.0"
edition = "2021"
[dependencies]
-61
View File
@@ -1,61 +0,0 @@
# Rust Volume Server Rewrite Dev Plan
## Goal
Build a Rust implementation of SeaweedFS volume server that is behavior-compatible with the current Go implementation and can pass the existing integration suites under `/Users/chris/dev/seaweedfs2/test/volume_server/http` and `/Users/chris/dev/seaweedfs2/test/volume_server/grpc`.
## Compatibility Target
- CLI compatibility for volume-server startup flags used by integration harness.
- HTTP and gRPC behavioral parity for tested paths.
- Drop-in process integration with current Go master in transition phases.
## Phases
### Phase 0: Bootstrap and Harness Integration
- [x] Add Rust volume-server crate.
- [x] Implement Rust launcher that can run as a volume-server process entrypoint.
- [x] Add integration harness switches so tests can run with:
- Go master + Go volume (default)
- Go master + Rust volume (`VOLUME_SERVER_IMPL=rust` or `VOLUME_SERVER_BINARY=...`)
- [ ] Add CI smoke coverage for Rust volume-server mode.
### Phase 1: Native Rust Control Plane Skeleton
- [ ] Native Rust HTTP server with admin endpoints:
- [ ] `GET /status`
- [ ] `GET /healthz`
- [ ] static/UI endpoints used by tests
- [ ] Native Rust gRPC server with basic lifecycle/state RPCs:
- [ ] `GetState`, `SetState`, `VolumeServerStatus`, `Ping`, `VolumeServerLeave`
- [ ] Flag/config parser parity for currently exercised startup options.
### Phase 2: Native Data Path (HTTP + core gRPC)
- [ ] HTTP read/write/delete parity:
- [ ] path variants, conditional headers, ranges, auth, throttling
- [ ] chunk manifest read/delete behavior
- [ ] image and compression transform branches
- [ ] gRPC data RPC parity:
- [ ] `ReadNeedleBlob`, `ReadNeedleMeta`, `WriteNeedleBlob`
- [ ] `BatchDelete`, `ReadAllNeedles`
- [ ] copy/receive/sync baseline
### Phase 3: Advanced gRPC Surface
- [ ] Vacuum RPC family.
- [ ] Tail sender/receiver.
- [ ] Erasure coding family.
- [ ] Tiering/remote fetch family.
- [ ] Query/Scrub family.
### Phase 4: Hardening and Cutover
- [ ] Determinism/flake hardening in integration runtime.
- [ ] Performance and resource-baseline checks versus Go.
- [ ] Optional dual-run diff tooling for payload/header parity.
- [ ] Default harness/CI mode switch to Rust volume server once parity threshold is met.
## Integration Test Mapping
- HTTP suite: `/Users/chris/dev/seaweedfs2/test/volume_server/http`
- gRPC suite: `/Users/chris/dev/seaweedfs2/test/volume_server/grpc`
- Harness: `/Users/chris/dev/seaweedfs2/test/volume_server/framework`
## Progress Log
- Date: 2026-02-15
- Change: Created Rust volume-server crate (`weed-volume-rs`) as compatibility launcher and wired harness binary selection (`VOLUME_SERVER_IMPL`/`VOLUME_SERVER_BINARY`).
- Validation: Rust-mode conformance smoke execution pending CI and local subset runs.
-177
View File
@@ -1,177 +0,0 @@
use std::env;
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::{Command, ExitCode};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("weed-volume-rs: {err}");
ExitCode::from(1)
}
}
}
fn run() -> Result<(), String> {
let args: Vec<String> = env::args().skip(1).collect();
if args.iter().any(|a| a == "-h" || a == "--help") {
print_help();
return Ok(());
}
if args.iter().any(|a| a == "--version") {
println!("weed-volume-rs 0.1.0");
return Ok(());
}
let mut forwarded = Vec::<String>::new();
let has_volume_subcommand = args.iter().any(|a| a == "volume");
if has_volume_subcommand {
forwarded.extend(args);
} else {
forwarded.push("volume".to_string());
forwarded.extend(args);
}
let weed_binary = resolve_weed_binary()?;
#[cfg(unix)]
{
let exec_err = Command::new(&weed_binary).args(&forwarded).exec();
return Err(format!(
"exec {} failed: {}",
weed_binary.display(),
exec_err
));
}
#[cfg(not(unix))]
{
let status = Command::new(&weed_binary)
.args(&forwarded)
.status()
.map_err(|e| format!("spawn {} failed: {}", weed_binary.display(), e))?;
if status.success() {
return Ok(());
}
return Err(format!(
"delegated process {} exited with status {}",
weed_binary.display(),
status
));
}
}
fn print_help() {
println!("weed-volume-rs");
println!();
println!("Rust compatibility launcher for SeaweedFS volume server.");
println!("It forwards all volume-server flags to the Go weed binary.");
println!();
println!("Examples:");
println!(" weed-volume-rs -ip=127.0.0.1 -port=8080 -master=127.0.0.1:9333 ...");
println!(" weed-volume-rs volume -ip=127.0.0.1 -port=8080 -master=127.0.0.1:9333 ...");
}
fn resolve_weed_binary() -> Result<PathBuf, String> {
if let Some(from_env) = env::var_os("WEED_BINARY") {
let path = PathBuf::from(from_env);
if is_executable_file(&path) {
return Ok(path);
}
return Err(format!(
"WEED_BINARY is set but not executable: {}",
path.display()
));
}
let repo_root = resolve_repo_root()?;
let local_weed = repo_root.join("weed").join("weed");
if is_executable_file(&local_weed) {
return Ok(local_weed);
}
let bin_dir = env::temp_dir().join("seaweedfs_volume_server_it_bin");
std::fs::create_dir_all(&bin_dir)
.map_err(|e| format!("create binary directory {}: {}", bin_dir.display(), e))?;
let bin_path = bin_dir.join("weed");
if is_executable_file(&bin_path) {
return Ok(bin_path);
}
build_weed_binary(&repo_root, &bin_path)?;
if !is_executable_file(&bin_path) {
return Err(format!(
"built weed binary is not executable: {}",
bin_path.display()
));
}
Ok(bin_path)
}
fn resolve_repo_root() -> Result<PathBuf, String> {
if let Some(from_env) = env::var_os("SEAWEEDFS_REPO_ROOT") {
return Ok(PathBuf::from(from_env));
}
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let repo_root = manifest_dir
.parent()
.and_then(|p| p.parent())
.ok_or_else(|| "unable to detect repository root from CARGO_MANIFEST_DIR".to_string())?;
Ok(repo_root.to_path_buf())
}
fn build_weed_binary(repo_root: &PathBuf, output_path: &PathBuf) -> Result<(), String> {
let mut cmd = Command::new("go");
cmd.arg("build").arg("-o").arg(output_path).arg(".");
cmd.current_dir(repo_root.join("weed"));
let output = cmd
.output()
.map_err(|e| format!("failed to execute go build: {}", e))?;
if output.status.success() {
return Ok(());
}
let mut msg = String::new();
msg.push_str("go build failed");
if !output.stdout.is_empty() {
msg.push_str("\nstdout:\n");
msg.push_str(&String::from_utf8_lossy(&output.stdout));
}
if !output.stderr.is_empty() {
msg.push_str("\nstderr:\n");
msg.push_str(&String::from_utf8_lossy(&output.stderr));
}
Err(msg)
}
fn is_executable_file(path: &PathBuf) -> bool {
let metadata = match std::fs::metadata(path) {
Ok(v) => v,
Err(_) => return false,
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
#[allow(dead_code)]
fn _collect_os_args() -> Vec<OsString> {
env::args_os().collect()
}
@@ -77,14 +77,14 @@ func TestObjectLockValidation(t *testing.T) {
require.NoError(t, err, "Setting Object Lock retention should succeed")
t.Log(" Object Lock retention applied successfully")
// Verify retention allows simple DELETE (creates delete marker) but blocks version deletion
// AWS S3 behavior: Simple DELETE (without version ID) is ALWAYS allowed and creates delete marker
// Verify retention blocks simple DELETE (COMPLIANCE mode is strict WORM)
// AWS S3 behavior: Simple DELETE (without version ID) is blocked by COMPLIANCE retention
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
require.NoError(t, err, "Simple DELETE should succeed and create delete marker (AWS S3 behavior)")
t.Log(" Simple DELETE succeeded (creates delete marker - correct AWS behavior)")
require.Error(t, err, "Simple DELETE should be blocked by COMPLIANCE retention (AWS S3 behavior)")
t.Log(" Simple DELETE correctly blocked by COMPLIANCE retention")
// Now verify that DELETE with version ID is properly blocked by retention
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
+7 -7
View File
@@ -329,12 +329,12 @@ func TestRetentionModeCompliance(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, types.ObjectLockRetentionModeCompliance, retentionResp.Retention.Mode)
// Try simple DELETE - should succeed and create delete marker (AWS S3 behavior)
// Try simple DELETE - should fail for COMPLIANCE mode (strict WORM)
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
require.NoError(t, err, "Simple DELETE should succeed and create delete marker")
require.Error(t, err, "Simple DELETE should be blocked by COMPLIANCE retention")
// Try DELETE with version ID - should fail for COMPLIANCE mode
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
@@ -388,12 +388,12 @@ func TestLegalHoldWorkflow(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, types.ObjectLockLegalHoldStatusOn, legalHoldResp.LegalHold.Status)
// Try simple DELETE - should succeed and create delete marker (AWS S3 behavior)
// Try simple DELETE - should fail due to legal hold
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
require.NoError(t, err, "Simple DELETE should succeed and create delete marker")
require.Error(t, err, "Simple DELETE should be blocked by legal hold")
// Try DELETE with version ID - should fail due to legal hold
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
@@ -591,12 +591,12 @@ func TestRetentionAndLegalHoldCombination(t *testing.T) {
})
require.NoError(t, err)
// Try simple DELETE - should succeed and create delete marker (AWS S3 behavior)
// Try simple DELETE - should fail due to legal hold + COMPLIANCE retention
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
require.NoError(t, err, "Simple DELETE should succeed and create delete marker")
require.Error(t, err, "Simple DELETE should be blocked by legal hold")
// Try DELETE with version ID and bypass - should still fail due to legal hold
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
@@ -607,7 +607,7 @@ func TestRetentionAndLegalHoldCombination(t *testing.T) {
})
require.Error(t, err, "Legal hold should prevent deletion even with governance bypass")
// Remove legal hold (must specify version ID since latest version is now delete marker)
// Remove legal hold (must specify version ID)
_, err = client.PutObjectLegalHold(context.TODO(), &s3.PutObjectLegalHoldInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
@@ -42,12 +42,12 @@ func TestWORMRetentionIntegration(t *testing.T) {
})
require.NoError(t, err)
// Try simple DELETE - should succeed and create delete marker (AWS S3 behavior)
// Try simple DELETE - should fail due to GOVERNANCE retention
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
require.NoError(t, err, "Simple DELETE should succeed and create delete marker")
require.Error(t, err, "Simple DELETE should be blocked by GOVERNANCE retention")
// Try DELETE with version ID - should fail due to GOVERNANCE retention
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
@@ -325,12 +325,12 @@ func TestRetentionWithMultipartUpload(t *testing.T) {
})
require.NoError(t, err)
// Try simple DELETE - should succeed and create delete marker (AWS S3 behavior)
// Try simple DELETE - should fail due to GOVERNANCE retention
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
require.NoError(t, err, "Simple DELETE should succeed and create delete marker")
require.Error(t, err, "Simple DELETE should be blocked by GOVERNANCE retention")
// Try DELETE with version ID - should fail due to GOVERNANCE retention
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
-7
View File
@@ -1127,10 +1127,3 @@ Update this section during implementation:
- Profiles covered: P1.
- Gaps introduced/remaining: `499` cancellation and transport-interruption branches remain pending.
- Commit: `d1e5f390a`
- Date: 2026-02-15
- Change: Added Rust volume-server migration bootstrap wiring.
- APIs covered: Harness now supports Go master + selectable volume binary (`VOLUME_SERVER_IMPL=rust` or `VOLUME_SERVER_BINARY`), with Rust-mode smoke coverage for representative HTTP/gRPC integration tests.
- Profiles covered: P1 smoke and default Go matrix unchanged.
- Gaps introduced/remaining: native Rust handler implementations are tracked in `/Users/chris/dev/seaweedfs2/rust/volume_server/DEV_PLAN.md`; current Rust mode is a compatibility launcher phase.
- Commit: pending
+1 -4
View File
@@ -1,10 +1,7 @@
.PHONY: test-volume-server test-volume-server-short test-volume-server-rust-smoke
.PHONY: test-volume-server test-volume-server-short
test-volume-server:
go test ./test/volume_server/... -v
test-volume-server-short:
go test ./test/volume_server/... -short -v
test-volume-server-rust-smoke:
VOLUME_SERVER_IMPL=rust go test ./test/volume_server/http ./test/volume_server/grpc -run 'TestAdminStatusAndHealthz|TestUploadReadRangeHeadDeleteRoundTrip|TestStateAndStatusRPCs|TestVolumeSyncStatusAndReadVolumeFileStatus' -v
-3
View File
@@ -15,9 +15,6 @@ If a `weed` binary is not found, the harness will build one automatically.
## Optional environment variables
- `WEED_BINARY`: explicit path to the `weed` executable (disables auto-build).
- `VOLUME_SERVER_IMPL`: select volume-server implementation (`go` default, `rust` for Rust launcher mode).
- `VOLUME_SERVER_BINARY`: explicit path to the volume-server executable (overrides `VOLUME_SERVER_IMPL`).
- `VOLUME_SERVER_RUST_REBUILD=1`: force rebuild of Rust volume-server binary in Rust mode.
- `VOLUME_SERVER_IT_KEEP_LOGS=1`: keep temporary test directories and process logs.
## Current scope (Phase 0)
+18 -91
View File
@@ -32,12 +32,11 @@ type Cluster struct {
testingTB testing.TB
profile matrix.Profile
weedBinary string
volumeBinary string
baseDir string
configDir string
logsDir string
keepLogs bool
weedBinary string
baseDir string
configDir string
logsDir string
keepLogs bool
masterPort int
masterGrpcPort int
@@ -55,9 +54,9 @@ type Cluster struct {
func StartSingleVolumeCluster(t testing.TB, profile matrix.Profile) *Cluster {
t.Helper()
weedBinary, volumeBinary, err := FindOrBuildServerBinaries()
weedBinary, err := FindOrBuildWeedBinary()
if err != nil {
t.Fatalf("resolve server binaries: %v", err)
t.Fatalf("resolve weed binary: %v", err)
}
baseDir, keepLogs, err := newWorkDir()
@@ -93,7 +92,6 @@ func StartSingleVolumeCluster(t testing.TB, profile matrix.Profile) *Cluster {
testingTB: t,
profile: profile,
weedBinary: weedBinary,
volumeBinary: volumeBinary,
baseDir: baseDir,
configDir: configDir,
logsDir: logsDir,
@@ -209,7 +207,7 @@ func (c *Cluster) startVolume(dataDir string) error {
args = append(args, "-inflightDownloadDataTimeout="+c.profile.InflightDownloadTimeout.String())
}
c.volumeCmd = exec.Command(c.volumeBinary, args...)
c.volumeCmd = exec.Command(c.weedBinary, args...)
c.volumeCmd.Dir = c.baseDir
c.volumeCmd.Stdout = logFile
c.volumeCmd.Stderr = logFile
@@ -343,12 +341,17 @@ func FindOrBuildWeedBinary() (string, error) {
return "", fmt.Errorf("WEED_BINARY is set but not executable: %s", fromEnv)
}
repoRoot, err := detectRepoRoot()
if err != nil {
return "", err
repoRoot := ""
if _, file, _, ok := runtime.Caller(0); ok {
repoRoot = filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
candidate := filepath.Join(repoRoot, "weed", "weed")
if isExecutableFile(candidate) {
return candidate, nil
}
}
if candidate := filepath.Join(repoRoot, "weed", "weed"); isExecutableFile(candidate) {
return candidate, nil
if repoRoot == "" {
return "", errors.New("unable to detect repository root")
}
binDir := filepath.Join(os.TempDir(), "seaweedfs_volume_server_it_bin")
@@ -374,82 +377,6 @@ func FindOrBuildWeedBinary() (string, error) {
return binPath, nil
}
// FindOrBuildServerBinaries returns master and volume executables.
// Master always runs from the Go weed binary; volume can be switched via env.
func FindOrBuildServerBinaries() (masterBinary string, volumeBinary string, err error) {
masterBinary, err = FindOrBuildWeedBinary()
if err != nil {
return "", "", err
}
volumeBinary, err = FindOrBuildVolumeServerBinary(masterBinary)
if err != nil {
return "", "", err
}
return masterBinary, volumeBinary, nil
}
// FindOrBuildVolumeServerBinary resolves the executable used for volume-server processes.
//
// Behavior:
// - `VOLUME_SERVER_BINARY=/path/to/bin`: use explicit executable path.
// - `VOLUME_SERVER_IMPL=rust`: build/use Rust volume server launcher.
// - default: use the same Go `weed` binary.
func FindOrBuildVolumeServerBinary(defaultBinary string) (string, error) {
if fromEnv := os.Getenv("VOLUME_SERVER_BINARY"); fromEnv != "" {
if isExecutableFile(fromEnv) {
return fromEnv, nil
}
return "", fmt.Errorf("VOLUME_SERVER_BINARY is set but not executable: %s", fromEnv)
}
impl := strings.ToLower(strings.TrimSpace(os.Getenv("VOLUME_SERVER_IMPL")))
if impl == "" || impl == "go" {
return defaultBinary, nil
}
if impl != "rust" {
return "", fmt.Errorf("unsupported VOLUME_SERVER_IMPL %q (supported: go, rust)", impl)
}
repoRoot, err := detectRepoRoot()
if err != nil {
return "", err
}
return FindOrBuildRustVolumeServerBinary(repoRoot)
}
// FindOrBuildRustVolumeServerBinary builds the Rust volume server launcher when needed.
func FindOrBuildRustVolumeServerBinary(repoRoot string) (string, error) {
manifestPath := filepath.Join(repoRoot, "rust", "volume_server", "Cargo.toml")
if _, err := os.Stat(manifestPath); err != nil {
return "", fmt.Errorf("rust volume server manifest not found at %s: %w", manifestPath, err)
}
targetDir := filepath.Join(os.TempDir(), "seaweedfs_volume_server_it_rust_target")
binPath := filepath.Join(targetDir, "release", "weed-volume-rs")
if isExecutableFile(binPath) && os.Getenv("VOLUME_SERVER_RUST_REBUILD") != "1" {
return binPath, nil
}
cmd := exec.Command("cargo", "build", "--release", "--manifest-path", manifestPath, "--target-dir", targetDir)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("build rust volume server binary: %w\n%s", err, out.String())
}
if !isExecutableFile(binPath) {
return "", fmt.Errorf("built rust volume server binary is not executable: %s", binPath)
}
return binPath, nil
}
func detectRepoRoot() (string, error) {
if _, file, _, ok := runtime.Caller(0); ok {
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..")), nil
}
return "", errors.New("unable to detect repository root")
}
func isExecutableFile(path string) bool {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
+22 -24
View File
@@ -17,12 +17,11 @@ type DualVolumeCluster struct {
testingTB testing.TB
profile matrix.Profile
weedBinary string
volumeBinary string
baseDir string
configDir string
logsDir string
keepLogs bool
weedBinary string
baseDir string
configDir string
logsDir string
keepLogs bool
masterPort int
masterGrpcPort int
@@ -34,7 +33,7 @@ type DualVolumeCluster struct {
volumeGrpcPort1 int
volumePubPort1 int
masterCmd *exec.Cmd
masterCmd *exec.Cmd
volumeCmd0 *exec.Cmd
volumeCmd1 *exec.Cmd
@@ -44,9 +43,9 @@ type DualVolumeCluster struct {
func StartDualVolumeCluster(t testing.TB, profile matrix.Profile) *DualVolumeCluster {
t.Helper()
weedBinary, volumeBinary, err := FindOrBuildServerBinaries()
weedBinary, err := FindOrBuildWeedBinary()
if err != nil {
t.Fatalf("resolve server binaries: %v", err)
t.Fatalf("resolve weed binary: %v", err)
}
baseDir, keepLogs, err := newWorkDir()
@@ -80,22 +79,21 @@ func StartDualVolumeCluster(t testing.TB, profile matrix.Profile) *DualVolumeClu
}
c := &DualVolumeCluster{
testingTB: t,
profile: profile,
weedBinary: weedBinary,
volumeBinary: volumeBinary,
baseDir: baseDir,
configDir: configDir,
logsDir: logsDir,
keepLogs: keepLogs,
masterPort: masterPort,
masterGrpcPort: masterGrpcPort,
volumePort0: ports[0],
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],
volumePubPort0: ports[0],
volumePort1: ports[2],
volumeGrpcPort1: ports[3],
volumePubPort1: ports[2],
volumePubPort1: ports[2],
}
if profile.SplitPublicPort {
c.volumePubPort0 = ports[4]
@@ -229,7 +227,7 @@ func (c *DualVolumeCluster) startVolume(index int, dataDir string) error {
args = append(args, "-inflightDownloadDataTimeout="+c.profile.InflightDownloadTimeout.String())
}
cmd := exec.Command(c.volumeBinary, args...)
cmd := exec.Command(c.weedBinary, args...)
cmd.Dir = c.baseDir
cmd.Stdout = logFile
cmd.Stderr = logFile
-4
View File
@@ -10,7 +10,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
"github.com/seaweedfs/seaweedfs/weed/admin/plugin"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/glog"
@@ -109,9 +108,6 @@ type AdminServer struct {
// Worker gRPC server
workerGrpcServer *WorkerGrpcServer
// Plugin system manager
PluginManager *plugin.Manager
// Collection statistics caching
collectionStatsCache map[string]collectionStats
lastCollectionStatsUpdate time.Time
-21
View File
@@ -26,7 +26,6 @@ type AdminHandlers struct {
maintenanceHandlers *MaintenanceHandlers
mqHandlers *MessageQueueHandlers
serviceAccountHandlers *ServiceAccountHandlers
pluginHandlers *PluginHandlers
}
// NewAdminHandlers creates a new instance of AdminHandlers
@@ -39,7 +38,6 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
maintenanceHandlers := NewMaintenanceHandlers(adminServer)
mqHandlers := NewMessageQueueHandlers(adminServer)
serviceAccountHandlers := NewServiceAccountHandlers(adminServer)
pluginHandlers := NewPluginHandlers(adminServer)
return &AdminHandlers{
adminServer: adminServer,
authHandlers: authHandlers,
@@ -50,7 +48,6 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers {
maintenanceHandlers: maintenanceHandlers,
mqHandlers: mqHandlers,
serviceAccountHandlers: serviceAccountHandlers,
pluginHandlers: pluginHandlers,
}
}
@@ -131,11 +128,6 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
protected.POST("/maintenance/config/:taskType", dash.RequireWriteAccess(), h.maintenanceHandlers.UpdateTaskConfig)
protected.GET("/maintenance/tasks/:id", h.maintenanceHandlers.ShowTaskDetail)
// Plugin system routes
protected.GET("/plugins", h.pluginHandlers.ShowPlugins)
protected.GET("/plugins/jobs/:jobType", h.pluginHandlers.ShowPluginJobs)
protected.GET("/plugins/config/:jobType", h.pluginHandlers.ShowPluginConfig)
// API routes for AJAX calls
api := r.Group("/api")
api.Use(dash.RequireAuthAPI()) // Use API-specific auth middleware
@@ -250,19 +242,6 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser,
maintenanceApi.PUT("/config", dash.RequireWriteAccess(), h.adminServer.UpdateMaintenanceConfigAPI)
}
// Plugin system API routes
pluginApi := api.Group("/plugin")
{
pluginApi.GET("/list", h.pluginHandlers.ListPluginsAPI)
pluginApi.GET("/jobs/:jobType", h.pluginHandlers.ListJobsAPI)
pluginApi.GET("/config/:jobType", h.pluginHandlers.GetConfigAPI)
pluginApi.POST("/config/:jobType/apply", dash.RequireWriteAccess(), h.pluginHandlers.SaveConfigAPI)
pluginApi.GET("/detection/history/:jobType", h.pluginHandlers.GetDetectionHistoryAPI)
pluginApi.GET("/execution/history/:jobType", h.pluginHandlers.GetExecutionHistoryAPI)
pluginApi.POST("/jobs/:jobType/trigger-detection", dash.RequireWriteAccess(), h.pluginHandlers.TriggerDetectionAPI)
pluginApi.POST("/jobs/:jobID/cancel", dash.RequireWriteAccess(), h.pluginHandlers.CancelJobAPI)
}
// Message Queue API routes
mqApi := api.Group("/mq")
{
-533
View File
@@ -1,533 +0,0 @@
package handlers
import (
"fmt"
"net/http"
"sort"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
"github.com/seaweedfs/seaweedfs/weed/admin/plugin"
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
// PluginHandlers handles HTTP requests for the plugin system
type PluginHandlers struct {
adminServer *dash.AdminServer
}
// NewPluginHandlers creates a new plugin handlers instance
func NewPluginHandlers(adminServer *dash.AdminServer) *PluginHandlers {
return &PluginHandlers{
adminServer: adminServer,
}
}
// ShowPlugins renders the main plugins page
func (h *PluginHandlers) ShowPlugins(c *gin.Context) {
data := h.getPluginsData()
html := app.PluginsPage(data)
c.Header("Content-Type", "text/html")
html.Render(c.Request.Context(), c.Writer)
}
// ShowPluginJobs renders the job monitoring page
func (h *PluginHandlers) ShowPluginJobs(c *gin.Context) {
jobType := c.Param("jobType")
data := h.getJobsData(jobType)
html := app.PluginJobsPage(data)
c.Header("Content-Type", "text/html")
html.Render(c.Request.Context(), c.Writer)
}
// ShowPluginConfig renders the plugin configuration page
func (h *PluginHandlers) ShowPluginConfig(c *gin.Context) {
jobType := c.Param("jobType")
data := h.getPluginConfigData(jobType)
html := app.PluginConfigPage(data)
c.Header("Content-Type", "text/html")
html.Render(c.Request.Context(), c.Writer)
}
// ListPluginsAPI returns JSON list of registered plugins
func (h *PluginHandlers) ListPluginsAPI(c *gin.Context) {
if h.adminServer.PluginManager == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"})
return
}
plugins := h.adminServer.PluginManager.ListConnectedPlugins()
// Convert to API response
var pluginList []map[string]interface{}
for _, p := range plugins {
pluginList = append(pluginList, map[string]interface{}{
"id": p.ID,
"name": p.Name,
"version": p.Version,
"protocol_version": p.ProtocolVersion,
"connected_at": p.ConnectedAt,
"last_heartbeat": p.LastHeartbeat,
"state": p.State.String(),
"healthy": p.IsHealthy(),
"pending_jobs": p.PendingJobs,
"running_jobs": p.RunningJobs,
"cpu_usage": p.CPUUsagePercent,
"memory_usage_mb": p.MemoryUsageMB,
})
}
c.JSON(http.StatusOK, gin.H{"plugins": pluginList})
}
// ListJobsAPI returns JSON list of jobs for a job type
func (h *PluginHandlers) ListJobsAPI(c *gin.Context) {
jobType := c.Param("jobType")
stateStr := c.DefaultQuery("state", "")
limit := 100
if h.adminServer.PluginManager == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"})
return
}
var state plugin.JobState
if stateStr != "" {
// Parse state string
switch stateStr {
case "pending":
state = plugin.JobStatePending
case "running":
state = plugin.JobStateRunning
case "completed":
state = plugin.JobStateCompleted
case "failed":
state = plugin.JobStateFailed
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
return
}
}
jobs := h.adminServer.PluginManager.ListJobs(jobType, state)
if limit > 0 && len(jobs) > limit {
jobs = jobs[:limit]
}
var jobList []map[string]interface{}
for _, j := range jobs {
jobList = append(jobList, map[string]interface{}{
"id": j.ID,
"type": j.Type,
"description": j.Description,
"state": j.State.String(),
"created_at": j.CreatedAt,
"updated_at": j.UpdatedAt,
"executor_id": j.ExecutorID,
"progress": j.ProgressPercent,
"retries": j.Retries,
})
}
c.JSON(http.StatusOK, gin.H{"jobs": jobList, "job_type": jobType})
}
// GetConfigAPI returns the configuration for a job type
func (h *PluginHandlers) GetConfigAPI(c *gin.Context) {
jobType := c.Param("jobType")
if h.adminServer.PluginManager == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"})
return
}
config, err := h.adminServer.PluginManager.GetConfig(jobType)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("config not found: %v", err)})
return
}
adminConfigMap := convertConfigFieldsToMap(config.AdminConfig)
workerConfigMap := convertConfigFieldsToMap(config.WorkerConfig)
c.JSON(http.StatusOK, gin.H{
"job_type": jobType,
"enabled": config.Enabled,
"admin_config": adminConfigMap,
"worker_config": workerConfigMap,
"created_at": config.CreatedAt,
"updated_at": config.UpdatedAt,
"created_by": config.CreatedBy,
})
}
// SaveConfigAPI saves the configuration for a job type
func (h *PluginHandlers) SaveConfigAPI(c *gin.Context) {
jobType := c.Param("jobType")
if h.adminServer.PluginManager == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"})
return
}
var req struct {
Enabled bool `json:"enabled"`
AdminConfig map[string]interface{} `json:"admin_config"`
WorkerConfig map[string]interface{} `json:"worker_config"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid request: %v", err)})
return
}
// Convert maps back to config fields
adminConfigFields := convertMapToConfigFields(req.AdminConfig)
workerConfigFields := convertMapToConfigFields(req.WorkerConfig)
err := h.adminServer.PluginManager.SaveConfig(jobType, req.Enabled, adminConfigFields, workerConfigFields)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "configuration saved"})
}
// GetDetectionHistoryAPI returns detection history for a job type
func (h *PluginHandlers) GetDetectionHistoryAPI(c *gin.Context) {
jobType := c.Param("jobType")
limitStr := c.DefaultQuery("limit", "10")
if h.adminServer.PluginManager == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"})
return
}
limit, err := strconv.Atoi(limitStr)
if err != nil || limit > 100 {
limit = 10
}
records := h.adminServer.PluginManager.GetDetectionHistory(jobType, limit)
var historyList []map[string]interface{}
for _, rec := range records {
historyList = append(historyList, map[string]interface{}{
"timestamp": rec.Timestamp,
"duration_ms": rec.DurationMs,
"jobs_detected": rec.JobsDetected,
"status": rec.Status,
"error": rec.Error,
"worker_id": rec.WorkerID,
"detailed_logs": rec.DetailedLogs,
})
}
c.JSON(http.StatusOK, gin.H{
"job_type": jobType,
"history": historyList,
"count": len(historyList),
})
}
// GetExecutionHistoryAPI returns execution history for a job type
func (h *PluginHandlers) GetExecutionHistoryAPI(c *gin.Context) {
jobType := c.Param("jobType")
limitStr := c.DefaultQuery("limit", "10")
if h.adminServer.PluginManager == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"})
return
}
limit, err := strconv.Atoi(limitStr)
if err != nil || limit > 100 {
limit = 10
}
records := h.adminServer.PluginManager.GetExecutionHistory(jobType, limit)
var historyList []map[string]interface{}
for _, rec := range records {
historyList = append(historyList, map[string]interface{}{
"job_id": rec.JobID,
"job_type": rec.JobType,
"started_at": rec.StartedAt,
"completed_at": rec.CompletedAt,
"duration_ms": rec.DurationMs,
"success": rec.Success,
"error": rec.Error,
"worker_id": rec.WorkerID,
"items_processed": rec.ItemsProcessed,
"execution_logs": rec.ExecutionLogs,
})
}
c.JSON(http.StatusOK, gin.H{
"job_type": jobType,
"history": historyList,
"count": len(historyList),
})
}
// TriggerDetectionAPI manually triggers detection for a job type
func (h *PluginHandlers) TriggerDetectionAPI(c *gin.Context) {
jobType := c.Param("jobType")
if h.adminServer.PluginManager == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"})
return
}
err := h.adminServer.PluginManager.TriggerDetection(jobType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("detection failed: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "detection triggered"})
}
// CancelJobAPI cancels a running job
func (h *PluginHandlers) CancelJobAPI(c *gin.Context) {
jobID := c.Param("jobID")
if h.adminServer.PluginManager == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "plugin manager not available"})
return
}
err := h.adminServer.PluginManager.CancelJob(jobID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("cancel failed: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "job cancelled"})
}
// getPluginsData prepares data for the main plugins page
func (h *PluginHandlers) getPluginsData() map[string]interface{} {
data := map[string]interface{}{
"plugins": []map[string]interface{}{},
"job_types": []string{},
"total_plugins": 0,
"total_jobs": 0,
}
if h.adminServer.PluginManager == nil {
return data
}
// Get connected plugins
plugins := h.adminServer.PluginManager.ListConnectedPlugins()
var pluginList []map[string]interface{}
for _, p := range plugins {
pluginList = append(pluginList, map[string]interface{}{
"id": p.ID,
"name": p.Name,
"version": p.Version,
"connected_at": p.ConnectedAt.Format(time.RFC3339),
"state": p.State.String(),
"healthy": p.IsHealthy(),
"pending_jobs": p.PendingJobs,
"running_jobs": p.RunningJobs,
"cpu_usage": fmt.Sprintf("%.1f%%", p.CPUUsagePercent),
"memory_usage": fmt.Sprintf("%.1f MB", p.MemoryUsageMB),
})
}
// Get registered job types
jobTypes := h.adminServer.PluginManager.ListJobTypes()
sort.Strings(jobTypes) // Sort alphabetically
// Count jobs
totalJobs := 0
for _, jobType := range jobTypes {
jobs := h.adminServer.PluginManager.ListJobs(jobType, 0)
totalJobs += len(jobs)
}
data["plugins"] = pluginList
data["job_types"] = jobTypes
data["total_plugins"] = len(plugins)
data["total_jobs"] = totalJobs
return data
}
// getJobsData prepares data for the job monitoring page
func (h *PluginHandlers) getJobsData(jobType string) map[string]interface{} {
data := map[string]interface{}{
"job_type": jobType,
"jobs": []map[string]interface{}{},
"pending_count": 0,
"running_count": 0,
"completed_count": 0,
"failed_count": 0,
}
if h.adminServer.PluginManager == nil {
return data
}
// Get all jobs for this type
allJobs := h.adminServer.PluginManager.ListJobs(jobType, 0)
var jobsList []map[string]interface{}
pendingCount, runningCount, completedCount, failedCount := 0, 0, 0, 0
for _, job := range allJobs {
jobsList = append(jobsList, map[string]interface{}{
"id": job.ID,
"description": job.Description,
"state": job.State.String(),
"created_at": job.CreatedAt.Format(time.RFC3339),
"updated_at": job.UpdatedAt.Format(time.RFC3339),
"progress": job.ProgressPercent,
"executor_id": job.ExecutorID,
"retries": job.Retries,
})
switch job.State {
case plugin.JobStatePending:
pendingCount++
case plugin.JobStateRunning:
runningCount++
case plugin.JobStateCompleted:
completedCount++
case plugin.JobStateFailed:
failedCount++
}
}
data["jobs"] = jobsList
data["pending_count"] = pendingCount
data["running_count"] = runningCount
data["completed_count"] = completedCount
data["failed_count"] = failedCount
return data
}
// getPluginConfigData prepares data for the plugin configuration page
func (h *PluginHandlers) getPluginConfigData(jobType string) map[string]interface{} {
data := map[string]interface{}{
"job_type": jobType,
"enabled": false,
"admin_config": map[string]interface{}{},
"worker_config": map[string]interface{}{},
"schema": map[string]interface{}{},
"detection_history": []map[string]interface{}{},
"execution_history": []map[string]interface{}{},
}
if h.adminServer.PluginManager == nil {
return data
}
// Get config
config, err := h.adminServer.PluginManager.GetConfig(jobType)
if err == nil && config != nil {
data["enabled"] = config.Enabled
data["admin_config"] = convertConfigFieldsToMap(config.AdminConfig)
data["worker_config"] = convertConfigFieldsToMap(config.WorkerConfig)
}
// Get schema
// detector, err := h.adminServer.PluginManager.GetDetectorForJobType(jobType)
// if err == nil && detector != nil {
// data["schema"] = detector.ConfigSchema // Will be extended later
// }
// Get detection history
detectionRecords := h.adminServer.PluginManager.GetDetectionHistory(jobType, 10)
var detectionList []map[string]interface{}
for _, rec := range detectionRecords {
detectionList = append(detectionList, map[string]interface{}{
"timestamp": rec.Timestamp.Format(time.RFC3339),
"duration_ms": rec.DurationMs,
"jobs_detected": rec.JobsDetected,
"status": rec.Status,
"error": rec.Error,
"detailed_logs": rec.DetailedLogs,
})
}
data["detection_history"] = detectionList
// Get execution history
executionRecords := h.adminServer.PluginManager.GetExecutionHistory(jobType, 10)
var executionList []map[string]interface{}
for _, rec := range executionRecords {
executionList = append(executionList, map[string]interface{}{
"job_id": rec.JobID,
"started_at": rec.StartedAt.Format(time.RFC3339),
"completed_at": rec.CompletedAt.Format(time.RFC3339),
"duration_ms": rec.DurationMs,
"success": rec.Success,
"error": rec.Error,
"items_processed": rec.ItemsProcessed,
})
}
data["execution_history"] = executionList
return data
}
// Helper functions to convert between data formats
func convertConfigFieldsToMap(fields []*plugin_pb.ConfigFieldValue) map[string]interface{} {
result := make(map[string]interface{})
for _, field := range fields {
if field == nil {
continue
}
if field.StringValue != "" {
result[field.FieldName] = field.StringValue
} else if field.IntValue != 0 {
result[field.FieldName] = field.IntValue
} else if field.FloatValue != 0 {
result[field.FieldName] = field.FloatValue
} else if field.BoolValue {
result[field.FieldName] = field.BoolValue
} else if field.JsonValue != "" {
result[field.FieldName] = field.JsonValue
}
}
return result
}
func convertMapToConfigFields(m map[string]interface{}) []*plugin_pb.ConfigFieldValue {
var fields []*plugin_pb.ConfigFieldValue
for k, v := range m {
field := &plugin_pb.ConfigFieldValue{FieldName: k}
switch val := v.(type) {
case string:
field.StringValue = val
case float64:
// Try to detect if it's an int or bool
if val == float64(int64(val)) {
field.IntValue = int64(val)
} else {
field.FloatValue = float32(val)
}
case bool:
field.BoolValue = val
case int:
field.IntValue = int64(val)
}
fields = append(fields, field)
}
return fields
}
// convertConfigSchemaToMap converts schema to a map (placeholder for future use)
// func convertConfigSchemaToMap(schema interface{}) map[string]interface{} {
// // TODO: implement schema conversion when schema structure is finalized
// return make(map[string]interface{})
// }
-307
View File
@@ -1,307 +0,0 @@
package plugin
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
// ConfigManager handles configuration persistence and retrieval
type ConfigManager struct {
dataDir string
configs map[string]*JobTypeConfig
mu sync.RWMutex
}
// NewConfigManager creates a new configuration manager
func NewConfigManager(dataDir string) (*ConfigManager, error) {
cm := &ConfigManager{
dataDir: filepath.Join(dataDir, "plugins"),
configs: make(map[string]*JobTypeConfig),
}
// Create plugins directory if it doesn't exist
if err := os.MkdirAll(cm.dataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create plugin config directory: %w", err)
}
return cm, nil
}
// LoadConfig loads a configuration from disk
func (cm *ConfigManager) LoadConfig(jobType string) (*JobTypeConfig, error) {
cm.mu.Lock()
defer cm.mu.Unlock()
// Check cache first
if config, exists := cm.configs[jobType]; exists {
return config, nil
}
configPath := filepath.Join(cm.dataDir, jobType+".json")
// If file doesn't exist, create default config
if _, err := os.Stat(configPath); err != nil {
if os.IsNotExist(err) {
config := &JobTypeConfig{
JobType: jobType,
Enabled: false,
AdminConfig: make([]*plugin_pb.ConfigFieldValue, 0),
WorkerConfig: make([]*plugin_pb.ConfigFieldValue, 0),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedBy: "system",
}
cm.configs[jobType] = config
return config, nil
}
return nil, fmt.Errorf("failed to access config file: %w", err)
}
// Load from file
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var configData map[string]interface{}
if err := json.Unmarshal(data, &configData); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
config := &JobTypeConfig{
JobType: jobType,
AdminConfig: make([]*plugin_pb.ConfigFieldValue, 0),
WorkerConfig: make([]*plugin_pb.ConfigFieldValue, 0),
}
// Parse fields
if enabled, ok := configData["enabled"].(bool); ok {
config.Enabled = enabled
}
if createdBy, ok := configData["created_by"].(string); ok {
config.CreatedBy = createdBy
}
if createdAt, ok := configData["created_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
config.CreatedAt = t
}
}
if updatedAt, ok := configData["updated_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
config.UpdatedAt = t
}
}
cm.configs[jobType] = config
return config, nil
}
// SaveConfig saves a configuration to disk
func (cm *ConfigManager) SaveConfig(config *JobTypeConfig) error {
cm.mu.Lock()
defer cm.mu.Unlock()
config.UpdatedAt = time.Now()
cm.configs[config.JobType] = config
configPath := filepath.Join(cm.dataDir, config.JobType+".json")
// Convert to JSON-serializable format
configData := map[string]interface{}{
"job_type": config.JobType,
"enabled": config.Enabled,
"created_by": config.CreatedBy,
"created_at": config.CreatedAt.Format(time.RFC3339),
"updated_at": config.UpdatedAt.Format(time.RFC3339),
"admin_config": cm.configValuesToJSON(config.AdminConfig),
"worker_config": cm.configValuesToJSON(config.WorkerConfig),
}
data, err := json.MarshalIndent(configData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(configPath, data, 0644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}
// GetConfig retrieves a configuration
func (cm *ConfigManager) GetConfig(jobType string) (*JobTypeConfig, error) {
cm.mu.RLock()
if config, exists := cm.configs[jobType]; exists {
cm.mu.RUnlock()
return config, nil
}
cm.mu.RUnlock()
// Load from disk if not in cache
return cm.LoadConfig(jobType)
}
// UpdateConfig updates a configuration
func (cm *ConfigManager) UpdateConfig(jobType string, enabled bool, adminConfig, workerConfig []*plugin_pb.ConfigFieldValue, updatedBy string) (*JobTypeConfig, error) {
config, err := cm.GetConfig(jobType)
if err != nil {
return nil, err
}
config.SetConfig(enabled, adminConfig, workerConfig)
config.CreatedBy = updatedBy
if err := cm.SaveConfig(config); err != nil {
return nil, err
}
return config, nil
}
// ListConfigs lists all configurations
func (cm *ConfigManager) ListConfigs() []*JobTypeConfig {
cm.mu.RLock()
defer cm.mu.RUnlock()
configs := make([]*JobTypeConfig, 0, len(cm.configs))
for _, config := range cm.configs {
configs = append(configs, config)
}
return configs
}
// DeleteConfig deletes a configuration
func (cm *ConfigManager) DeleteConfig(jobType string) error {
cm.mu.Lock()
defer cm.mu.Unlock()
configPath := filepath.Join(cm.dataDir, jobType+".json")
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete config file: %w", err)
}
delete(cm.configs, jobType)
return nil
}
// configValuesToJSON converts ConfigFieldValue slice to JSON
func (cm *ConfigManager) configValuesToJSON(values []*plugin_pb.ConfigFieldValue) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(values))
for _, val := range values {
item := map[string]interface{}{
"field_name": val.FieldName,
}
// Include non-empty values
if val.StringValue != "" {
item["string_value"] = val.StringValue
}
if val.IntValue != 0 {
item["int_value"] = val.IntValue
}
if val.FloatValue != 0 {
item["float_value"] = val.FloatValue
}
if val.BoolValue {
item["bool_value"] = val.BoolValue
}
if val.DurationValue != nil {
item["duration_value"] = val.DurationValue.String()
}
if len(val.MultiselectValues) > 0 {
item["multiselect_values"] = val.MultiselectValues
}
if val.JsonValue != "" {
item["json_value"] = val.JsonValue
}
result = append(result, item)
}
return result
}
// LoadAllConfigs loads all configurations from disk
func (cm *ConfigManager) LoadAllConfigs() error {
cm.mu.Lock()
defer cm.mu.Unlock()
entries, err := os.ReadDir(cm.dataDir)
if err != nil {
return fmt.Errorf("failed to read config directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
// Only load .json files
if filepath.Ext(entry.Name()) != ".json" {
continue
}
jobType := entry.Name()[:len(entry.Name())-5] // Remove .json
configPath := filepath.Join(cm.dataDir, entry.Name())
data, err := os.ReadFile(configPath)
if err != nil {
continue
}
var configData map[string]interface{}
if err := json.Unmarshal(data, &configData); err != nil {
continue
}
config := &JobTypeConfig{
JobType: jobType,
AdminConfig: make([]*plugin_pb.ConfigFieldValue, 0),
WorkerConfig: make([]*plugin_pb.ConfigFieldValue, 0),
}
// Parse fields
if enabled, ok := configData["enabled"].(bool); ok {
config.Enabled = enabled
}
if createdBy, ok := configData["created_by"].(string); ok {
config.CreatedBy = createdBy
}
if createdAt, ok := configData["created_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
config.CreatedAt = t
}
}
if updatedAt, ok := configData["updated_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
config.UpdatedAt = t
}
}
cm.configs[jobType] = config
}
return nil
}
// GetDataDir returns the data directory path
func (cm *ConfigManager) GetDataDir() string {
return cm.dataDir
}
-390
View File
@@ -1,390 +0,0 @@
package plugin
import (
"context"
"fmt"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/grpc"
)
// Dispatcher handles job detection and dispatch to executors
type Dispatcher struct {
registry *Registry
queues map[string]*JobQueue
configMgr *ConfigManager
mu sync.RWMutex
// Detection scheduling
detectionIntervals map[string]time.Duration
detectionTickers map[string]*time.Ticker
stopChan chan struct{}
// Job counter for ID generation
jobCounter uint64
jobCounterMu sync.Mutex
// Active streams for execution
executionStreams map[string]*ExecutionStream
streamsMu sync.RWMutex
}
// ExecutionStream tracks an active job execution stream
type ExecutionStream struct {
JobID string
PluginID string
Connection *grpc.ClientConn
Context context.Context
Cancel context.CancelFunc
StartTime time.Time
LastUpdate time.Time
}
// NewDispatcher creates a new job dispatcher
func NewDispatcher(registry *Registry, configMgr *ConfigManager) *Dispatcher {
return &Dispatcher{
registry: registry,
queues: make(map[string]*JobQueue),
configMgr: configMgr,
detectionIntervals: make(map[string]time.Duration),
detectionTickers: make(map[string]*time.Ticker),
stopChan: make(chan struct{}),
executionStreams: make(map[string]*ExecutionStream),
}
}
// RegisterJobType registers a job type for detection and dispatch
func (d *Dispatcher) RegisterJobType(jobType string, detectionInterval time.Duration) error {
d.mu.Lock()
defer d.mu.Unlock()
if _, exists := d.queues[jobType]; exists {
return fmt.Errorf("job type %s already registered", jobType)
}
d.queues[jobType] = NewJobQueue(jobType)
d.detectionIntervals[jobType] = detectionInterval
return nil
}
// Start starts the dispatcher with detection scheduling
func (d *Dispatcher) Start() {
d.mu.Lock()
defer d.mu.Unlock()
for jobType, interval := range d.detectionIntervals {
ticker := time.NewTicker(interval)
d.detectionTickers[jobType] = ticker
go d.detectionLoop(jobType, ticker)
}
}
// Stop stops the dispatcher
func (d *Dispatcher) Stop() {
close(d.stopChan)
d.mu.Lock()
defer d.mu.Unlock()
for _, ticker := range d.detectionTickers {
ticker.Stop()
}
}
// detectionLoop periodically runs detection for a job type
func (d *Dispatcher) detectionLoop(jobType string, ticker *time.Ticker) {
for {
select {
case <-ticker.C:
d.runDetection(jobType)
case <-d.stopChan:
return
}
}
}
// runDetection runs detection for a job type and queues detected jobs
func (d *Dispatcher) runDetection(jobType string) {
// Get detector plugin
_, err := d.registry.GetDetectorForJobType(jobType)
if err != nil {
// No detector available, skip
return
}
// Get job type config
config, err := d.configMgr.GetConfig(jobType)
if err != nil || !config.Enabled {
// Config not found or disabled, skip
return
}
// TODO: Call detector plugin's DetectJobs method
// For now, this is a placeholder for the gRPC call
}
// QueueJob adds a job to the queue
func (d *Dispatcher) QueueJob(jobType string, req *plugin_pb.JobRequest, dedupKey string) (*Job, error) {
d.mu.RLock()
queue, exists := d.queues[jobType]
d.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("job type %s not registered", jobType)
}
// Generate job ID
jobID := d.generateJobID()
return queue.AddJob(jobID, req, dedupKey)
}
// DispatchJob assigns a job to an available executor
func (d *Dispatcher) DispatchJob(jobType string) (*Job, *ConnectedPlugin, error) {
d.mu.RLock()
queue, exists := d.queues[jobType]
d.mu.RUnlock()
if !exists {
return nil, nil, fmt.Errorf("job type %s not registered", jobType)
}
// Get next pending job
job, err := queue.GetPendingJob()
if err != nil {
return nil, nil, err
}
// Get available executor
executor, err := d.registry.GetExecutorForJobType(jobType)
if err != nil {
// Put job back in queue
queue.mu.Lock()
queue.pendingQueue = append(queue.pendingQueue, job)
queue.mu.Unlock()
return nil, nil, err
}
job.ExecutorID = executor.ID
return job, executor, nil
}
// GetJob retrieves a job by ID across all queues
func (d *Dispatcher) GetJob(jobID string) (*Job, error) {
d.mu.RLock()
defer d.mu.RUnlock()
for _, queue := range d.queues {
job, err := queue.GetJob(jobID)
if err == nil {
return job, nil
}
}
return nil, fmt.Errorf("job %s not found", jobID)
}
// ListJobs returns jobs of a specific type and status
func (d *Dispatcher) ListJobs(jobType string, status JobState) []*Job {
d.mu.RLock()
queue, exists := d.queues[jobType]
d.mu.RUnlock()
if !exists {
return nil
}
return queue.ListJobs(status, 0)
}
// ListAllJobs returns all jobs across all queues
func (d *Dispatcher) ListAllJobs() []*Job {
d.mu.RLock()
defer d.mu.RUnlock()
var jobs []*Job
for _, queue := range d.queues {
jobs = append(jobs, queue.ListAllJobs()...)
}
return jobs
}
// UpdateJobProgress updates job progress
func (d *Dispatcher) UpdateJobProgress(jobID string, percent int32) error {
job, err := d.GetJob(jobID)
if err != nil {
return err
}
job.SetProgress(percent)
return nil
}
// CompleteJob marks a job as complete
func (d *Dispatcher) CompleteJob(jobID string, completed *plugin_pb.JobCompleted) error {
job, err := d.GetJob(jobID)
if err != nil {
return err
}
d.mu.RLock()
queue, exists := d.queues[job.Type]
d.mu.RUnlock()
if !exists {
return fmt.Errorf("job type %s not found", job.Type)
}
d.removeExecutionStream(jobID)
return queue.CompleteJob(jobID, completed)
}
// FailJob marks a job as failed
func (d *Dispatcher) FailJob(jobID string, failed *plugin_pb.JobFailed) error {
job, err := d.GetJob(jobID)
if err != nil {
return err
}
d.mu.RLock()
queue, exists := d.queues[job.Type]
d.mu.RUnlock()
if !exists {
return fmt.Errorf("job type %s not found", job.Type)
}
d.removeExecutionStream(jobID)
_, err = queue.FailJob(jobID, failed)
return err
}
// CancelJob cancels a job
func (d *Dispatcher) CancelJob(jobID string) error {
job, err := d.GetJob(jobID)
if err != nil {
return err
}
d.mu.RLock()
queue, exists := d.queues[job.Type]
d.mu.RUnlock()
if !exists {
return fmt.Errorf("job type %s not found", job.Type)
}
d.removeExecutionStream(jobID)
return queue.CancelJob(jobID)
}
// RetryJob retries a failed job
func (d *Dispatcher) RetryJob(jobID string) error {
job, err := d.GetJob(jobID)
if err != nil {
return err
}
state := job.GetState()
if state != JobStateFailed {
return fmt.Errorf("can only retry failed jobs, job is in %s state", state.String())
}
d.mu.RLock()
queue, exists := d.queues[job.Type]
d.mu.RUnlock()
if !exists {
return fmt.Errorf("job type %s not found", job.Type)
}
job.SetState(JobStatePending)
job.Retries++
queue.mu.Lock()
queue.pendingQueue = append(queue.pendingQueue, job)
queue.sortPendingQueue()
queue.mu.Unlock()
return nil
}
// generateJobID generates a unique job ID
func (d *Dispatcher) generateJobID() string {
d.jobCounterMu.Lock()
defer d.jobCounterMu.Unlock()
d.jobCounter++
return fmt.Sprintf("job-%d-%d", time.Now().Unix(), d.jobCounter)
}
// RegisterExecutionStream registers a new execution stream
func (d *Dispatcher) RegisterExecutionStream(jobID, pluginID string, ctx context.Context) *ExecutionStream {
d.streamsMu.Lock()
defer d.streamsMu.Unlock()
cancelCtx, cancel := context.WithCancel(ctx)
stream := &ExecutionStream{
JobID: jobID,
PluginID: pluginID,
Context: cancelCtx,
Cancel: cancel,
StartTime: time.Now(),
LastUpdate: time.Now(),
}
d.executionStreams[jobID] = stream
return stream
}
// removeExecutionStream removes an execution stream
func (d *Dispatcher) removeExecutionStream(jobID string) {
d.streamsMu.Lock()
defer d.streamsMu.Unlock()
stream, exists := d.executionStreams[jobID]
if exists {
stream.Cancel()
delete(d.executionStreams, jobID)
}
}
// GetStats returns dispatcher statistics
func (d *Dispatcher) GetStats() map[string]interface{} {
d.mu.RLock()
defer d.mu.RUnlock()
queueStats := make(map[string]interface{})
for jobType, queue := range d.queues {
queueStats[jobType] = queue.GetStats()
}
d.streamsMu.RLock()
activeStreams := len(d.executionStreams)
d.streamsMu.RUnlock()
return map[string]interface{}{
"queue_stats": queueStats,
"active_streams": activeStreams,
"registered_types": len(d.queues),
}
}
// TriggerDetection manually triggers detection for a job type
func (d *Dispatcher) TriggerDetection(jobType string, detector interface{}, config *JobTypeConfig) error {
// Record the detection run
record := &DetectionRecord{
Timestamp: time.Now(),
JobsDetected: 0,
Status: "started",
}
// TODO: Call detector to run detection
// For now, mark as completed with no jobs detected
record.DurationMs = 0
record.Status = "completed"
return nil
}
-461
View File
@@ -1,461 +0,0 @@
package plugin
import (
"context"
"fmt"
"io"
"sync"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/grpc"
)
// GrpcServer implements the plugin gRPC service
type GrpcServer struct {
plugin_pb.UnimplementedPluginServiceServer
plugin_pb.UnimplementedAdminQueryServiceServer
plugin_pb.UnimplementedAdminCommandServiceServer
registry *Registry
dispatcher *Dispatcher
configMgr *ConfigManager
// Active connections
connections map[string]*grpc.ClientStream
connMu sync.RWMutex
}
// NewGrpcServer creates a new gRPC server
func NewGrpcServer(registry *Registry, dispatcher *Dispatcher, configMgr *ConfigManager) *GrpcServer {
return &GrpcServer{
registry: registry,
dispatcher: dispatcher,
configMgr: configMgr,
connections: make(map[string]*grpc.ClientStream),
}
}
// ============================================================================
// PluginService Implementation
// ============================================================================
// Connect handles bidirectional streaming for plugin registration and lifecycle
func (s *GrpcServer) Connect(stream grpc.BidiStreamingServer[plugin_pb.PluginMessage, plugin_pb.AdminMessage]) error {
var pluginID string
for {
msg, err := stream.Recv()
if err == io.EOF {
// Stream closed normally
if pluginID != "" {
s.registry.UnregisterPlugin(pluginID)
}
return nil
}
if err != nil {
return err
}
// Handle different message types
switch content := msg.Content.(type) {
case *plugin_pb.PluginMessage_Register:
register := content.Register
pluginID = register.PluginId
_, _ = s.registry.RegisterPlugin(pluginID, register)
if err != nil {
return err
}
case *plugin_pb.PluginMessage_Heartbeat:
heartbeat := content.Heartbeat
if err := s.registry.UpdateHeartbeat(heartbeat.PluginId, heartbeat); err != nil {
return err
}
case *plugin_pb.PluginMessage_StatusUpdate:
// Handle job status updates
statusUpdate := content.StatusUpdate
if err := s.handleStatusUpdate(statusUpdate); err != nil {
return err
}
}
// Send acknowledgment
adminMsg := &plugin_pb.AdminMessage{
Content: &plugin_pb.AdminMessage_AdminCommand{
AdminCommand: &plugin_pb.AdminCommand{
CommandType: plugin_pb.AdminCommand_RELOAD_CONFIG,
},
},
}
if err := stream.Send(adminMsg); err != nil {
if pluginID != "" {
s.registry.UnregisterPlugin(pluginID)
}
return err
}
}
}
// ExecuteJob handles bidirectional streaming for job execution
func (s *GrpcServer) ExecuteJob(stream grpc.BidiStreamingServer[plugin_pb.JobExecutionMessage, plugin_pb.JobProgressMessage]) error {
var jobID string
for {
execMsg, err := stream.Recv()
if err == io.EOF {
// Stream closed normally
return nil
}
if err != nil {
return err
}
jobID = execMsg.JobId
// Handle different execution message types
switch content := execMsg.Content.(type) {
case *plugin_pb.JobExecutionMessage_JobStarted:
started := content.JobStarted
if err := s.handleJobStarted(jobID, started); err != nil {
return err
}
case *plugin_pb.JobExecutionMessage_Progress:
progress := content.Progress
if err := s.dispatcher.UpdateJobProgress(jobID, progress.ProgressPercent); err != nil {
return err
}
case *plugin_pb.JobExecutionMessage_Checkpoint:
checkpoint := content.Checkpoint
// Store checkpoint for resumption
if err := s.storeCheckpoint(jobID, checkpoint); err != nil {
return err
}
case *plugin_pb.JobExecutionMessage_JobCompleted:
completed := content.JobCompleted
if err := s.dispatcher.CompleteJob(jobID, completed); err != nil {
return err
}
case *plugin_pb.JobExecutionMessage_JobFailed:
failed := content.JobFailed
if err := s.dispatcher.FailJob(jobID, failed); err != nil {
return err
}
case *plugin_pb.JobExecutionMessage_LogEntry:
logEntry := content.LogEntry
if err := s.storeLogEntry(jobID, logEntry); err != nil {
return err
}
}
// Send progress update
progressMsg := &plugin_pb.JobProgressMessage{
JobId: jobID,
Content: &plugin_pb.JobProgressMessage_Update{
Update: &plugin_pb.JobProgressUpdate{
JobId: jobID,
ProgressPercent: 0,
CurrentStep: "processing",
StatusMessage: "Processing job",
Timestamp: nil,
},
},
}
if err := stream.Send(progressMsg); err != nil {
return err
}
}
}
// ============================================================================
// AdminQueryService Implementation
// ============================================================================
// GetPluginStats returns current plugin system statistics
func (s *GrpcServer) GetPluginStats(ctx context.Context, req *emptypb.Empty) (*plugin_pb.PluginStats, error) {
plugins := s.registry.ListPlugins()
jobs := s.dispatcher.ListAllJobs()
var totalJobs, pendingJobs, runningJobs, completedJobs, failedJobs int32
for _, job := range jobs {
totalJobs++
switch job.GetState() {
case JobStatePending:
pendingJobs++
case JobStateRunning:
runningJobs++
case JobStateCompleted:
completedJobs++
case JobStateFailed:
failedJobs++
}
}
stats := &plugin_pb.PluginStats{
TotalPlugins: int32(len(plugins)),
ActivePlugins: int32(len(plugins)),
TotalJobs: totalJobs,
PendingJobs: pendingJobs,
RunningJobs: runningJobs,
CompletedJobs: completedJobs,
FailedJobs: failedJobs,
}
return stats, nil
}
// ListPlugins returns all connected plugins
func (s *GrpcServer) ListPlugins(ctx context.Context, req *emptypb.Empty) (*plugin_pb.PluginList, error) {
plugins := s.registry.ListPlugins()
pluginInfos := make([]*plugin_pb.PluginInfo, 0, len(plugins))
for _, p := range plugins {
info := &plugin_pb.PluginInfo{
PluginId: p.ID,
Name: p.Name,
Version: p.Version,
ProtocolVersion: p.ProtocolVersion,
Healthy: p.IsHealthy(),
Status: p.GetState().String(),
}
// Add capabilities
for _, cap := range p.Capabilities {
info.Capabilities = append(info.Capabilities, cap)
}
pluginInfos = append(pluginInfos, info)
}
return &plugin_pb.PluginList{Plugins: pluginInfos}, nil
}
// ListJobs returns jobs filtered by type and status
func (s *GrpcServer) ListJobs(ctx context.Context, req *plugin_pb.ListJobsRequest) (*plugin_pb.JobList, error) {
var state JobState
switch req.Status {
case plugin_pb.ListJobsRequest_PENDING:
state = JobStatePending
case plugin_pb.ListJobsRequest_RUNNING:
state = JobStateRunning
case plugin_pb.ListJobsRequest_COMPLETED:
state = JobStateCompleted
case plugin_pb.ListJobsRequest_FAILED:
state = JobStateFailed
default:
state = JobStatePending
}
jobs := s.dispatcher.ListJobs(req.JobType, state)
jobPbs := make([]*plugin_pb.Job, 0, len(jobs))
for _, job := range jobs {
jobPb := &plugin_pb.Job{
JobId: job.ID,
JobType: job.Type,
Description: job.Description,
Priority: job.Priority,
CreatedAt: nil,
UpdatedAt: nil,
ExecutorPluginId: job.ExecutorID,
ProgressPercent: job.GetProgress(),
Config: job.Config,
}
jobPbs = append(jobPbs, jobPb)
}
return &plugin_pb.JobList{
Jobs: jobPbs,
TotalCount: int32(len(jobPbs)),
ReturnedCount: int32(len(jobPbs)),
}, nil
}
// GetJob returns detailed job information
func (s *GrpcServer) GetJob(ctx context.Context, req *plugin_pb.GetJobRequest) (*plugin_pb.JobDetail, error) {
job, err := s.dispatcher.GetJob(req.JobId)
if err != nil {
return nil, fmt.Errorf("job not found: %w", err)
}
jobPb := &plugin_pb.Job{
JobId: job.ID,
JobType: job.Type,
Description: job.Description,
Priority: job.Priority,
ExecutorPluginId: job.ExecutorID,
ProgressPercent: job.GetProgress(),
Config: job.Config,
}
detail := &plugin_pb.JobDetail{
Job: jobPb,
LogEntries: job.LogEntries,
CompletionInfo: job.CompletedInfo,
FailureInfo: job.FailedInfo,
CheckpointIds: job.CheckpointIDs,
}
return detail, nil
}
// GetJobExecutionLog returns job execution logs
func (s *GrpcServer) GetJobExecutionLog(req *plugin_pb.GetJobExecutionLogRequest, stream grpc.ServerStreamingServer[plugin_pb.ExecutionLogEntry]) error {
job, err := s.dispatcher.GetJob(req.JobId)
if err != nil {
return fmt.Errorf("job not found: %w", err)
}
// Send log entries
for _, entry := range job.LogEntries {
if err := stream.Send(entry); err != nil {
return err
}
}
return nil
}
// ============================================================================
// AdminCommandService Implementation
// ============================================================================
// UpdateJobTypeConfig updates configuration for a job type
func (s *GrpcServer) UpdateJobTypeConfig(ctx context.Context, req *plugin_pb.UpdateJobTypeConfigRequest) (*plugin_pb.JobTypeConfig, error) {
config := req.Config
_, err := s.configMgr.UpdateConfig(
config.JobType,
config.Enabled,
config.AdminConfig,
config.WorkerConfig,
"admin",
)
if err != nil {
return nil, fmt.Errorf("failed to update config: %w", err)
}
return config, nil
}
// CreateJob creates a new job
func (s *GrpcServer) CreateJob(ctx context.Context, req *plugin_pb.CreateJobRequest) (*plugin_pb.Job, error) {
jobReq := &plugin_pb.JobRequest{
JobType: req.JobType,
Description: req.Description,
Priority: req.Priority,
Config: req.Config,
Metadata: req.Metadata,
RequestSource: "admin",
}
job, err := s.dispatcher.QueueJob(req.JobType, jobReq, "")
if err != nil {
return nil, fmt.Errorf("failed to create job: %w", err)
}
return &plugin_pb.Job{
JobId: job.ID,
JobType: job.Type,
Description: job.Description,
Priority: job.Priority,
ProgressPercent: job.GetProgress(),
Config: job.Config,
}, nil
}
// CancelJob cancels a running job
func (s *GrpcServer) CancelJob(ctx context.Context, req *plugin_pb.CancelJobRequest) (*emptypb.Empty, error) {
if err := s.dispatcher.CancelJob(req.JobId); err != nil {
return nil, fmt.Errorf("failed to cancel job: %w", err)
}
return &emptypb.Empty{}, nil
}
// RetryJob retries a failed job
func (s *GrpcServer) RetryJob(ctx context.Context, req *plugin_pb.RetryJobRequest) (*plugin_pb.Job, error) {
if err := s.dispatcher.RetryJob(req.JobId); err != nil {
return nil, fmt.Errorf("failed to retry job: %w", err)
}
job, err := s.dispatcher.GetJob(req.JobId)
if err != nil {
return nil, fmt.Errorf("job not found: %w", err)
}
return &plugin_pb.Job{
JobId: job.ID,
JobType: job.Type,
Description: job.Description,
Priority: job.Priority,
ProgressPercent: job.GetProgress(),
Config: job.Config,
}, nil
}
// ============================================================================
// Helper Methods
// ============================================================================
// handleStatusUpdate handles execution status updates
func (s *GrpcServer) handleStatusUpdate(update *plugin_pb.ExecutionStatusUpdate) error {
if err := s.dispatcher.UpdateJobProgress(update.JobId, update.ProgressPercent); err != nil {
return err
}
return nil
}
// handleJobStarted handles job start messages
func (s *GrpcServer) handleJobStarted(jobID string, started *plugin_pb.JobStarted) error {
job, err := s.dispatcher.GetJob(jobID)
if err != nil {
return err
}
job.SetState(JobStateRunning)
return nil
}
// storeCheckpoint stores a job checkpoint
func (s *GrpcServer) storeCheckpoint(jobID string, checkpoint *plugin_pb.JobCheckpoint) error {
job, err := s.dispatcher.GetJob(jobID)
if err != nil {
return err
}
job.mu.Lock()
defer job.mu.Unlock()
job.CheckpointIDs = append(job.CheckpointIDs, checkpoint.CheckpointId)
return nil
}
// storeLogEntry stores a log entry for a job
func (s *GrpcServer) storeLogEntry(jobID string, logEntry *plugin_pb.ExecutionLog) error {
job, err := s.dispatcher.GetJob(jobID)
if err != nil {
return err
}
job.mu.Lock()
defer job.mu.Unlock()
job.LogEntries = append(job.LogEntries, &plugin_pb.ExecutionLogEntry{
Timestamp: logEntry.Timestamp,
Level: logEntry.Level,
Message: logEntry.Message,
Context: logEntry.Context,
})
return nil
}
-346
View File
@@ -1,346 +0,0 @@
package plugin
import (
"fmt"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
// JobQueue manages jobs for a specific job type with priority and deduplication
type JobQueue struct {
jobType string
// Active jobs by ID
jobs map[string]*Job
// Priority queue
pendingQueue []*Job
// Completed jobs history (keeps last 1000)
completedHistory []*Job
maxHistorySize int
// Deduplication tracking
seenKeys map[string]bool
mu sync.RWMutex
// Retry configuration
maxRetries int32
retryBackoff time.Duration
retryBackoffMax time.Duration
}
// NewJobQueue creates a new job queue for a job type
func NewJobQueue(jobType string) *JobQueue {
return &JobQueue{
jobType: jobType,
jobs: make(map[string]*Job),
pendingQueue: make([]*Job, 0),
completedHistory: make([]*Job, 0),
maxHistorySize: 1000,
seenKeys: make(map[string]bool),
maxRetries: 3,
retryBackoff: time.Second,
retryBackoffMax: 5 * time.Minute,
}
}
// AddJob adds a new job to the queue with deduplication
func (q *JobQueue) AddJob(jobID string, req *plugin_pb.JobRequest, dedupKey string) (*Job, error) {
q.mu.Lock()
defer q.mu.Unlock()
// Check deduplication
if dedupKey != "" && q.seenKeys[dedupKey] {
return nil, fmt.Errorf("job with key %s already queued", dedupKey)
}
if _, exists := q.jobs[jobID]; exists {
return nil, fmt.Errorf("job %s already exists", jobID)
}
job := &Job{
ID: jobID,
Type: req.JobType,
Description: req.Description,
Priority: req.Priority,
State: JobStatePending,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Config: req.Config,
Metadata: req.Metadata,
Retries: 0,
}
q.jobs[jobID] = job
q.pendingQueue = append(q.pendingQueue, job)
if dedupKey != "" {
q.seenKeys[dedupKey] = true
}
// Sort by priority (higher priority first)
q.sortPendingQueue()
return job, nil
}
// GetPendingJob returns the next job to execute
func (q *JobQueue) GetPendingJob() (*Job, error) {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.pendingQueue) == 0 {
return nil, fmt.Errorf("no pending jobs")
}
job := q.pendingQueue[0]
q.pendingQueue = q.pendingQueue[1:]
job.SetState(JobStateRunning)
return job, nil
}
// GetJob retrieves a job by ID
func (q *JobQueue) GetJob(jobID string) (*Job, error) {
q.mu.RLock()
defer q.mu.RUnlock()
job, exists := q.jobs[jobID]
if !exists {
return nil, fmt.Errorf("job %s not found", jobID)
}
return job, nil
}
// CompleteJob marks a job as completed
func (q *JobQueue) CompleteJob(jobID string, completed *plugin_pb.JobCompleted) error {
q.mu.Lock()
defer q.mu.Unlock()
job, exists := q.jobs[jobID]
if !exists {
return fmt.Errorf("job %s not found", jobID)
}
job.SetState(JobStateCompleted)
job.CompletedInfo = completed
q.addToHistory(job)
return nil
}
// FailJob marks a job as failed and determines if it should be retried
func (q *JobQueue) FailJob(jobID string, failed *plugin_pb.JobFailed) (*Job, error) {
q.mu.Lock()
defer q.mu.Unlock()
job, exists := q.jobs[jobID]
if !exists {
return nil, fmt.Errorf("job %s not found", jobID)
}
job.FailedInfo = failed
// Check if we should retry
if failed.Retryable && job.Retries < q.maxRetries {
job.Retries++
job.LastRetryTime = time.Now()
job.SetState(JobStatePending)
// Add back to queue with exponential backoff for retry
q.pendingQueue = append(q.pendingQueue, job)
q.sortPendingQueue()
return job, nil
}
// No retry, mark as failed
job.SetState(JobStateFailed)
q.addToHistory(job)
return job, nil
}
// PauseJob pauses a running job
func (q *JobQueue) PauseJob(jobID string) error {
q.mu.Lock()
defer q.mu.Unlock()
job, exists := q.jobs[jobID]
if !exists {
return fmt.Errorf("job %s not found", jobID)
}
if job.GetState() != JobStateRunning {
return fmt.Errorf("job %s is not running", jobID)
}
job.SetState(JobStatePaused)
return nil
}
// ResumeJob resumes a paused job
func (q *JobQueue) ResumeJob(jobID string) error {
q.mu.Lock()
defer q.mu.Unlock()
job, exists := q.jobs[jobID]
if !exists {
return fmt.Errorf("job %s not found", jobID)
}
if job.GetState() != JobStatePaused {
return fmt.Errorf("job %s is not paused", jobID)
}
job.SetState(JobStateRunning)
return nil
}
// CancelJob cancels a job
func (q *JobQueue) CancelJob(jobID string) error {
q.mu.Lock()
defer q.mu.Unlock()
job, exists := q.jobs[jobID]
if !exists {
return fmt.Errorf("job %s not found", jobID)
}
state := job.GetState()
if state != JobStatePending && state != JobStateRunning && state != JobStatePaused {
return fmt.Errorf("cannot cancel job in %s state", state.String())
}
job.SetState(JobStateCancelled)
// Remove from pending queue if present
for i, j := range q.pendingQueue {
if j.ID == jobID {
q.pendingQueue = append(q.pendingQueue[:i], q.pendingQueue[i+1:]...)
break
}
}
q.addToHistory(job)
return nil
}
// ListJobs returns jobs filtered by status
func (q *JobQueue) ListJobs(status JobState, limit int) []*Job {
q.mu.RLock()
defer q.mu.RUnlock()
var jobs []*Job
// Include pending jobs from queue
for _, job := range q.pendingQueue {
if job.GetState() == status {
jobs = append(jobs, job)
}
}
// Include all active jobs
for _, job := range q.jobs {
if job.GetState() == status {
jobs = append(jobs, job)
}
}
if limit > 0 && len(jobs) > limit {
jobs = jobs[:limit]
}
return jobs
}
// ListAllJobs returns all jobs
func (q *JobQueue) ListAllJobs() []*Job {
q.mu.RLock()
defer q.mu.RUnlock()
jobs := make([]*Job, 0, len(q.jobs))
for _, job := range q.jobs {
jobs = append(jobs, job)
}
// Add completed history
for _, job := range q.completedHistory {
jobs = append(jobs, job)
}
return jobs
}
// sortPendingQueue sorts the pending queue by priority (higher first)
func (q *JobQueue) sortPendingQueue() {
for i := 0; i < len(q.pendingQueue)-1; i++ {
for j := i + 1; j < len(q.pendingQueue); j++ {
if q.pendingQueue[j].Priority > q.pendingQueue[i].Priority {
q.pendingQueue[i], q.pendingQueue[j] = q.pendingQueue[j], q.pendingQueue[i]
}
}
}
}
// addToHistory adds a completed job to history
func (q *JobQueue) addToHistory(job *Job) {
q.completedHistory = append(q.completedHistory, job)
// Keep only last N entries
if len(q.completedHistory) > q.maxHistorySize {
q.completedHistory = q.completedHistory[len(q.completedHistory)-q.maxHistorySize:]
}
// Remove from active jobs
delete(q.jobs, job.ID)
}
// GetPendingCount returns number of pending jobs
func (q *JobQueue) GetPendingCount() int {
q.mu.RLock()
defer q.mu.RUnlock()
return len(q.pendingQueue)
}
// GetStats returns queue statistics
func (q *JobQueue) GetStats() map[string]interface{} {
q.mu.RLock()
defer q.mu.RUnlock()
var running, completed, failed, cancelled int
for _, job := range q.jobs {
switch job.GetState() {
case JobStateRunning:
running++
case JobStateCompleted:
completed++
case JobStateFailed:
failed++
case JobStateCancelled:
cancelled++
}
}
completed += len(q.completedHistory)
return map[string]interface{}{
"job_type": q.jobType,
"pending": len(q.pendingQueue),
"running": running,
"completed": completed,
"failed": failed,
"cancelled": cancelled,
"total": len(q.jobs) + len(q.completedHistory),
"history_size": len(q.completedHistory),
}
}
-542
View File
@@ -1,542 +0,0 @@
package plugin
import (
"context"
"fmt"
"net"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/grpc"
)
// Manager is the main plugin system manager
type Manager struct {
registry *Registry
dispatcher *Dispatcher
configMgr *ConfigManager
grpcServer *GrpcServer
// gRPC server
grpcListener net.Listener
grpc *grpc.Server
// Configuration
listenAddr string
dataDir string
healthCheckInterval time.Duration
// State
mu sync.RWMutex
running bool
stopChan chan struct{}
}
// NewManager creates a new plugin manager
func NewManager(listenAddr, dataDir string) (*Manager, error) {
// Create configuration manager
configMgr, err := NewConfigManager(dataDir)
if err != nil {
return nil, fmt.Errorf("failed to create config manager: %w", err)
}
// Create registry
registry := NewRegistry()
// Create dispatcher
dispatcher := NewDispatcher(registry, configMgr)
// Create gRPC server implementation
grpcServer := NewGrpcServer(registry, dispatcher, configMgr)
return &Manager{
registry: registry,
dispatcher: dispatcher,
configMgr: configMgr,
grpcServer: grpcServer,
listenAddr: listenAddr,
dataDir: dataDir,
healthCheckInterval: 30 * time.Second,
stopChan: make(chan struct{}),
}, nil
}
// Start starts the plugin manager and gRPC server
func (m *Manager) Start() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.running {
return fmt.Errorf("plugin manager already running")
}
// Start registry health checks
m.registry.Start()
// Start dispatcher
m.dispatcher.Start()
// Create gRPC server
m.grpc = grpc.NewServer()
// Register services
plugin_pb.RegisterPluginServiceServer(m.grpc, m.grpcServer)
plugin_pb.RegisterAdminQueryServiceServer(m.grpc, m.grpcServer)
plugin_pb.RegisterAdminCommandServiceServer(m.grpc, m.grpcServer)
// Start listening
listener, err := net.Listen("tcp", m.listenAddr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", m.listenAddr, err)
}
m.grpcListener = listener
// Start gRPC server in background
go func() {
if err := m.grpc.Serve(listener); err != nil && err != grpc.ErrServerStopped {
fmt.Printf("gRPC server error: %v\n", err)
}
}()
m.running = true
fmt.Printf("Plugin manager started on %s\n", m.listenAddr)
return nil
}
// Stop stops the plugin manager and gRPC server
func (m *Manager) Stop() error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.running {
return fmt.Errorf("plugin manager not running")
}
close(m.stopChan)
// Stop dispatcher
m.dispatcher.Stop()
// Stop registry
m.registry.Stop()
// Stop gRPC server
if m.grpc != nil {
m.grpc.GracefulStop()
}
m.running = false
return nil
}
// IsRunning returns whether the manager is running
func (m *Manager) IsRunning() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.running
}
// RegisterJobType registers a job type for detection and execution
func (m *Manager) RegisterJobType(jobType string, detectionInterval time.Duration) error {
m.mu.RLock()
running := m.running
m.mu.RUnlock()
if !running {
return fmt.Errorf("plugin manager not running")
}
// Register job type
if err := m.dispatcher.RegisterJobType(jobType, detectionInterval); err != nil {
return err
}
_, err := m.configMgr.LoadConfig(jobType)
if err != nil {
return err
}
return nil
}
// QueueJob queues a new job for execution
func (m *Manager) QueueJob(jobType string, description string, priority int64, metadata map[string]string) (*Job, error) {
m.mu.RLock()
running := m.running
m.mu.RUnlock()
if !running {
return nil, fmt.Errorf("plugin manager not running")
}
jobReq := &plugin_pb.JobRequest{
JobType: jobType,
Description: description,
Priority: priority,
Metadata: metadata,
RequestSource: "admin",
}
return m.dispatcher.QueueJob(jobType, jobReq, "")
}
// DispatchJob dispatches the next available job
func (m *Manager) DispatchJob(jobType string) (*Job, *ConnectedPlugin, error) {
m.mu.RLock()
running := m.running
m.mu.RUnlock()
if !running {
return nil, nil, fmt.Errorf("plugin manager not running")
}
return m.dispatcher.DispatchJob(jobType)
}
// GetJob retrieves a job by ID
func (m *Manager) GetJob(jobID string) (*Job, error) {
return m.dispatcher.GetJob(jobID)
}
// ListJobs returns jobs of a specific type and status
func (m *Manager) ListJobs(jobType string, status JobState) []*Job {
return m.dispatcher.ListJobs(jobType, status)
}
// ListAllJobs returns all jobs
func (m *Manager) ListAllJobs() []*Job {
return m.dispatcher.ListAllJobs()
}
// ListPlugins returns all connected plugins
func (m *Manager) ListPlugins() []*ConnectedPlugin {
return m.registry.ListPlugins()
}
// GetPlugin retrieves a plugin by ID
func (m *Manager) GetPlugin(pluginID string) (*ConnectedPlugin, error) {
return m.registry.GetPlugin(pluginID)
}
// ListPluginsByCapability returns plugins that support a job type
func (m *Manager) ListPluginsByCapability(jobType string) []*ConnectedPlugin {
return m.registry.ListPluginsByCapability(jobType)
}
// UpdateJobTypeConfig updates configuration for a job type
func (m *Manager) UpdateJobTypeConfig(jobType string, enabled bool, config []*plugin_pb.ConfigFieldValue) (*JobTypeConfig, error) {
return m.configMgr.UpdateConfig(jobType, enabled, config, nil, "admin")
}
// GetJobTypeConfig retrieves configuration for a job type
func (m *Manager) GetJobTypeConfig(jobType string) (*JobTypeConfig, error) {
return m.configMgr.GetConfig(jobType)
}
// CancelJob cancels a job
func (m *Manager) CancelJob(jobID string) error {
return m.dispatcher.CancelJob(jobID)
}
// RetryJob retries a failed job
func (m *Manager) RetryJob(jobID string) error {
return m.dispatcher.RetryJob(jobID)
}
// GetStats returns system statistics
func (m *Manager) GetStats() map[string]interface{} {
m.mu.RLock()
running := m.running
m.mu.RUnlock()
stats := map[string]interface{}{
"running": running,
}
if running {
stats["registry"] = m.registry.GetStats()
stats["dispatcher"] = m.dispatcher.GetStats()
}
return stats
}
// WaitForPluginConnection waits for a plugin to connect with timeout
func (m *Manager) WaitForPluginConnection(timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
plugins := m.registry.ListPlugins()
if len(plugins) > 0 {
return nil
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("no plugin connected within %v", timeout)
}
// CreateTestJob creates a job for testing
func (m *Manager) CreateTestJob(jobType string, description string) (*Job, error) {
return m.QueueJob(jobType, description, 100, map[string]string{
"test": "true",
})
}
// GetHealthStatus returns the health status of the plugin system
func (m *Manager) GetHealthStatus() map[string]interface{} {
m.mu.RLock()
running := m.running
m.mu.RUnlock()
status := map[string]interface{}{
"running": running,
}
if !running {
return status
}
plugins := m.registry.ListPlugins()
var healthyCount int
for _, p := range plugins {
if p.IsHealthy() {
healthyCount++
}
}
jobs := m.dispatcher.ListAllJobs()
var runningJobs, pendingJobs, completedJobs, failedJobs int
for _, job := range jobs {
switch job.GetState() {
case JobStateRunning:
runningJobs++
case JobStatePending:
pendingJobs++
case JobStateCompleted:
completedJobs++
case JobStateFailed:
failedJobs++
}
}
status["plugins"] = map[string]interface{}{
"total": len(plugins),
"healthy": healthyCount,
}
status["jobs"] = map[string]interface{}{
"running": runningJobs,
"pending": pendingJobs,
"completed": completedJobs,
"failed": failedJobs,
"total": len(jobs),
}
return status
}
// LoadAllConfigs loads all configurations from disk
func (m *Manager) LoadAllConfigs() error {
return m.configMgr.LoadAllConfigs()
}
// GetDataDir returns the data directory
func (m *Manager) GetDataDir() string {
return m.configMgr.GetDataDir()
}
// QueryPluginService queries a plugin service for capabilities
func (m *Manager) QueryPluginService(ctx context.Context, pluginID string, addr string) (*plugin_pb.PluginRegister, error) {
// This would be implemented to connect to a plugin and query its capabilities
// For now, return a placeholder error
return nil, fmt.Errorf("not yet implemented")
}
// ExecuteDetection triggers detection for a job type
func (m *Manager) ExecuteDetection(ctx context.Context, jobType string) ([]*DetectedJobInfo, error) {
_, err := m.registry.GetDetectorForJobType(jobType)
if err != nil {
return nil, fmt.Errorf("no detector available for job type %s: %w", jobType, err)
}
// TODO: Call detector plugin's DetectJobs method
// This would require connecting to the plugin and calling its service
return nil, fmt.Errorf("detection not yet implemented")
}
// DetectedJobInfo holds information about a detected job
type DetectedJobInfo struct {
Key string
JobType string
Priority int64
Metadata map[string]string
Timestamp time.Time
}
// GetListenAddr returns the listen address
func (m *Manager) GetListenAddr() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.listenAddr
}
// GetContext returns a context for the manager
func (m *Manager) GetContext() context.Context {
return context.Background()
}
// Close closes the manager and releases resources
func (m *Manager) Close() error {
if m.IsRunning() {
return m.Stop()
}
return nil
}
// ListConnectedPlugins returns the list of connected plugins
func (m *Manager) ListConnectedPlugins() []*ConnectedPlugin {
return m.registry.ListPlugins()
}
// GetConfig retrieves configuration for a job type
func (m *Manager) GetConfig(jobType string) (*JobTypeConfig, error) {
return m.configMgr.GetConfig(jobType)
}
// SaveConfig saves configuration for a job type
func (m *Manager) SaveConfig(jobType string, enabled bool, adminConfig, workerConfig []*plugin_pb.ConfigFieldValue) error {
config := &JobTypeConfig{
JobType: jobType,
Enabled: enabled,
AdminConfig: adminConfig,
WorkerConfig: workerConfig,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
return m.configMgr.SaveConfig(config)
}
// GetDetectionHistory returns detection history for a job type
func (m *Manager) GetDetectionHistory(jobType string, limit int) []*DetectionRecord {
config, err := m.configMgr.GetConfig(jobType)
if err != nil || config == nil {
return []*DetectionRecord{}
}
config.mu.RLock()
defer config.mu.RUnlock()
// Return in reverse order (latest first)
var result []*DetectionRecord
start := len(config.DetectionHistory) - 1
if start < 0 {
return result
}
for i := start; i >= 0 && len(result) < limit; i-- {
result = append(result, config.DetectionHistory[i])
}
return result
}
// GetExecutionHistory returns execution history for a job type
func (m *Manager) GetExecutionHistory(jobType string, limit int) []*ExecutionRecord {
config, err := m.configMgr.GetConfig(jobType)
if err != nil || config == nil {
return []*ExecutionRecord{}
}
config.mu.RLock()
defer config.mu.RUnlock()
// Return in reverse order (latest first)
var result []*ExecutionRecord
start := len(config.ExecutionHistory) - 1
if start < 0 {
return result
}
for i := start; i >= 0 && len(result) < limit; i-- {
result = append(result, config.ExecutionHistory[i])
}
return result
}
// TriggerDetection manually triggers detection for a job type
func (m *Manager) TriggerDetection(jobType string) error {
detector, err := m.registry.GetDetectorForJobType(jobType)
if err != nil {
return fmt.Errorf("no detector for job type %s", jobType)
}
config, err := m.configMgr.GetConfig(jobType)
if err != nil || !config.Enabled {
return fmt.Errorf("job type %s not enabled", jobType)
}
// Call the dispatcher to run detection
return m.dispatcher.TriggerDetection(jobType, detector, config)
}
// ListJobTypes returns the list of registered job types
func (m *Manager) ListJobTypes() []string {
m.mu.RLock()
defer m.mu.RUnlock()
var jobTypes []string
for jobType := range m.dispatcher.queues {
jobTypes = append(jobTypes, jobType)
}
return jobTypes
}
// GetDetectorForJobType retrieves a detector for a job type
func (m *Manager) GetDetectorForJobType(jobType string) (interface{}, error) {
return m.registry.GetDetectorForJobType(jobType)
}
// RecordDetection records a detection event
func (m *Manager) RecordDetection(jobType string, record *DetectionRecord) error {
config, err := m.configMgr.GetConfig(jobType)
if err != nil || config == nil {
return err
}
config.mu.Lock()
defer config.mu.Unlock()
// Keep last 50 detection records
config.DetectionHistory = append(config.DetectionHistory, record)
if len(config.DetectionHistory) > 50 {
config.DetectionHistory = config.DetectionHistory[len(config.DetectionHistory)-50:]
}
return nil
}
// RecordExecution records a job execution event
func (m *Manager) RecordExecution(jobType string, record *ExecutionRecord) error {
config, err := m.configMgr.GetConfig(jobType)
if err != nil || config == nil {
return err
}
config.mu.Lock()
defer config.mu.Unlock()
// Keep last 100 execution records
config.ExecutionHistory = append(config.ExecutionHistory, record)
if len(config.ExecutionHistory) > 100 {
config.ExecutionHistory = config.ExecutionHistory[len(config.ExecutionHistory)-100:]
}
return nil
}
-333
View File
@@ -1,333 +0,0 @@
package plugin
import (
"fmt"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
// Registry manages all connected plugins and their capabilities
type Registry struct {
plugins map[string]*ConnectedPlugin
// Index by job type and capability (detector/executor)
detectorsByJobType map[string][]*ConnectedPlugin
executorsByJobType map[string][]*ConnectedPlugin
mu sync.RWMutex
// Configuration
healthCheckInterval time.Duration
healthCheckTimeout time.Duration
healthCheckTicker *time.Ticker
stopChan chan struct{}
}
// NewRegistry creates a new plugin registry
func NewRegistry() *Registry {
return &Registry{
plugins: make(map[string]*ConnectedPlugin),
detectorsByJobType: make(map[string][]*ConnectedPlugin),
executorsByJobType: make(map[string][]*ConnectedPlugin),
healthCheckInterval: 30 * time.Second,
healthCheckTimeout: 90 * time.Second,
stopChan: make(chan struct{}),
}
}
// RegisterPlugin adds a new plugin to the registry
func (r *Registry) RegisterPlugin(pluginID string, register *plugin_pb.PluginRegister) (*ConnectedPlugin, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.plugins[pluginID]; exists {
return nil, fmt.Errorf("plugin %s already registered", pluginID)
}
plugin := &ConnectedPlugin{
ID: pluginID,
Name: register.Name,
Version: register.Version,
ProtocolVersion: register.ProtocolVersion,
ConnectedAt: time.Now(),
LastHeartbeat: time.Now(),
State: PluginStateConnected,
Healthy: true,
Capabilities: make(map[string]*plugin_pb.JobTypeCapability),
StreamConnected: true,
}
// Index capabilities
for _, cap := range register.Capabilities {
plugin.Capabilities[cap.JobType] = cap
if cap.CanDetect {
r.detectorsByJobType[cap.JobType] = append(r.detectorsByJobType[cap.JobType], plugin)
}
if cap.CanExecute {
r.executorsByJobType[cap.JobType] = append(r.executorsByJobType[cap.JobType], plugin)
}
}
r.plugins[pluginID] = plugin
return plugin, nil
}
// UnregisterPlugin removes a plugin from the registry
func (r *Registry) UnregisterPlugin(pluginID string) error {
r.mu.Lock()
defer r.mu.Unlock()
plugin, exists := r.plugins[pluginID]
if !exists {
return fmt.Errorf("plugin %s not found", pluginID)
}
// Remove from capability indexes
for jobType := range plugin.Capabilities {
r.removePluginFromDetectors(jobType, plugin)
r.removePluginFromExecutors(jobType, plugin)
}
delete(r.plugins, pluginID)
plugin.SetState(PluginStateDisconnected)
return nil
}
// removePluginFromDetectors removes plugin from detector index for a job type
func (r *Registry) removePluginFromDetectors(jobType string, plugin *ConnectedPlugin) {
detectors := r.detectorsByJobType[jobType]
for i, p := range detectors {
if p.ID == plugin.ID {
r.detectorsByJobType[jobType] = append(detectors[:i], detectors[i+1:]...)
break
}
}
if len(r.detectorsByJobType[jobType]) == 0 {
delete(r.detectorsByJobType, jobType)
}
}
// removePluginFromExecutors removes plugin from executor index for a job type
func (r *Registry) removePluginFromExecutors(jobType string, plugin *ConnectedPlugin) {
executors := r.executorsByJobType[jobType]
for i, p := range executors {
if p.ID == plugin.ID {
r.executorsByJobType[jobType] = append(executors[:i], executors[i+1:]...)
break
}
}
if len(r.executorsByJobType[jobType]) == 0 {
delete(r.executorsByJobType, jobType)
}
}
// GetPlugin retrieves a plugin by ID
func (r *Registry) GetPlugin(pluginID string) (*ConnectedPlugin, error) {
r.mu.RLock()
defer r.mu.RUnlock()
plugin, exists := r.plugins[pluginID]
if !exists {
return nil, fmt.Errorf("plugin %s not found", pluginID)
}
return plugin, nil
}
// GetDetectorForJobType returns an available detector plugin for a job type
func (r *Registry) GetDetectorForJobType(jobType string) (*ConnectedPlugin, error) {
r.mu.RLock()
defer r.mu.RUnlock()
detectors, exists := r.detectorsByJobType[jobType]
if !exists || len(detectors) == 0 {
return nil, fmt.Errorf("no detector available for job type %s", jobType)
}
// Return the first healthy detector
for _, detector := range detectors {
if detector.IsHealthy() {
return detector, nil
}
}
return nil, fmt.Errorf("no healthy detector available for job type %s", jobType)
}
// GetExecutorForJobType returns an available executor plugin for a job type
func (r *Registry) GetExecutorForJobType(jobType string) (*ConnectedPlugin, error) {
r.mu.RLock()
defer r.mu.RUnlock()
executors, exists := r.executorsByJobType[jobType]
if !exists || len(executors) == 0 {
return nil, fmt.Errorf("no executor available for job type %s", jobType)
}
// Return the first healthy executor with lowest workload
var bestExecutor *ConnectedPlugin
var minLoad int32 = int32(^uint32(0) >> 1)
for _, executor := range executors {
if executor.IsHealthy() {
load := executor.PendingJobs + executor.RunningJobs
if load < minLoad {
minLoad = load
bestExecutor = executor
}
}
}
if bestExecutor == nil {
return nil, fmt.Errorf("no healthy executor available for job type %s", jobType)
}
return bestExecutor, nil
}
// ListPlugins returns all connected plugins
func (r *Registry) ListPlugins() []*ConnectedPlugin {
r.mu.RLock()
defer r.mu.RUnlock()
plugins := make([]*ConnectedPlugin, 0, len(r.plugins))
for _, plugin := range r.plugins {
plugins = append(plugins, plugin)
}
return plugins
}
// ListPluginsByCapability returns plugins that support a specific job type
func (r *Registry) ListPluginsByCapability(jobType string) []*ConnectedPlugin {
r.mu.RLock()
defer r.mu.RUnlock()
detectors := r.detectorsByJobType[jobType]
executors := r.executorsByJobType[jobType]
// Combine and deduplicate
pluginMap := make(map[string]*ConnectedPlugin)
for _, p := range detectors {
pluginMap[p.ID] = p
}
for _, p := range executors {
pluginMap[p.ID] = p
}
plugins := make([]*ConnectedPlugin, 0, len(pluginMap))
for _, p := range pluginMap {
plugins = append(plugins, p)
}
return plugins
}
// UpdateHeartbeat updates plugin heartbeat and health status
func (r *Registry) UpdateHeartbeat(pluginID string, heartbeat *plugin_pb.PluginHeartbeat) error {
r.mu.Lock()
plugin, exists := r.plugins[pluginID]
r.mu.Unlock()
if !exists {
return fmt.Errorf("plugin %s not found", pluginID)
}
plugin.UpdateHeartbeat(
heartbeat.CpuUsagePercent,
heartbeat.MemoryUsageMb,
heartbeat.PendingJobs,
int32(len(plugin.Capabilities)), // rough estimate
)
return nil
}
// Start starts the health check loop
func (r *Registry) Start() {
r.healthCheckTicker = time.NewTicker(r.healthCheckInterval)
go r.healthCheckLoop()
}
// Stop stops the health check loop
func (r *Registry) Stop() {
close(r.stopChan)
if r.healthCheckTicker != nil {
r.healthCheckTicker.Stop()
}
}
// healthCheckLoop periodically checks plugin health
func (r *Registry) healthCheckLoop() {
for {
select {
case <-r.healthCheckTicker.C:
r.checkHealth()
case <-r.stopChan:
return
}
}
}
// checkHealth checks the health of all plugins
func (r *Registry) checkHealth() {
r.mu.RLock()
plugins := make([]*ConnectedPlugin, 0, len(r.plugins))
for _, p := range r.plugins {
plugins = append(plugins, p)
}
r.mu.RUnlock()
now := time.Now()
for _, plugin := range plugins {
plugin.mu.RLock()
lastHeartbeat := plugin.LastHeartbeat
plugin.mu.RUnlock()
timeSinceHeartbeat := now.Sub(lastHeartbeat)
if timeSinceHeartbeat > r.healthCheckTimeout {
plugin.SetState(PluginStateUnhealthy)
plugin.mu.Lock()
plugin.Healthy = false
plugin.mu.Unlock()
} else if timeSinceHeartbeat > r.healthCheckTimeout/2 {
plugin.SetState(PluginStateUnhealthy)
} else {
plugin.SetState(PluginStateHealthy)
plugin.mu.Lock()
plugin.Healthy = true
plugin.mu.Unlock()
}
}
}
// GetStats returns registry statistics
func (r *Registry) GetStats() map[string]interface{} {
r.mu.RLock()
defer r.mu.RUnlock()
stats := map[string]interface{}{
"total_plugins": len(r.plugins),
"job_types": len(r.detectorsByJobType),
}
var healthyCount int
for _, p := range r.plugins {
if p.IsHealthy() {
healthyCount++
}
}
stats["healthy_plugins"] = healthyCount
jobTypeStats := make(map[string]map[string]interface{})
for jobType := range r.detectorsByJobType {
jobTypeStats[jobType] = map[string]interface{}{
"detectors": len(r.detectorsByJobType[jobType]),
"executors": len(r.executorsByJobType[jobType]),
}
}
stats["job_type_stats"] = jobTypeStats
return stats
}
-420
View File
@@ -1,420 +0,0 @@
package testing
import (
"context"
"fmt"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/protobuf/types/known/timestamppb"
)
// TestHarness provides utilities for testing plugin functionality
type TestHarness struct {
ctx context.Context
cancel context.CancelFunc
adminServer *MockAdminServer
pluginClient plugin_pb.PluginService_ConnectClient
executionClient plugin_pb.PluginService_ExecuteJobClient
mu sync.RWMutex
createdJobs map[string]*plugin_pb.JobRequest
executedJobs map[string]*ExecutionTracker
progressUpdates map[string][]*plugin_pb.JobProgress
testDataDir string
}
// ExecutionTracker tracks the execution of a job
type ExecutionTracker struct {
JobID string
Status string
ProgressPercent int32
Messages []*plugin_pb.JobExecutionMessage
StartTime time.Time
LastUpdate time.Time
mu sync.RWMutex
}
// NewTestHarness creates a new test harness
func NewTestHarness(ctx context.Context) *TestHarness {
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithCancel(ctx)
return &TestHarness{
ctx: ctx,
cancel: cancel,
adminServer: NewMockAdminServer(),
createdJobs: make(map[string]*plugin_pb.JobRequest),
executedJobs: make(map[string]*ExecutionTracker),
progressUpdates: make(map[string][]*plugin_pb.JobProgress),
}
}
// Setup initializes the test harness and starts mock connections
func (h *TestHarness) Setup() error {
h.mu.Lock()
defer h.mu.Unlock()
if h.adminServer == nil {
h.adminServer = NewMockAdminServer()
}
return nil
}
// Teardown cleans up test resources
func (h *TestHarness) Teardown() error {
h.mu.Lock()
defer h.mu.Unlock()
if h.cancel != nil {
h.cancel()
}
if h.adminServer != nil {
h.adminServer.Close()
}
return nil
}
// GetContext returns the test context
func (h *TestHarness) GetContext() context.Context {
return h.ctx
}
// CreateJob creates a test job request
func (h *TestHarness) CreateJob(jobType, description string, priority int64, config []*plugin_pb.ConfigFieldValue) *plugin_pb.JobRequest {
h.mu.Lock()
defer h.mu.Unlock()
jobID := fmt.Sprintf("test-job-%d", time.Now().UnixNano())
job := &plugin_pb.JobRequest{
JobId: jobID,
JobType: jobType,
Description: description,
Priority: priority,
CreatedAt: timestamppb.Now(),
Config: config,
Metadata: make(map[string]string),
}
h.createdJobs[jobID] = job
return job
}
// ExecuteJob simulates job execution and returns a tracker
func (h *TestHarness) ExecuteJob(job *plugin_pb.JobRequest) *ExecutionTracker {
h.mu.Lock()
defer h.mu.Unlock()
tracker := &ExecutionTracker{
JobID: job.JobId,
Status: "running",
Messages: make([]*plugin_pb.JobExecutionMessage, 0),
StartTime: time.Now(),
}
h.executedJobs[job.JobId] = tracker
return tracker
}
// UpdateJobProgress updates the progress of a job
func (h *TestHarness) UpdateJobProgress(jobID string, progressPercent int32, currentStep, statusMessage string) {
h.mu.Lock()
defer h.mu.Unlock()
progress := &plugin_pb.JobProgress{
ProgressPercent: progressPercent,
CurrentStep: currentStep,
StatusMessage: statusMessage,
UpdatedAt: timestamppb.Now(),
}
h.progressUpdates[jobID] = append(h.progressUpdates[jobID], progress)
if tracker, ok := h.executedJobs[jobID]; ok {
tracker.mu.Lock()
tracker.ProgressPercent = progressPercent
tracker.LastUpdate = time.Now()
tracker.mu.Unlock()
}
}
// VerifyProgress checks if job progress meets expectations
func (h *TestHarness) VerifyProgress(jobID string, expectedPercent int32) error {
h.mu.RLock()
defer h.mu.RUnlock()
tracker, ok := h.executedJobs[jobID]
if !ok {
return fmt.Errorf("job not found: %s", jobID)
}
tracker.mu.RLock()
actual := tracker.ProgressPercent
tracker.mu.RUnlock()
if actual != expectedPercent {
return fmt.Errorf("progress mismatch for job %s: expected %d, got %d", jobID, expectedPercent, actual)
}
return nil
}
// CompleteJob marks a job as completed
func (h *TestHarness) CompleteJob(jobID string, summary string, output map[string]string) error {
h.mu.Lock()
defer h.mu.Unlock()
tracker, ok := h.executedJobs[jobID]
if !ok {
return fmt.Errorf("job not found: %s", jobID)
}
tracker.mu.Lock()
tracker.Status = "completed"
tracker.ProgressPercent = 100
tracker.LastUpdate = time.Now()
tracker.mu.Unlock()
completed := &plugin_pb.JobExecutionMessage{
JobId: jobID,
Content: &plugin_pb.JobExecutionMessage_JobCompleted{
JobCompleted: &plugin_pb.JobCompleted{
CompletedAt: timestamppb.Now(),
Summary: summary,
Output: output,
},
},
}
tracker.mu.Lock()
tracker.Messages = append(tracker.Messages, completed)
tracker.mu.Unlock()
return nil
}
// FailJob marks a job as failed
func (h *TestHarness) FailJob(jobID, errorCode, errorMessage string, retryable bool) error {
h.mu.Lock()
defer h.mu.Unlock()
tracker, ok := h.executedJobs[jobID]
if !ok {
return fmt.Errorf("job not found: %s", jobID)
}
tracker.mu.Lock()
tracker.Status = "failed"
tracker.LastUpdate = time.Now()
tracker.mu.Unlock()
failed := &plugin_pb.JobExecutionMessage{
JobId: jobID,
Content: &plugin_pb.JobExecutionMessage_JobFailed{
JobFailed: &plugin_pb.JobFailed{
ErrorCode: errorCode,
ErrorMessage: errorMessage,
Retryable: retryable,
FailedAt: timestamppb.Now(),
RetryCount: 0,
},
},
}
tracker.mu.Lock()
tracker.Messages = append(tracker.Messages, failed)
tracker.mu.Unlock()
return nil
}
// GetJobMessages returns all execution messages for a job
func (h *TestHarness) GetJobMessages(jobID string) []*plugin_pb.JobExecutionMessage {
h.mu.RLock()
defer h.mu.RUnlock()
tracker, ok := h.executedJobs[jobID]
if !ok {
return nil
}
tracker.mu.RLock()
defer tracker.mu.RUnlock()
return tracker.Messages
}
// GetJobStatus returns the current status of a job
func (h *TestHarness) GetJobStatus(jobID string) (string, error) {
h.mu.RLock()
defer h.mu.RUnlock()
tracker, ok := h.executedJobs[jobID]
if !ok {
return "", fmt.Errorf("job not found: %s", jobID)
}
tracker.mu.RLock()
defer tracker.mu.RUnlock()
return tracker.Status, nil
}
// GetAdminServer returns the mock admin server
func (h *TestHarness) GetAdminServer() *MockAdminServer {
h.mu.RLock()
defer h.mu.RUnlock()
return h.adminServer
}
// WaitForJobCompletion waits for a job to complete or timeout
func (h *TestHarness) WaitForJobCompletion(jobID string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
if time.Now().After(deadline) {
return fmt.Errorf("job completion timeout: %s", jobID)
}
status, err := h.GetJobStatus(jobID)
if err != nil {
return err
}
if status == "completed" || status == "failed" {
return nil
}
time.Sleep(100 * time.Millisecond)
}
}
// AssertJobExists verifies that a job was created
func (h *TestHarness) AssertJobExists(jobID string) error {
h.mu.RLock()
defer h.mu.RUnlock()
if _, ok := h.createdJobs[jobID]; !ok {
return fmt.Errorf("job does not exist: %s", jobID)
}
return nil
}
// AssertJobNotExists verifies that a job was not created
func (h *TestHarness) AssertJobNotExists(jobID string) error {
h.mu.RLock()
defer h.mu.RUnlock()
if _, ok := h.createdJobs[jobID]; ok {
return fmt.Errorf("job should not exist: %s", jobID)
}
return nil
}
// AssertJobStatus verifies that a job has the expected status
func (h *TestHarness) AssertJobStatus(jobID, expectedStatus string) error {
status, err := h.GetJobStatus(jobID)
if err != nil {
return err
}
if status != expectedStatus {
return fmt.Errorf("job status mismatch for %s: expected %s, got %s", jobID, expectedStatus, status)
}
return nil
}
// CreatePluginRegister creates a test plugin registration message
func CreatePluginRegister(pluginID, name, version string, capabilities []*plugin_pb.JobTypeCapability) *plugin_pb.PluginMessage {
return &plugin_pb.PluginMessage{
Content: &plugin_pb.PluginMessage_Register{
Register: &plugin_pb.PluginRegister{
PluginId: pluginID,
Name: name,
Version: version,
ProtocolVersion: "v1",
Capabilities: capabilities,
},
},
}
}
// CreatePluginHeartbeat creates a test plugin heartbeat message
func CreatePluginHeartbeat(pluginID string, pendingJobs, runningJobs int32, cpuUsage, memoryUsage float32) *plugin_pb.PluginMessage {
return &plugin_pb.PluginMessage{
Content: &plugin_pb.PluginMessage_Heartbeat{
Heartbeat: &plugin_pb.PluginHeartbeat{
PluginId: pluginID,
Timestamp: timestamppb.Now(),
UptimeSeconds: 3600,
PendingJobs: pendingJobs,
CpuUsagePercent: cpuUsage,
MemoryUsageMb: memoryUsage,
},
},
}
}
// CreateJobStartedMessage creates a job started message
func CreateJobStartedMessage(jobID, executorID string) *plugin_pb.JobExecutionMessage {
return &plugin_pb.JobExecutionMessage{
JobId: jobID,
Content: &plugin_pb.JobExecutionMessage_JobStarted{
JobStarted: &plugin_pb.JobStarted{
StartedAt: timestamppb.Now(),
ExecutorId: executorID,
},
},
}
}
// CreateJobProgressMessage creates a job progress message
func CreateJobProgressMessage(jobID string, progressPercent int32, currentStep, statusMessage string) *plugin_pb.JobExecutionMessage {
return &plugin_pb.JobExecutionMessage{
JobId: jobID,
Content: &plugin_pb.JobExecutionMessage_Progress{
Progress: &plugin_pb.JobProgress{
ProgressPercent: progressPercent,
CurrentStep: currentStep,
StatusMessage: statusMessage,
UpdatedAt: timestamppb.Now(),
},
},
}
}
// CreateJobCompletedMessage creates a job completed message
func CreateJobCompletedMessage(jobID, summary string, output map[string]string) *plugin_pb.JobExecutionMessage {
return &plugin_pb.JobExecutionMessage{
JobId: jobID,
Content: &plugin_pb.JobExecutionMessage_JobCompleted{
JobCompleted: &plugin_pb.JobCompleted{
CompletedAt: timestamppb.Now(),
Summary: summary,
Output: output,
},
},
}
}
// CreateJobFailedMessage creates a job failed message
func CreateJobFailedMessage(jobID, errorCode, errorMessage string, retryable bool) *plugin_pb.JobExecutionMessage {
return &plugin_pb.JobExecutionMessage{
JobId: jobID,
Content: &plugin_pb.JobExecutionMessage_JobFailed{
JobFailed: &plugin_pb.JobFailed{
ErrorCode: errorCode,
ErrorMessage: errorMessage,
Retryable: retryable,
FailedAt: timestamppb.Now(),
RetryCount: 0,
},
},
}
}
-262
View File
@@ -1,262 +0,0 @@
package testing
import (
"io"
"sync"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
// MockAdminServer implements the PluginService for testing
type MockAdminServer struct {
mu sync.RWMutex
receivedPluginMessages []*plugin_pb.PluginMessage
receivedJobMessages []*plugin_pb.JobExecutionMessage
jobResponses map[string][]*plugin_pb.JobProgressMessage
pluginResponses map[string][]*plugin_pb.AdminMessage
streams map[string]*MockStream
closed bool
}
// MockStream represents a bidirectional stream
type MockStream struct {
mu sync.RWMutex
messages chan interface{}
closed bool
}
// NewMockAdminServer creates a new mock admin server
func NewMockAdminServer() *MockAdminServer {
return &MockAdminServer{
receivedPluginMessages: make([]*plugin_pb.PluginMessage, 0),
receivedJobMessages: make([]*plugin_pb.JobExecutionMessage, 0),
jobResponses: make(map[string][]*plugin_pb.JobProgressMessage),
pluginResponses: make(map[string][]*plugin_pb.AdminMessage),
streams: make(map[string]*MockStream),
}
}
// Connect implements the Connect RPC - bidirectional stream for plugin registration
func (m *MockAdminServer) Connect(stream plugin_pb.PluginService_ConnectServer) error {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return io.EOF
}
m.mu.Unlock()
// Receive messages from plugin
for {
msg, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
m.mu.Lock()
m.receivedPluginMessages = append(m.receivedPluginMessages, msg)
m.mu.Unlock()
// Send response if available
var response *plugin_pb.AdminMessage
if msg.GetRegister() != nil {
response = &plugin_pb.AdminMessage{
Content: &plugin_pb.AdminMessage_ConfigUpdate{
ConfigUpdate: &plugin_pb.ConfigUpdate{
JobType: "test_job_type",
},
},
}
}
if response != nil {
err = stream.Send(response)
if err != nil {
return err
}
}
}
}
// ExecuteJob implements the ExecuteJob RPC - bidirectional stream for job execution
func (m *MockAdminServer) ExecuteJob(stream plugin_pb.PluginService_ExecuteJobServer) error {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return io.EOF
}
m.mu.Unlock()
// Receive execution messages from plugin
for {
msg, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
m.mu.Lock()
m.receivedJobMessages = append(m.receivedJobMessages, msg)
m.mu.Unlock()
// Send progress update if available
jobID := msg.JobId
m.mu.RLock()
responses, ok := m.jobResponses[jobID]
m.mu.RUnlock()
if ok && len(responses) > 0 {
response := responses[0]
err = stream.Send(response)
if err != nil {
return err
}
m.mu.Lock()
m.jobResponses[jobID] = responses[1:]
m.mu.Unlock()
}
}
}
// AddJobResponse adds a pre-configured response for a specific job
func (m *MockAdminServer) AddJobResponse(jobID string, response *plugin_pb.JobProgressMessage) {
m.mu.Lock()
defer m.mu.Unlock()
m.jobResponses[jobID] = append(m.jobResponses[jobID], response)
}
// AddPluginResponse adds a pre-configured response for plugin messages
func (m *MockAdminServer) AddPluginResponse(response *plugin_pb.AdminMessage) {
m.mu.Lock()
defer m.mu.Unlock()
// Store responses indexed by a generic key for now
key := "default"
m.pluginResponses[key] = append(m.pluginResponses[key], response)
}
// GetReceivedPluginMessages returns all received plugin messages
func (m *MockAdminServer) GetReceivedPluginMessages() []*plugin_pb.PluginMessage {
m.mu.RLock()
defer m.mu.RUnlock()
// Return a copy
result := make([]*plugin_pb.PluginMessage, len(m.receivedPluginMessages))
copy(result, m.receivedPluginMessages)
return result
}
// GetReceivedJobMessages returns all received job execution messages
func (m *MockAdminServer) GetReceivedJobMessages() []*plugin_pb.JobExecutionMessage {
m.mu.RLock()
defer m.mu.RUnlock()
// Return a copy
result := make([]*plugin_pb.JobExecutionMessage, len(m.receivedJobMessages))
copy(result, m.receivedJobMessages)
return result
}
// GetReceivedJobMessages returns job execution messages filtered by job ID
func (m *MockAdminServer) GetJobMessages(jobID string) []*plugin_pb.JobExecutionMessage {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*plugin_pb.JobExecutionMessage
for _, msg := range m.receivedJobMessages {
if msg.JobId == jobID {
result = append(result, msg)
}
}
return result
}
// CountReceivedMessages returns the count of received plugin messages
func (m *MockAdminServer) CountReceivedMessages() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.receivedPluginMessages)
}
// CountReceivedJobMessages returns the count of received job execution messages
func (m *MockAdminServer) CountReceivedJobMessages() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.receivedJobMessages)
}
// ClearMessages clears all recorded messages
func (m *MockAdminServer) ClearMessages() {
m.mu.Lock()
defer m.mu.Unlock()
m.receivedPluginMessages = make([]*plugin_pb.PluginMessage, 0)
m.receivedJobMessages = make([]*plugin_pb.JobExecutionMessage, 0)
}
// Close closes the mock server
func (m *MockAdminServer) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
m.closed = true
return nil
}
// IsClosed returns whether the server is closed
func (m *MockAdminServer) IsClosed() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.closed
}
// VerifyPluginRegistration verifies that a plugin registration was received
func (m *MockAdminServer) VerifyPluginRegistration(pluginID string) error {
messages := m.GetReceivedPluginMessages()
for _, msg := range messages {
if reg := msg.GetRegister(); reg != nil && reg.PluginId == pluginID {
return nil
}
}
return io.EOF
}
// VerifyJobExecution verifies that job execution messages were received for a job
func (m *MockAdminServer) VerifyJobExecution(jobID string) error {
messages := m.GetJobMessages(jobID)
if len(messages) == 0 {
return io.EOF
}
return nil
}
// VerifyJobCompletion verifies that a job completion message was received
func (m *MockAdminServer) VerifyJobCompletion(jobID string) error {
messages := m.GetJobMessages(jobID)
for _, msg := range messages {
if msg.GetJobCompleted() != nil {
return nil
}
}
return io.EOF
}
// VerifyJobFailure verifies that a job failure message was received
func (m *MockAdminServer) VerifyJobFailure(jobID string) error {
messages := m.GetJobMessages(jobID)
for _, msg := range messages {
if msg.GetJobFailed() != nil {
return nil
}
}
return io.EOF
}
-353
View File
@@ -1,353 +0,0 @@
package testing
import (
"fmt"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/protobuf/types/known/timestamppb"
)
// MockPlugin simulates a plugin worker for testing
type MockPlugin struct {
ID string
Name string
Version string
ProtocolVersion string
Capabilities []*plugin_pb.JobTypeCapability
// Configuration
DetectionEnabled bool
ExecutionEnabled bool
FailureMode string // "" = success, "detection_error", "execution_error"
DetectionDelay time.Duration
ExecutionDelay time.Duration
// Tracking
mu sync.RWMutex
detectedJobs map[string]*plugin_pb.DetectedJob
executedJobs map[string]*JobExecution
registrationTime time.Time
callCount map[string]int
errors []string
}
// JobExecution tracks execution of a job
type JobExecution struct {
JobID string
Config []*plugin_pb.ConfigFieldValue
Status string
ProgressPercent int32
Messages []*plugin_pb.JobExecutionMessage
StartTime time.Time
EndTime time.Time
ErrorInfo *plugin_pb.JobFailed
mu sync.RWMutex
}
// NewMockPlugin creates a new mock plugin
func NewMockPlugin(id, name, version string) *MockPlugin {
return &MockPlugin{
ID: id,
Name: name,
Version: version,
ProtocolVersion: "v1",
Capabilities: make([]*plugin_pb.JobTypeCapability, 0),
DetectionEnabled: true,
ExecutionEnabled: true,
DetectionDelay: 100 * time.Millisecond,
ExecutionDelay: 100 * time.Millisecond,
detectedJobs: make(map[string]*plugin_pb.DetectedJob),
executedJobs: make(map[string]*JobExecution),
registrationTime: time.Now(),
callCount: make(map[string]int),
errors: make([]string, 0),
}
}
// AddCapability adds a job type capability to the plugin
func (mp *MockPlugin) AddCapability(jobType string, canDetect, canExecute bool) {
mp.mu.Lock()
defer mp.mu.Unlock()
capability := &plugin_pb.JobTypeCapability{
JobType: jobType,
CanDetect: canDetect,
CanExecute: canExecute,
Version: "v1",
}
mp.Capabilities = append(mp.Capabilities, capability)
}
// GetRegistrationMessage returns the plugin registration message
func (mp *MockPlugin) GetRegistrationMessage() *plugin_pb.PluginMessage {
mp.mu.RLock()
defer mp.mu.RUnlock()
return &plugin_pb.PluginMessage{
Content: &plugin_pb.PluginMessage_Register{
Register: &plugin_pb.PluginRegister{
PluginId: mp.ID,
Name: mp.Name,
Version: mp.Version,
ProtocolVersion: mp.ProtocolVersion,
Capabilities: mp.Capabilities,
},
},
}
}
// GetHeartbeatMessage returns a heartbeat message
func (mp *MockPlugin) GetHeartbeatMessage(pendingJobs, runningJobs int32, cpuUsage, memoryUsage float32) *plugin_pb.PluginMessage {
mp.mu.RLock()
defer mp.mu.RUnlock()
uptime := int64(time.Since(mp.registrationTime).Seconds())
return &plugin_pb.PluginMessage{
Content: &plugin_pb.PluginMessage_Heartbeat{
Heartbeat: &plugin_pb.PluginHeartbeat{
PluginId: mp.ID,
Timestamp: timestamppb.Now(),
UptimeSeconds: uptime,
PendingJobs: pendingJobs,
CpuUsagePercent: cpuUsage,
MemoryUsageMb: memoryUsage,
},
},
}
}
// SimulateDetection simulates job detection
func (mp *MockPlugin) SimulateDetection(jobType string, detectedCount int) ([]*plugin_pb.DetectedJob, error) {
mp.mu.Lock()
defer mp.mu.Unlock()
mp.callCount["detection"]++
if !mp.DetectionEnabled {
err := fmt.Errorf("detection disabled for plugin %s", mp.ID)
mp.errors = append(mp.errors, err.Error())
return nil, err
}
if mp.FailureMode == "detection_error" {
err := fmt.Errorf("simulated detection error")
mp.errors = append(mp.errors, err.Error())
return nil, err
}
time.Sleep(mp.DetectionDelay)
var jobs []*plugin_pb.DetectedJob
now := time.Now()
for i := 0; i < detectedCount; i++ {
jobKey := fmt.Sprintf("detected-job-%s-%d-%d", jobType, now.Unix(), i)
job := &plugin_pb.DetectedJob{
JobKey: jobKey,
JobType: jobType,
Description: fmt.Sprintf("Detected %s job %d", jobType, i),
Priority: int64(10 - i),
Metadata: make(map[string]string),
}
jobKey2 := fmt.Sprintf("%s-%d", jobType, i)
mp.detectedJobs[jobKey2] = job
jobs = append(jobs, job)
}
return jobs, nil
}
// SimulateExecution simulates job execution
func (mp *MockPlugin) SimulateExecution(jobID, jobType string, config []*plugin_pb.ConfigFieldValue) (*JobExecution, error) {
mp.mu.Lock()
mp.callCount["execution"]++
if !mp.ExecutionEnabled {
err := fmt.Errorf("execution disabled for plugin %s", mp.ID)
mp.errors = append(mp.errors, err.Error())
mp.mu.Unlock()
return nil, err
}
if mp.FailureMode == "execution_error" {
err := fmt.Errorf("simulated execution error")
mp.errors = append(mp.errors, err.Error())
mp.mu.Unlock()
return nil, err
}
execution := &JobExecution{
JobID: jobID,
Config: config,
Status: "running",
Messages: make([]*plugin_pb.JobExecutionMessage, 0),
StartTime: time.Now(),
}
mp.executedJobs[jobID] = execution
mp.mu.Unlock()
// Simulate progress updates
for progress := 0; progress <= 100; progress += 25 {
time.Sleep(mp.ExecutionDelay)
mp.mu.Lock()
if exec, ok := mp.executedJobs[jobID]; ok {
exec.mu.Lock()
exec.ProgressPercent = int32(progress)
msg := &plugin_pb.JobExecutionMessage{
JobId: jobID,
Content: &plugin_pb.JobExecutionMessage_Progress{
Progress: &plugin_pb.JobProgress{
ProgressPercent: int32(progress),
CurrentStep: fmt.Sprintf("Step %d", progress/25),
StatusMessage: fmt.Sprintf("Executing step at %d%%", progress),
UpdatedAt: timestamppb.Now(),
},
},
}
exec.Messages = append(exec.Messages, msg)
exec.mu.Unlock()
}
mp.mu.Unlock()
}
mp.mu.Lock()
if exec, ok := mp.executedJobs[jobID]; ok {
exec.mu.Lock()
exec.Status = "completed"
exec.ProgressPercent = 100
exec.EndTime = time.Now()
completed := &plugin_pb.JobExecutionMessage{
JobId: jobID,
Content: &plugin_pb.JobExecutionMessage_JobCompleted{
JobCompleted: &plugin_pb.JobCompleted{
CompletedAt: timestamppb.Now(),
Summary: fmt.Sprintf("Job %s completed successfully", jobID),
Output: map[string]string{
"result": "success",
"job_id": jobID,
},
},
},
}
exec.Messages = append(exec.Messages, completed)
exec.mu.Unlock()
}
mp.mu.Unlock()
return execution, nil
}
// SimulateExecutionFailure simulates a job execution failure
func (mp *MockPlugin) SimulateExecutionFailure(jobID string, errorCode, errorMessage string, retryable bool) (*JobExecution, error) {
mp.mu.Lock()
defer mp.mu.Unlock()
execution := &JobExecution{
JobID: jobID,
Status: "failed",
Messages: make([]*plugin_pb.JobExecutionMessage, 0),
StartTime: time.Now(),
EndTime: time.Now(),
}
failedMsg := &plugin_pb.JobFailed{
ErrorCode: errorCode,
ErrorMessage: errorMessage,
Retryable: retryable,
FailedAt: timestamppb.Now(),
RetryCount: 0,
}
msg := &plugin_pb.JobExecutionMessage{
JobId: jobID,
Content: &plugin_pb.JobExecutionMessage_JobFailed{
JobFailed: failedMsg,
},
}
execution.Messages = append(execution.Messages, msg)
execution.ErrorInfo = failedMsg
mp.executedJobs[jobID] = execution
return execution, nil
}
// GetExecutionMessages returns execution messages for a job
func (mp *MockPlugin) GetExecutionMessages(jobID string) []*plugin_pb.JobExecutionMessage {
mp.mu.RLock()
defer mp.mu.RUnlock()
if exec, ok := mp.executedJobs[jobID]; ok {
exec.mu.RLock()
defer exec.mu.RUnlock()
result := make([]*plugin_pb.JobExecutionMessage, len(exec.Messages))
copy(result, exec.Messages)
return result
}
return nil
}
// GetCallCount returns the number of times a method was called
func (mp *MockPlugin) GetCallCount(method string) int {
mp.mu.RLock()
defer mp.mu.RUnlock()
return mp.callCount[method]
}
// GetErrors returns all recorded errors
func (mp *MockPlugin) GetErrors() []string {
mp.mu.RLock()
defer mp.mu.RUnlock()
result := make([]string, len(mp.errors))
copy(result, mp.errors)
return result
}
// SetFailureMode sets the failure simulation mode
func (mp *MockPlugin) SetFailureMode(mode string) {
mp.mu.Lock()
defer mp.mu.Unlock()
mp.FailureMode = mode
}
// SetDetectionDelay sets the detection simulation delay
func (mp *MockPlugin) SetDetectionDelay(delay time.Duration) {
mp.mu.Lock()
defer mp.mu.Unlock()
mp.DetectionDelay = delay
}
// SetExecutionDelay sets the execution simulation delay
func (mp *MockPlugin) SetExecutionDelay(delay time.Duration) {
mp.mu.Lock()
defer mp.mu.Unlock()
mp.ExecutionDelay = delay
}
// Reset clears all state
func (mp *MockPlugin) Reset() {
mp.mu.Lock()
defer mp.mu.Unlock()
mp.detectedJobs = make(map[string]*plugin_pb.DetectedJob)
mp.executedJobs = make(map[string]*JobExecution)
mp.callCount = make(map[string]int)
mp.errors = make([]string, 0)
mp.FailureMode = ""
}
-256
View File
@@ -1,256 +0,0 @@
package plugin
import (
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/protobuf/types/known/timestamppb"
)
// JobState represents the current state of a job
type JobState int32
const (
JobStatePending JobState = 0
JobStateRunning JobState = 1
JobStateCompleted JobState = 2
JobStateFailed JobState = 3
JobStateCancelled JobState = 4
JobStatePaused JobState = 5
)
// String returns string representation of JobState
func (s JobState) String() string {
switch s {
case JobStatePending:
return "pending"
case JobStateRunning:
return "running"
case JobStateCompleted:
return "completed"
case JobStateFailed:
return "failed"
case JobStateCancelled:
return "cancelled"
case JobStatePaused:
return "paused"
default:
return "unknown"
}
}
// PluginState represents the current state of a plugin connection
type PluginState int32
const (
PluginStateConnecting PluginState = 0
PluginStateConnected PluginState = 1
PluginStateHealthy PluginState = 2
PluginStateUnhealthy PluginState = 3
PluginStateDisconnected PluginState = 4
)
// String returns string representation of PluginState
func (s PluginState) String() string {
switch s {
case PluginStateConnecting:
return "connecting"
case PluginStateConnected:
return "connected"
case PluginStateHealthy:
return "healthy"
case PluginStateUnhealthy:
return "unhealthy"
case PluginStateDisconnected:
return "disconnected"
default:
return "unknown"
}
}
// Job represents a work unit in the plugin system
type Job struct {
ID string
Type string
Description string
Priority int64
State JobState
CreatedAt time.Time
UpdatedAt time.Time
ExecutorID string
ProgressPercent int32
Config []*plugin_pb.ConfigFieldValue
Metadata map[string]string
// Execution tracking
Retries int32
LastRetryTime time.Time
CompletedInfo *plugin_pb.JobCompleted
FailedInfo *plugin_pb.JobFailed
CheckpointIDs []string
LogEntries []*plugin_pb.ExecutionLogEntry
// Lock for thread-safe access
mu sync.RWMutex
}
// ConnectedPlugin represents a connected plugin worker
type ConnectedPlugin struct {
ID string
Name string
Version string
ProtocolVersion string
ConnectedAt time.Time
LastHeartbeat time.Time
State PluginState
Healthy bool
// Capabilities indexed by job type
Capabilities map[string]*plugin_pb.JobTypeCapability
// Current workload
PendingJobs int32
RunningJobs int32
// Resource usage
CPUUsagePercent float32
MemoryUsageMB float32
// Communication stream
StreamConnected bool
// Lock for thread-safe access
mu sync.RWMutex
}
// JobTypeConfig holds configuration for a specific job type
type JobTypeConfig struct {
JobType string
Enabled bool
AdminConfig []*plugin_pb.ConfigFieldValue
WorkerConfig []*plugin_pb.ConfigFieldValue
CreatedAt time.Time
UpdatedAt time.Time
CreatedBy string
// History tracking
DetectionHistory []*DetectionRecord
ExecutionHistory []*ExecutionRecord
mu sync.RWMutex
}
// DetectionRecord holds information about a detection run
type DetectionRecord struct {
Timestamp time.Time
DurationMs int64
JobsDetected int
Status string
Error string
WorkerID string
DetailedLogs []string
}
// ExecutionRecord holds information about a job execution
type ExecutionRecord struct {
JobID string
JobType string
StartedAt time.Time
CompletedAt time.Time
DurationMs int64
Success bool
Error string
WorkerID string
ItemsProcessed int
ExecutionLogs []string
}
// GetState safely gets the job state
func (j *Job) GetState() JobState {
j.mu.RLock()
defer j.mu.RUnlock()
return j.State
}
// SetState safely sets the job state
func (j *Job) SetState(state JobState) {
j.mu.Lock()
defer j.mu.Unlock()
j.State = state
j.UpdatedAt = time.Now()
}
// GetProgress safely gets progress
func (j *Job) GetProgress() int32 {
j.mu.RLock()
defer j.mu.RUnlock()
return j.ProgressPercent
}
// SetProgress safely sets progress
func (j *Job) SetProgress(percent int32) {
j.mu.Lock()
defer j.mu.Unlock()
j.ProgressPercent = percent
j.UpdatedAt = time.Now()
}
// GetState safely gets plugin state
func (p *ConnectedPlugin) GetState() PluginState {
p.mu.RLock()
defer p.mu.RUnlock()
return p.State
}
// SetState safely sets plugin state
func (p *ConnectedPlugin) SetState(state PluginState) {
p.mu.Lock()
defer p.mu.Unlock()
p.State = state
p.LastHeartbeat = time.Now()
}
// IsHealthy safely checks if plugin is healthy
func (p *ConnectedPlugin) IsHealthy() bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.Healthy
}
// UpdateHeartbeat safely updates heartbeat
func (p *ConnectedPlugin) UpdateHeartbeat(cpu, memory float32, pending, running int32) {
p.mu.Lock()
defer p.mu.Unlock()
p.LastHeartbeat = time.Now()
p.CPUUsagePercent = cpu
p.MemoryUsageMB = memory
p.PendingJobs = pending
p.RunningJobs = running
}
// GetConfig safely gets configuration
func (c *JobTypeConfig) GetConfig() (*plugin_pb.JobTypeConfig, error) {
c.mu.RLock()
defer c.mu.RUnlock()
return &plugin_pb.JobTypeConfig{
JobType: c.JobType,
Enabled: c.Enabled,
AdminConfig: c.AdminConfig,
WorkerConfig: c.WorkerConfig,
CreatedAt: timestamppb.New(c.CreatedAt),
UpdatedAt: timestamppb.New(c.UpdatedAt),
CreatedBy: c.CreatedBy,
}, nil
}
// SetConfig safely sets configuration
func (c *JobTypeConfig) SetConfig(enabled bool, adminConfig, workerConfig []*plugin_pb.ConfigFieldValue) {
c.mu.Lock()
defer c.mu.Unlock()
c.Enabled = enabled
c.AdminConfig = adminConfig
c.WorkerConfig = workerConfig
c.UpdatedAt = time.Now()
}
@@ -1,296 +0,0 @@
package balance
import (
"context"
"fmt"
"sort"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/protobuf/types/known/durationpb"
)
// Detector scans for volume distribution imbalance across servers
type Detector struct {
masterAddr string
}
// ServerVolumeInfo represents volume count on a server
type ServerVolumeInfo struct {
ServerID string
Rack string
VolumeCount int32
TotalVolumeGB int64
AvailableGB int64
WriteableCount int32
}
// VolumeInfo represents a volume that can be migrated
type VolumeInfo struct {
ID string
Collection string
SizeGB int64
SourceServer string
TargetServer string
Replicas int32
IsWriteable bool
LastModifiedAt time.Time
}
// NewDetector creates a new balance detector
func NewDetector(masterAddr string) *Detector {
return &Detector{
masterAddr: masterAddr,
}
}
// DetectJobs identifies volume distribution imbalance and returns migration jobs
// Returns DetectedJob items with priority based on imbalance severity
func (d *Detector) DetectJobs(ctx context.Context, config *plugin_pb.JobTypeConfig) ([]*plugin_pb.DetectedJob, error) {
var detectedJobs []*plugin_pb.DetectedJob
// Extract configuration parameters
imbalanceThreshold := float64(20) // default 20%
minServers := int32(2) // default 2
preferRackDiversity := false // default false
for _, cfv := range config.AdminConfig {
if cfv.FieldName == "imbalanceThreshold" {
imbalanceThreshold = float64(cfv.IntValue)
} else if cfv.FieldName == "minServers" {
minServers = int32(cfv.IntValue)
} else if cfv.FieldName == "preferRackDiversity" {
preferRackDiversity = cfv.BoolValue
}
}
glog.Infof("balance detector: scanning volume distribution (threshold=%.1f%%, minServers=%d, rackDiversity=%v)",
imbalanceThreshold, minServers, preferRackDiversity)
// Get server and volume information
serverInfo, volumes := d.scanServerVolumes(ctx)
if int32(len(serverInfo)) < minServers {
glog.Infof("balance detector: insufficient servers (%d < %d), skipping detection",
len(serverInfo), minServers)
return detectedJobs, nil
}
// Calculate volume distribution imbalance
imbalance, maxServer, minServer := d.calculateImbalance(serverInfo)
glog.Infof("balance detector: current imbalance=%.1f%%, max=%d volumes on %s, min=%d volumes on %s",
imbalance, maxServer.VolumeCount, maxServer.ServerID, minServer.VolumeCount, minServer.ServerID)
// Check if imbalance exceeds threshold
if imbalance <= imbalanceThreshold {
glog.Infof("balance detector: imbalance %.1f%% is within threshold %.1f%%, no rebalancing needed",
imbalance, imbalanceThreshold)
return detectedJobs, nil
}
glog.Infof("balance detector: imbalance %.1f%% exceeds threshold %.1f%%, triggering rebalancing",
imbalance, imbalanceThreshold)
// Generate migration jobs to reduce imbalance
migrations := d.generateMigrations(serverInfo, volumes, preferRackDiversity)
now := time.Now()
for i, migration := range migrations {
priority := int64((imbalance * 1000)) - int64(i*10) // Higher imbalance = higher priority
jobKey := fmt.Sprintf("balance_%s_to_%s_%s", migration.SourceServer, migration.TargetServer, now.Format("20060102150405"))
job := &plugin_pb.DetectedJob{
JobKey: jobKey,
JobType: "balance",
Description: fmt.Sprintf("Migrate volume %s from %s to %s (imbalance reduction: %.1f%%)",
migration.ID, migration.SourceServer, migration.TargetServer, imbalance),
Priority: priority,
EstimatedDuration: durationpb.New(time.Duration(migration.SizeGB) * time.Second),
Metadata: map[string]string{
"volume_id": migration.ID,
"collection": migration.Collection,
"source_server": migration.SourceServer,
"target_server": migration.TargetServer,
"volume_size_gb": fmt.Sprintf("%d", migration.SizeGB),
"imbalance": fmt.Sprintf("%.2f", imbalance),
"rack_diversity": fmt.Sprintf("%v", preferRackDiversity),
},
SuggestedConfig: []*plugin_pb.ConfigFieldValue{},
}
detectedJobs = append(detectedJobs, job)
glog.Infof("balance detector: detected migration job %s (priority=%d)", migration.ID, priority)
}
glog.Infof("balance detector: found %d migration jobs to reduce imbalance", len(detectedJobs))
return detectedJobs, nil
}
// calculateImbalance calculates volume distribution imbalance
// imbalance = (max_volumes - min_volumes) / avg_volumes * 100
func (d *Detector) calculateImbalance(servers []ServerVolumeInfo) (imbalance float64, maxServer, minServer ServerVolumeInfo) {
if len(servers) == 0 {
return 0, ServerVolumeInfo{}, ServerVolumeInfo{}
}
// Sort by volume count to find min and max
sorted := make([]ServerVolumeInfo, len(servers))
copy(sorted, servers)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].VolumeCount < sorted[j].VolumeCount
})
minServer = sorted[0]
maxServer = sorted[len(sorted)-1]
// Calculate average
totalVolumes := int32(0)
for _, s := range servers {
totalVolumes += s.VolumeCount
}
avgVolumes := float64(totalVolumes) / float64(len(servers))
// Avoid division by zero
if avgVolumes == 0 {
return 0, maxServer, minServer
}
// Calculate imbalance percentage
imbalance = float64(maxServer.VolumeCount-minServer.VolumeCount) / avgVolumes * 100
// Ensure it's not negative
if imbalance < 0 {
imbalance = 0
}
return imbalance, maxServer, minServer
}
// generateMigrations generates a list of volume migrations to reduce imbalance
func (d *Detector) generateMigrations(servers []ServerVolumeInfo, volumes []VolumeInfo, preferRackDiversity bool) []VolumeInfo {
var migrations []VolumeInfo
// Sort servers by volume count (descending)
serversCopy := make([]ServerVolumeInfo, len(servers))
copy(serversCopy, servers)
sort.Slice(serversCopy, func(i, j int) bool {
return serversCopy[i].VolumeCount > serversCopy[j].VolumeCount
})
// Calculate target volume count (average)
totalVolumes := int32(0)
for _, s := range serversCopy {
totalVolumes += s.VolumeCount
}
targetPerServer := totalVolumes / int32(len(serversCopy))
// For each overloaded server, select volumes to migrate
for _, sourceServer := range serversCopy {
if sourceServer.VolumeCount <= targetPerServer {
break // Rest are balanced
}
volumesToMove := sourceServer.VolumeCount - targetPerServer
// Find candidate volumes on this server
for _, vol := range volumes {
if volumesToMove <= 0 {
break
}
if vol.SourceServer != sourceServer.ServerID {
continue
}
// Skip read-only volumes
if !vol.IsWriteable {
continue
}
// Find a target server (underloaded, different if rack diversity preferred)
targetServer := d.selectTargetServer(serversCopy, sourceServer, preferRackDiversity)
if targetServer == nil {
continue
}
migration := VolumeInfo{
ID: vol.ID,
Collection: vol.Collection,
SizeGB: vol.SizeGB,
SourceServer: sourceServer.ServerID,
TargetServer: targetServer.ServerID,
Replicas: vol.Replicas,
IsWriteable: vol.IsWriteable,
}
migrations = append(migrations, migration)
volumesToMove--
// Update server counts
sourceServer.VolumeCount--
targetServer.VolumeCount++
}
}
// Sort by volume size (largest first for priority)
sort.Slice(migrations, func(i, j int) bool {
return migrations[i].SizeGB > migrations[j].SizeGB
})
return migrations
}
// selectTargetServer finds a suitable target server for migration
func (d *Detector) selectTargetServer(servers []ServerVolumeInfo, sourceServer ServerVolumeInfo, preferRackDiversity bool) *ServerVolumeInfo {
// Sort by volume count (ascending) - pick least loaded
sort.Slice(servers, func(i, j int) bool {
return servers[i].VolumeCount < servers[j].VolumeCount
})
for i := range servers {
server := &servers[i]
// Skip source server
if server.ServerID == sourceServer.ServerID {
continue
}
// If rack diversity preferred, try to pick different rack
if preferRackDiversity && server.Rack == sourceServer.Rack {
continue
}
return server
}
// If rack diversity is preferred but all servers are in same rack, fallback to least loaded
if preferRackDiversity {
for i := range servers {
server := &servers[i]
if server.ServerID != sourceServer.ServerID {
return server
}
}
}
return nil
}
// scanServerVolumes performs a scan of servers and volumes from the master
// This is a placeholder that would connect to master in production
func (d *Detector) scanServerVolumes(ctx context.Context) ([]ServerVolumeInfo, []VolumeInfo) {
// TODO: Connect to master server at d.masterAddr and get:
// 1. List of data nodes with their rack information
// 2. Volume distribution across nodes
// 3. Volume metadata (size, collection, writeable status)
//
// For now, return empty lists as a framework
var servers []ServerVolumeInfo
var volumes []VolumeInfo
return servers, volumes
}
@@ -1,326 +0,0 @@
package balance
import (
"context"
"fmt"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Executor handles the 5-step volume migration execution process
type Executor struct {
jobID string
sourceServer string
targetServer string
volumeID string
config *plugin_pb.JobTypeConfig
parallelMigrations int32
maxBytesPerSecond int64
}
// ExecutionStep represents a single step in the migration process
type ExecutionStep struct {
StepNumber int
Name string
Description string
}
// MigrationState tracks the state of a migration
type MigrationState struct {
mu sync.Mutex
volumeReadonly bool
volumeCopied bool
volumeMounted bool
tailStarted bool
sourceDeleted bool
bytesTransferred int64
}
// NewExecutor creates a new balance executor
func NewExecutor(jobID string, config *plugin_pb.JobTypeConfig) *Executor {
parallelMigrations := int32(2)
maxBytesPerSecond := int64(0)
for _, cfv := range config.WorkerConfig {
if cfv.FieldName == "parallelMigrations" {
parallelMigrations = int32(cfv.IntValue)
} else if cfv.FieldName == "maxBytesPerSecond" {
maxBytesPerSecond = cfv.IntValue
}
}
return &Executor{
jobID: jobID,
config: config,
parallelMigrations: parallelMigrations,
maxBytesPerSecond: maxBytesPerSecond,
}
}
// Execute runs the 5-step volume migration process
// Step 1: markVolumeReadonly - Make source volume read-only
// Step 2: copyVolume - Copy to target server
// Step 3: mountVolume - Mount on target
// Step 4: tailUpdates - Tail live updates from source
// Step 5: deleteSourceVolume - Delete from source if balanced
// Returns progress updates via the progressChan
func (e *Executor) Execute(ctx context.Context, metadata map[string]string, progressChan chan<- *plugin_pb.JobProgress) error {
e.volumeID = metadata["volume_id"]
e.sourceServer = metadata["source_server"]
e.targetServer = metadata["target_server"]
glog.Infof("balance executor job=%s: starting migration (volume=%s, %s -> %s)",
e.jobID, e.volumeID, e.sourceServer, e.targetServer)
state := &MigrationState{}
steps := []ExecutionStep{
{StepNumber: 1, Name: "markVolumeReadonly", Description: "Make source volume read-only"},
{StepNumber: 2, Name: "copyVolume", Description: "Copy volume data to target server"},
{StepNumber: 3, Name: "mountVolume", Description: "Mount volume on target server"},
{StepNumber: 4, Name: "tailUpdates", Description: "Tail live updates from source"},
{StepNumber: 5, Name: "deleteSourceVolume", Description: "Delete volume from source server"},
}
totalSteps := len(steps)
for i, step := range steps {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
glog.Infof("balance executor job=%s: executing step %d: %s", e.jobID, step.StepNumber, step.Name)
// Send progress update before executing step
progress := int32((i * 100) / totalSteps)
progressChan <- &plugin_pb.JobProgress{
ProgressPercent: progress,
CurrentStep: step.Name,
StatusMessage: step.Description,
UpdatedAt: timestamppb.Now(),
}
var err error
switch step.StepNumber {
case 1:
err = e.markVolumeReadonly(ctx, state)
case 2:
err = e.copyVolume(ctx, state, progressChan)
case 3:
err = e.mountVolume(ctx, state)
case 4:
err = e.tailUpdates(ctx, state, progressChan)
case 5:
err = e.deleteSourceVolume(ctx, state)
}
if err != nil {
glog.Errorf("balance executor job=%s: step %d failed: %v", e.jobID, step.StepNumber, err)
// Attempt to rollback on error
e.rollbackMigration(ctx, state)
return fmt.Errorf("step %d (%s) failed: %w", step.StepNumber, step.Name, err)
}
glog.Infof("balance executor job=%s: step %d completed", e.jobID, step.StepNumber)
}
// Send final progress update
progressChan <- &plugin_pb.JobProgress{
ProgressPercent: 100,
CurrentStep: "complete",
StatusMessage: "Volume migration completed successfully",
UpdatedAt: timestamppb.Now(),
}
glog.Infof("balance executor job=%s: migration completed (volume=%s, transferred=%dB)",
e.jobID, e.volumeID, state.bytesTransferred)
return nil
}
// Step 1: markVolumeReadonly makes the source volume read-only to prepare for migration
func (e *Executor) markVolumeReadonly(ctx context.Context, state *MigrationState) error {
glog.Infof("balance executor job=%s: marking volume %s as read-only on %s",
e.jobID, e.volumeID, e.sourceServer)
// TODO: Connect to source server and mark volume as read-only
// 1. Get volume server connection
// 2. Send MarkVolumeReadonly RPC
// 3. Wait for confirmation
// 4. Verify volume is read-only
state.mu.Lock()
state.volumeReadonly = true
state.mu.Unlock()
glog.Infof("balance executor job=%s: volume %s is now read-only", e.jobID, e.volumeID)
return nil
}
// Step 2: copyVolume copies volume data from source to target server
func (e *Executor) copyVolume(ctx context.Context, state *MigrationState, progressChan chan<- *plugin_pb.JobProgress) error {
glog.Infof("balance executor job=%s: starting volume copy from %s to %s (bandwidth=%dB/s)",
e.jobID, e.sourceServer, e.targetServer, e.maxBytesPerSecond)
// TODO: Connect to both source and target servers and perform copy
// 1. Get volume metadata from source (size, collection, replicas)
// 2. Create volume on target server
// 3. Stream volume data from source to target
// 4. Respect bandwidth limit if maxBytesPerSecond > 0
// 5. Periodically send progress updates via progressChan
// 6. Verify checksum after transfer
// Simulate progress updates
startTime := time.Now()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
elapsed := time.Since(startTime)
// Simulate byte transfer (would be actual in production)
state.mu.Lock()
state.bytesTransferred = int64(elapsed.Seconds()) * 1024 * 1024 // 1MB/s simulation
state.mu.Unlock()
if elapsed > 5*time.Second {
// Copy complete after 5 seconds in simulation
state.mu.Lock()
state.volumeCopied = true
state.mu.Unlock()
glog.Infof("balance executor job=%s: volume copy completed (%dB transferred)",
e.jobID, state.bytesTransferred)
return nil
}
}
}
}
// Step 3: mountVolume mounts the copied volume on the target server
func (e *Executor) mountVolume(ctx context.Context, state *MigrationState) error {
glog.Infof("balance executor job=%s: mounting volume %s on target server %s",
e.jobID, e.volumeID, e.targetServer)
state.mu.Lock()
if !state.volumeCopied {
state.mu.Unlock()
return fmt.Errorf("volume not copied yet")
}
state.mu.Unlock()
// TODO: Connect to target server and mount the volume
// 1. Send MountVolume RPC with volume ID and collection
// 2. Wait for mount to complete
// 3. Verify volume is accessible and readable
state.mu.Lock()
state.volumeMounted = true
state.mu.Unlock()
glog.Infof("balance executor job=%s: volume %s mounted on %s", e.jobID, e.volumeID, e.targetServer)
return nil
}
// Step 4: tailUpdates tails live updates from source to target to keep them in sync
func (e *Executor) tailUpdates(ctx context.Context, state *MigrationState, progressChan chan<- *plugin_pb.JobProgress) error {
glog.Infof("balance executor job=%s: starting to tail updates from %s to %s",
e.jobID, e.sourceServer, e.targetServer)
state.mu.Lock()
if !state.volumeMounted {
state.mu.Unlock()
return fmt.Errorf("volume not mounted on target")
}
state.mu.Unlock()
// TODO: Stream updates from source to target
// 1. Get update stream from source volume
// 2. Apply updates to target volume
// 3. Handle concurrent writes
// 4. Report tail progress
// Simulate tail for a short duration
for i := 0; i < 3; i++ {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
state.mu.Lock()
state.tailStarted = true
state.mu.Unlock()
progressChan <- &plugin_pb.JobProgress{
ProgressPercent: int32(70 + i*5),
CurrentStep: "tailUpdates",
StatusMessage: fmt.Sprintf("Applying updates... (%d updates processed)", (i+1)*100),
UpdatedAt: timestamppb.Now(),
}
}
}
glog.Infof("balance executor job=%s: tail updates completed", e.jobID)
return nil
}
// Step 5: deleteSourceVolume deletes the volume from the source server after verification
func (e *Executor) deleteSourceVolume(ctx context.Context, state *MigrationState) error {
glog.Infof("balance executor job=%s: deleting volume %s from source server %s",
e.jobID, e.volumeID, e.sourceServer)
state.mu.Lock()
if !state.tailStarted {
state.mu.Unlock()
return fmt.Errorf("tail updates not completed")
}
state.mu.Unlock()
// TODO: Delete volume from source server
// 1. Verify target volume is healthy and in sync
// 2. Verify no new writes can occur to source volume
// 3. Send DeleteVolume RPC to source server
// 4. Wait for deletion to complete
// 5. Verify source volume is removed
state.mu.Lock()
state.sourceDeleted = true
state.mu.Unlock()
glog.Infof("balance executor job=%s: volume %s deleted from source %s", e.jobID, e.volumeID, e.sourceServer)
return nil
}
// rollbackMigration attempts to rollback the migration if an error occurs
func (e *Executor) rollbackMigration(ctx context.Context, state *MigrationState) {
glog.Warningf("balance executor job=%s: attempting rollback (readonly=%v, copied=%v, mounted=%v, deleted=%v)",
e.jobID, state.volumeReadonly, state.volumeCopied, state.volumeMounted, state.sourceDeleted)
// Only rollback if we've started but not completed
state.mu.Lock()
defer state.mu.Unlock()
// If volume was deleted, we can't fully rollback
if state.sourceDeleted {
glog.Errorf("balance executor job=%s: cannot rollback - volume already deleted from source", e.jobID)
return
}
// If target was mounted, unmount it
if state.volumeMounted {
glog.Infof("balance executor job=%s: unmounting target volume", e.jobID)
// TODO: Send UnmountVolume RPC to target server
}
// If source was made read-only, mark it as writable again
if state.volumeReadonly {
glog.Infof("balance executor job=%s: restoring source volume to writable", e.jobID)
// TODO: Send MarkVolumeWritable RPC to source server
}
glog.Infof("balance executor job=%s: rollback completed", e.jobID)
}
-137
View File
@@ -1,137 +0,0 @@
package balance
import (
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
// GetConfigurationSchema returns the configuration schema for the balance job type
func GetConfigurationSchema() *plugin_pb.JobTypeConfigSchema {
return &plugin_pb.JobTypeConfigSchema{
JobType: "balance",
Version: "v1",
Description: "Automatically balance volume distribution across servers to optimize storage and performance",
AdminFields: []*plugin_pb.ConfigField{
{
Name: "imbalanceThreshold",
Label: "Imbalance Threshold (%)",
Description: "Trigger rebalancing when imbalance exceeds this percentage (1-100)",
FieldType: plugin_pb.ConfigField_INT,
Required: true,
DefaultValue: "20",
ValidationRules: []*plugin_pb.ValidationRule{
{
RuleType: plugin_pb.ValidationRule_MIN_VALUE,
Value: "1",
ErrorMessage: "Imbalance threshold must be at least 1",
},
{
RuleType: plugin_pb.ValidationRule_MAX_VALUE,
Value: "100",
ErrorMessage: "Imbalance threshold must be at most 100",
},
},
OptionsMsg: &plugin_pb.ConfigField_Options{
MinValue: 1,
MaxValue: 100,
},
},
{
Name: "minServers",
Label: "Minimum Servers",
Description: "Only rebalance when cluster has at least this many servers (2-10)",
FieldType: plugin_pb.ConfigField_INT,
Required: false,
DefaultValue: "2",
ValidationRules: []*plugin_pb.ValidationRule{
{
RuleType: plugin_pb.ValidationRule_MIN_VALUE,
Value: "2",
ErrorMessage: "Minimum servers must be at least 2",
},
{
RuleType: plugin_pb.ValidationRule_MAX_VALUE,
Value: "10",
ErrorMessage: "Minimum servers must be at most 10",
},
},
OptionsMsg: &plugin_pb.ConfigField_Options{
MinValue: 2,
MaxValue: 10,
},
},
{
Name: "preferRackDiversity",
Label: "Prefer Rack Diversity",
Description: "Consider rack locations when balancing to improve fault tolerance",
FieldType: plugin_pb.ConfigField_BOOL,
Required: false,
DefaultValue: "false",
},
},
WorkerFields: []*plugin_pb.ConfigField{
{
Name: "parallelMigrations",
Label: "Parallel Migrations",
Description: "Number of concurrent volume migrations to perform (1-10)",
FieldType: plugin_pb.ConfigField_INT,
Required: false,
DefaultValue: "2",
ValidationRules: []*plugin_pb.ValidationRule{
{
RuleType: plugin_pb.ValidationRule_MIN_VALUE,
Value: "1",
ErrorMessage: "Parallel migrations must be at least 1",
},
{
RuleType: plugin_pb.ValidationRule_MAX_VALUE,
Value: "10",
ErrorMessage: "Parallel migrations must be at most 10",
},
},
OptionsMsg: &plugin_pb.ConfigField_Options{
MinValue: 1,
MaxValue: 10,
},
},
{
Name: "maxBytesPerSecond",
Label: "Max Bytes Per Second",
Description: "Limit migration bandwidth (0 = unlimited, in bytes/sec)",
FieldType: plugin_pb.ConfigField_INT,
Required: false,
DefaultValue: "0",
ValidationRules: []*plugin_pb.ValidationRule{
{
RuleType: plugin_pb.ValidationRule_MIN_VALUE,
Value: "0",
ErrorMessage: "Max bytes per second must be non-negative",
},
},
OptionsMsg: &plugin_pb.ConfigField_Options{
MinValue: 0,
},
},
},
FieldGroups: []*plugin_pb.ConfigGroup{
{
Name: "Balance Settings",
Label: "Balance Settings",
Description: "Configuration for volume rebalancing",
FieldNames: []string{
"imbalanceThreshold",
"minServers",
"preferRackDiversity",
},
},
{
Name: "advanced",
Label: "Advanced",
Description: "Advanced migration parameters",
FieldNames: []string{
"parallelMigrations",
"maxBytesPerSecond",
},
},
},
}
}
-368
View File
@@ -1,368 +0,0 @@
package balance
import (
"context"
"fmt"
"io"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Worker is the main balance plugin worker
type Worker struct {
id string
name string
version string
masterAddr string
httpPort int
// gRPC connection
conn *grpc.ClientConn
client plugin_pb.PluginServiceClient
// Detector and executor
detector *Detector
// Context and coordination
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
// Running jobs
jobsMu sync.RWMutex
runningJobs map[string]context.CancelFunc
}
// NewWorker creates a new balance worker
func NewWorker(id, masterAddr string, httpPort int) *Worker {
return &Worker{
id: id,
name: "balance_worker",
version: "v1",
masterAddr: masterAddr,
httpPort: httpPort,
runningJobs: make(map[string]context.CancelFunc),
detector: NewDetector(masterAddr),
}
}
// Start starts the worker and connects to the admin server
func (w *Worker) Start(ctx context.Context) error {
glog.Infof("balance worker: starting (id=%s, master=%s, httpPort=%d)", w.id, w.masterAddr, w.httpPort)
w.ctx, w.cancel = context.WithCancel(ctx)
// Connect to admin server via gRPC
// Admin server runs on httpPort + 10000
adminPort := w.httpPort + 10000
adminAddr := fmt.Sprintf("localhost:%d", adminPort)
glog.Infof("balance worker: connecting to admin at %s", adminAddr)
conn, err := grpc.Dial(adminAddr, grpc.WithInsecure())
if err != nil {
return fmt.Errorf("failed to connect to admin server: %w", err)
}
w.conn = conn
w.client = plugin_pb.NewPluginServiceClient(conn)
// Start connection handler in goroutine
w.wg.Add(1)
go w.handleConnection()
return nil
}
// Stop stops the worker and closes all connections
func (w *Worker) Stop() error {
glog.Infof("balance worker: stopping (id=%s)", w.id)
// Cancel context to signal all goroutines
if w.cancel != nil {
w.cancel()
}
// Wait for goroutines with timeout
done := make(chan struct{})
go func() {
w.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
glog.Warningf("balance worker: graceful shutdown timeout")
}
// Close gRPC connection
if w.conn != nil {
if err := w.conn.Close(); err != nil {
glog.Errorf("balance worker: error closing gRPC connection: %v", err)
}
}
glog.Infof("balance worker: stopped")
return nil
}
// handleConnection handles the bidirectional gRPC connection with the admin server
func (w *Worker) handleConnection() {
defer w.wg.Done()
glog.Infof("balance worker: establishing bidirectional stream")
// Create bidirectional stream
stream, err := w.client.Connect(w.ctx)
if err != nil {
glog.Errorf("balance worker: failed to create stream: %v", err)
return
}
// Send registration message
if err := w.sendRegistration(stream); err != nil {
glog.Errorf("balance worker: failed to send registration: %v", err)
return
}
// Start heartbeat goroutine
w.wg.Add(1)
go w.sendHeartbeats(stream)
// Start job executor goroutine
w.wg.Add(1)
go w.executeJobs(stream)
// Listen for messages from admin
w.listenForMessages(stream)
glog.Infof("balance worker: connection closed")
}
// sendRegistration sends the initial registration message
func (w *Worker) sendRegistration(stream plugin_pb.PluginService_ConnectClient) error {
capabilities := []*plugin_pb.JobTypeCapability{
{
JobType: "balance",
CanDetect: true,
CanExecute: true,
Version: "v1",
},
}
register := &plugin_pb.PluginRegister{
PluginId: w.id,
Name: w.name,
Version: w.version,
ProtocolVersion: "v1",
Capabilities: capabilities,
}
msg := &plugin_pb.PluginMessage{
Content: &plugin_pb.PluginMessage_Register{
Register: register,
},
}
return stream.Send(msg)
}
// sendHeartbeats sends periodic heartbeat messages
func (w *Worker) sendHeartbeats(stream plugin_pb.PluginService_ConnectClient) {
defer w.wg.Done()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-w.ctx.Done():
return
case <-ticker.C:
w.jobsMu.RLock()
pendingJobs := int32(len(w.runningJobs))
w.jobsMu.RUnlock()
heartbeat := &plugin_pb.PluginHeartbeat{
PluginId: w.id,
Timestamp: timestamppb.Now(),
UptimeSeconds: int64(time.Since(time.Now()).Seconds()),
PendingJobs: pendingJobs,
CpuUsagePercent: 0, // TODO: Get actual CPU usage
MemoryUsageMb: 0, // TODO: Get actual memory usage
}
msg := &plugin_pb.PluginMessage{
Content: &plugin_pb.PluginMessage_Heartbeat{
Heartbeat: heartbeat,
},
}
if err := stream.Send(msg); err != nil {
glog.Errorf("balance worker: failed to send heartbeat: %v", err)
return
}
}
}
}
// executeJobs periodically detects and executes jobs
func (w *Worker) executeJobs(stream plugin_pb.PluginService_ConnectClient) {
defer w.wg.Done()
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-w.ctx.Done():
return
case <-ticker.C:
// TODO: Implement job detection and execution
// Query admin for jobs or use detector to find jobs
}
}
}
// listenForMessages listens for messages from the admin server
func (w *Worker) listenForMessages(stream plugin_pb.PluginService_ConnectClient) {
for {
msg, err := stream.Recv()
if err == io.EOF {
glog.Infof("balance worker: admin closed connection")
return
}
if err != nil {
glog.Errorf("balance worker: failed to receive message: %v", err)
return
}
if msg.Content == nil {
continue
}
switch content := msg.Content.(type) {
case *plugin_pb.AdminMessage_JobRequest:
w.handleJobRequest(content.JobRequest)
case *plugin_pb.AdminMessage_ConfigUpdate:
glog.Infof("balance worker: received config update: %s", content.ConfigUpdate.JobType)
case *plugin_pb.AdminMessage_AdminCommand:
w.handleAdminCommand(content.AdminCommand)
}
}
}
// handleJobRequest processes a job request from the admin
func (w *Worker) handleJobRequest(jobReq *plugin_pb.JobRequest) {
glog.Infof("balance worker: received job request (id=%s, type=%s)", jobReq.JobId, jobReq.JobType)
// Create job context
jobCtx, cancel := context.WithCancel(w.ctx)
w.jobsMu.Lock()
w.runningJobs[jobReq.JobId] = cancel
w.jobsMu.Unlock()
// Execute job in goroutine
w.wg.Add(1)
go func() {
defer w.wg.Done()
defer func() {
w.jobsMu.Lock()
delete(w.runningJobs, jobReq.JobId)
w.jobsMu.Unlock()
}()
w.executeJobRequest(jobCtx, jobReq)
}()
}
// executeJobRequest executes a single job request
func (w *Worker) executeJobRequest(ctx context.Context, jobReq *plugin_pb.JobRequest) {
// Build job config from request
config := &plugin_pb.JobTypeConfig{
JobType: jobReq.JobType,
AdminConfig: nil,
WorkerConfig: jobReq.Config,
}
// Create executor
executor := NewExecutor(jobReq.JobId, config)
// Progress channel
progressChan := make(chan *plugin_pb.JobProgress, 10)
// Execute job
go func() {
if err := executor.Execute(ctx, jobReq.Metadata, progressChan); err != nil {
glog.Errorf("balance worker: job execution failed (id=%s): %v", jobReq.JobId, err)
}
close(progressChan)
}()
// TODO: Send progress updates back to admin via ExecuteJob RPC
for progress := range progressChan {
_ = progress
glog.Infof("balance worker: job %s progress: %d%% (%s)", jobReq.JobId, progress.ProgressPercent, progress.CurrentStep)
}
}
// handleAdminCommand processes admin commands
func (w *Worker) handleAdminCommand(cmd *plugin_pb.AdminCommand) {
glog.Infof("balance worker: received admin command: %v", cmd.CommandType)
switch cmd.CommandType {
case plugin_pb.AdminCommand_RELOAD_CONFIG:
glog.Infof("balance worker: reloading configuration")
case plugin_pb.AdminCommand_ENABLE_JOB_TYPE:
glog.Infof("balance worker: enabling balance job type")
case plugin_pb.AdminCommand_DISABLE_JOB_TYPE:
glog.Infof("balance worker: disabling balance job type")
case plugin_pb.AdminCommand_SHUTDOWN:
glog.Infof("balance worker: received shutdown command")
w.Stop()
}
}
// GetCapabilities returns the worker's capabilities
func (w *Worker) GetCapabilities() []*plugin_pb.JobTypeCapability {
return []*plugin_pb.JobTypeCapability{
{
JobType: "balance",
CanDetect: true,
CanExecute: true,
Version: "v1",
},
}
}
// GetConfigurationSchema returns the configuration schema
func (w *Worker) GetConfigurationSchema() *plugin_pb.JobTypeConfigSchema {
return GetConfigurationSchema()
}
// Run is a convenience method that starts the worker and waits for context cancellation
func (w *Worker) Run(ctx context.Context) error {
if err := w.Start(ctx); err != nil {
return err
}
defer w.Stop()
<-ctx.Done()
return nil
}
// StartWorkerServer starts the balance worker with the given configuration
func StartWorkerServer(masterAddr string, httpPort int) error {
pluginID := fmt.Sprintf("balance_worker_%d", time.Now().Unix())
worker := NewWorker(pluginID, masterAddr, httpPort)
ctx := context.Background()
return worker.Run(ctx)
}
@@ -1,140 +0,0 @@
package erasure_coding
import (
"context"
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/protobuf/types/known/durationpb"
)
// Detector scans for volumes that are candidates for erasure coding
type Detector struct {
masterAddr string
}
// NewDetector creates a new erasure coding detector
func NewDetector(masterAddr string) *Detector {
return &Detector{
masterAddr: masterAddr,
}
}
// DetectJobs scans the master for volumes meeting EC criteria
// Returns a list of DetectedJob items sorted by priority (volume size)
func (d *Detector) DetectJobs(ctx context.Context, config *plugin_pb.JobTypeConfig) ([]*plugin_pb.DetectedJob, error) {
var detectedJobs []*plugin_pb.DetectedJob
// Get destination nodes from config
var destinationNodes []string
var ecM, ecN, stripeSize int64
for _, cfv := range config.AdminConfig {
if cfv.FieldName == "destination_data_nodes" {
if cfv.StringValue != "" {
// Parse comma-separated nodes
destinationNodes = append(destinationNodes, cfv.StringValue)
}
}
}
for _, cfv := range config.WorkerConfig {
if cfv.FieldName == "ec_m" {
ecM = cfv.IntValue
} else if cfv.FieldName == "ec_n" {
ecN = cfv.IntValue
} else if cfv.FieldName == "stripe_size" {
stripeSize = cfv.IntValue
}
}
if len(destinationNodes) == 0 {
glog.Warningf("erasure_coding detector: no destination nodes configured")
return detectedJobs, nil
}
if ecM == 0 {
ecM = 10
}
if ecN == 0 {
ecN = 4
}
if stripeSize == 0 {
stripeSize = 65536
}
glog.Infof("erasure_coding detector: scanning for volumes (ecM=%d, ecN=%d)", ecM, ecN)
// Scan master for volumes
// This would typically connect to the master server to get volume information
// For now, we create a framework that can be extended with actual master connectivity
volumes := d.scanVolumes(ctx)
for _, vol := range volumes {
// Check if volume is a candidate for EC
// Criteria: less than 90% full, not already encoded
if vol.fullnessPercent < 90 && !vol.isEncoded {
jobKey := fmt.Sprintf("ec_%s_%s", vol.id, time.Now().Format("20060102"))
// Priority is based on volume size (larger volumes get higher priority)
priority := int64(vol.sizeGB)
job := &plugin_pb.DetectedJob{
JobKey: jobKey,
JobType: "erasure_coding",
Description: fmt.Sprintf("Encode volume %s (%.1f%% full, %dGB)", vol.id, vol.fullnessPercent, vol.sizeGB),
Priority: priority,
EstimatedDuration: durationpb.New(time.Duration(vol.sizeGB) * time.Hour), // Rough estimate
Metadata: map[string]string{
"volume_id": vol.id,
"collection": vol.collection,
"fullness_pct": fmt.Sprintf("%.1f", vol.fullnessPercent),
"size_gb": fmt.Sprintf("%d", vol.sizeGB),
"data_nodes": fmt.Sprintf("%d", len(destinationNodes)),
},
SuggestedConfig: []*plugin_pb.ConfigFieldValue{
{
FieldName: "ec_m",
IntValue: ecM,
},
{
FieldName: "ec_n",
IntValue: ecN,
},
{
FieldName: "stripe_size",
IntValue: stripeSize,
},
},
}
detectedJobs = append(detectedJobs, job)
}
}
glog.Infof("erasure_coding detector: found %d candidate volumes", len(detectedJobs))
return detectedJobs, nil
}
// Volume represents a volume on the cluster
type volume struct {
id string
collection string
sizeGB int64
usedGB int64
fullnessPercent float64
isEncoded bool
replicaPlacement int32
dataNodes []string
}
// scanVolumes performs a scan of available volumes
// This is a placeholder implementation that would connect to master in production
func (d *Detector) scanVolumes(ctx context.Context) []volume {
// TODO: Connect to master server at d.masterAddr and get volume list
// For now, return empty list as a framework
var volumes []volume
return volumes
}
@@ -1,406 +0,0 @@
package erasure_coding
import (
"context"
stdtesting "testing"
"time"
plugintesting "github.com/seaweedfs/seaweedfs/weed/admin/plugin/testing"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
// TestECSchemaGeneration tests that the EC plugin generates the correct configuration schema
func TestECSchemaGeneration(t *stdtesting.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
harness := plugintesting.NewTestHarness(ctx)
if err := harness.Setup(); err != nil {
t.Fatalf("Setup failed: %v", err)
}
defer harness.Teardown()
// Verify schema fields for EC job type
expectedFields := map[string]bool{
"data_shards": true,
"parity_shards": true,
"block_size": true,
"algorithm": true,
}
configFields := []*plugin_pb.ConfigField{
{
Name: "data_shards",
Label: "Data Shards",
Description: "Number of data shards",
FieldType: plugin_pb.ConfigField_INT,
Required: true,
DefaultValue: "4",
},
{
Name: "parity_shards",
Label: "Parity Shards",
Description: "Number of parity shards",
FieldType: plugin_pb.ConfigField_INT,
Required: true,
DefaultValue: "2",
},
{
Name: "block_size",
Label: "Block Size",
Description: "Block size in bytes",
FieldType: plugin_pb.ConfigField_INT,
Required: false,
DefaultValue: "65536",
},
{
Name: "algorithm",
Label: "Algorithm",
Description: "Erasure coding algorithm",
FieldType: plugin_pb.ConfigField_SELECT,
Required: true,
DefaultValue: "reed_solomon",
},
}
// Verify all expected fields are present
for _, field := range configFields {
if _, ok := expectedFields[field.Name]; ok {
if field.FieldType == plugin_pb.ConfigField_INT ||
field.FieldType == plugin_pb.ConfigField_SELECT {
delete(expectedFields, field.Name)
}
}
}
if len(expectedFields) > 0 {
t.Errorf("Missing expected schema fields: %v", expectedFields)
}
}
// TestECDetection tests that the EC plugin can detect jobs
func TestECDetection(t *stdtesting.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
harness := plugintesting.NewTestHarness(ctx)
if err := harness.Setup(); err != nil {
t.Fatalf("Setup failed: %v", err)
}
defer harness.Teardown()
// Create mock plugin
plugin := plugintesting.NewMockPlugin("ec-plugin-1", "Erasure Coding", "1.0.0")
plugin.AddCapability("erasure_coding", true, true)
// Simulate detection
detectedJobs, err := plugin.SimulateDetection("erasure_coding", 3)
if err != nil {
t.Fatalf("Detection failed: %v", err)
}
if len(detectedJobs) != 3 {
t.Errorf("Expected 3 detected jobs, got %d", len(detectedJobs))
}
for i, job := range detectedJobs {
if job.JobType != "erasure_coding" {
t.Errorf("Job %d has wrong type: expected erasure_coding, got %s", i, job.JobType)
}
if job.JobKey == "" {
t.Errorf("Job %d missing job key", i)
}
}
}
// TestECExecution tests that the EC plugin can execute jobs
func TestECExecution(t *stdtesting.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
harness := plugintesting.NewTestHarness(ctx)
if err := harness.Setup(); err != nil {
t.Fatalf("Setup failed: %v", err)
}
defer harness.Teardown()
// Create mock plugin
plugin := plugintesting.NewMockPlugin("ec-plugin-2", "Erasure Coding", "1.0.0")
plugin.AddCapability("erasure_coding", true, true)
// Create a test job
config := []*plugin_pb.ConfigFieldValue{
{
FieldName: "data_shards",
IntValue: 4,
},
{
FieldName: "parity_shards",
IntValue: 2,
},
}
jobID := "ec-job-test-001"
execution, err := plugin.SimulateExecution(jobID, "erasure_coding", config)
if err != nil {
t.Fatalf("Execution failed: %v", err)
}
if execution.Status != "completed" {
t.Errorf("Expected status 'completed', got %s", execution.Status)
}
if execution.ProgressPercent != 100 {
t.Errorf("Expected progress 100, got %d", execution.ProgressPercent)
}
messages := plugin.GetExecutionMessages(jobID)
if len(messages) == 0 {
t.Fatal("Expected execution messages")
}
// Check for completion message
hasCompletion := false
for _, msg := range messages {
if msg.GetJobCompleted() != nil {
hasCompletion = true
break
}
}
if !hasCompletion {
t.Error("Missing job completion message")
}
}
// TestECErrorHandling tests error scenarios in EC plugin
func TestECErrorHandling(t *stdtesting.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
harness := plugintesting.NewTestHarness(ctx)
if err := harness.Setup(); err != nil {
t.Fatalf("Setup failed: %v", err)
}
defer harness.Teardown()
// Test 1: Detection disabled
plugin := plugintesting.NewMockPlugin("ec-plugin-3", "Erasure Coding", "1.0.0")
plugin.AddCapability("erasure_coding", true, true)
plugin.DetectionEnabled = false
_, err := plugin.SimulateDetection("erasure_coding", 1)
if err == nil {
t.Error("Expected error when detection is disabled")
}
// Test 2: Execution failure scenario
plugin.Reset()
plugin.DetectionEnabled = true
plugin.ExecutionEnabled = true
jobID := "ec-job-fail-001"
execution, err := plugin.SimulateExecutionFailure(jobID, "INVALID_CONFIG", "Data shards must be > 0", true)
if err != nil {
t.Fatalf("Execution failure simulation failed: %v", err)
}
if execution.Status != "failed" {
t.Errorf("Expected status 'failed', got %s", execution.Status)
}
if execution.ErrorInfo.ErrorCode != "INVALID_CONFIG" {
t.Errorf("Expected error code 'INVALID_CONFIG', got %s", execution.ErrorInfo.ErrorCode)
}
if !execution.ErrorInfo.Retryable {
t.Error("Expected error to be retryable")
}
// Test 3: Simulated detection error
plugin.SetFailureMode("detection_error")
_, err = plugin.SimulateDetection("erasure_coding", 1)
if err == nil {
t.Error("Expected error in detection with failure mode set")
}
errors := plugin.GetErrors()
if len(errors) == 0 {
t.Error("Expected recorded errors")
}
}
// TestECIntegration tests the full EC plugin workflow
func TestECIntegration(t *stdtesting.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
harness := plugintesting.NewTestHarness(ctx)
if err := harness.Setup(); err != nil {
t.Fatalf("Setup failed: %v", err)
}
defer harness.Teardown()
// Create mock admin server
adminServer := harness.GetAdminServer()
_ = adminServer // Keep reference to prevent GC
// Create mock plugin
plugin := plugintesting.NewMockPlugin("ec-plugin-integration", "Erasure Coding", "1.0.0")
plugin.AddCapability("erasure_coding", true, true)
// Step 1: Plugin registration
regMsg := plugin.GetRegistrationMessage()
_ = regMsg // Keep reference
// Step 2: Detect jobs
detectedJobs, err := plugin.SimulateDetection("erasure_coding", 2)
if err != nil {
t.Fatalf("Detection failed: %v", err)
}
if len(detectedJobs) != 2 {
t.Errorf("Expected 2 detected jobs, got %d", len(detectedJobs))
}
// Step 3: Create jobs from detection
for i, detectedJob := range detectedJobs {
job := harness.CreateJob(
"erasure_coding",
detectedJob.Description,
detectedJob.Priority,
detectedJob.SuggestedConfig,
)
// Step 4: Execute job with plugin simulation
_, err := plugin.SimulateExecution(job.JobId, "erasure_coding", job.Config)
if err != nil {
t.Errorf("Failed to execute job %d: %v", i, err)
continue
}
// Track in harness
_ = harness.ExecuteJob(job)
// Complete the job
if err := harness.CompleteJob(job.JobId, "Erasure coding completed", map[string]string{
"blocks_encoded": "1000",
}); err != nil {
t.Errorf("Failed to complete job %d: %v", i, err)
}
// Verify job status
if err := harness.AssertJobStatus(job.JobId, "completed"); err != nil {
t.Errorf("Job %d status verification failed: %v", i, err)
}
// Verify progress
if err := harness.VerifyProgress(job.JobId, 100); err != nil {
t.Errorf("Job %d progress verification failed: %v", i, err)
}
}
// Verify call counts
detectionCalls := plugin.GetCallCount("detection")
if detectionCalls == 0 {
t.Error("Expected at least one detection call")
}
executionCalls := plugin.GetCallCount("execution")
if executionCalls != 2 {
t.Errorf("Expected 2 execution calls, got %d", executionCalls)
}
}
// TestECConfigurationValidation tests configuration field validation
func TestECConfigurationValidation(t *stdtesting.T) {
tests := []struct {
name string
config *plugin_pb.ConfigFieldValue
wantErr bool
}{
{
name: "valid data shards",
config: &plugin_pb.ConfigFieldValue{
FieldName: "data_shards",
IntValue: 4,
},
wantErr: false,
},
{
name: "invalid data shards - zero",
config: &plugin_pb.ConfigFieldValue{
FieldName: "data_shards",
IntValue: 0,
},
wantErr: true,
},
{
name: "valid parity shards",
config: &plugin_pb.ConfigFieldValue{
FieldName: "parity_shards",
IntValue: 2,
},
wantErr: false,
},
{
name: "negative parity shards",
config: &plugin_pb.ConfigFieldValue{
FieldName: "parity_shards",
IntValue: -1,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *stdtesting.T) {
// Validate configuration
var hasError bool
// Simple validation: data_shards > 0, parity_shards >= 0
if tt.config.FieldName == "data_shards" && tt.config.IntValue <= 0 {
hasError = true
}
if tt.config.FieldName == "parity_shards" && tt.config.IntValue < 0 {
hasError = true
}
if hasError != tt.wantErr {
t.Errorf("validation error: expected %v, got %v", tt.wantErr, hasError)
}
})
}
}
// BenchmarkECDetection benchmarks the detection performance
func BenchmarkECDetection(b *stdtesting.B) {
plugin := plugintesting.NewMockPlugin("ec-plugin-bench", "Erasure Coding", "1.0.0")
plugin.AddCapability("erasure_coding", true, true)
plugin.SetDetectionDelay(0) // No delay for benchmark
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = plugin.SimulateDetection("erasure_coding", 10)
}
}
// BenchmarkECExecution benchmarks the execution performance
func BenchmarkECExecution(b *stdtesting.B) {
plugin := plugintesting.NewMockPlugin("ec-plugin-bench-exec", "Erasure Coding", "1.0.0")
plugin.AddCapability("erasure_coding", true, true)
plugin.SetExecutionDelay(0) // No delay for benchmark
config := []*plugin_pb.ConfigFieldValue{
{
FieldName: "data_shards",
IntValue: 4,
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
jobID := "bench-job-" + string(rune(i))
_, _ = plugin.SimulateExecution(jobID, "erasure_coding", config)
}
}
@@ -1,211 +0,0 @@
package erasure_coding
import (
"context"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Executor handles the 6-step erasure coding execution process
type Executor struct {
jobID string
volumeID string
config *plugin_pb.JobTypeConfig
}
// NewExecutor creates a new erasure coding executor
func NewExecutor(jobID string, config *plugin_pb.JobTypeConfig) *Executor {
return &Executor{
jobID: jobID,
config: config,
}
}
// ExecutionStep represents a single step in the EC process
type ExecutionStep struct {
StepNumber int
Name string
Description string
}
// Execute runs the 6-step erasure coding process
// Returns progress updates via the progressChan
func (e *Executor) Execute(ctx context.Context, metadata map[string]string, progressChan chan<- *plugin_pb.JobProgress) error {
volumeID := metadata["volume_id"]
e.volumeID = volumeID
steps := []ExecutionStep{
{StepNumber: 1, Name: "markVolumeReadonly", Description: "Mark volume as read-only"},
{StepNumber: 2, Name: "copyVolumeFilesToWorker", Description: "Copy volume files to worker"},
{StepNumber: 3, Name: "generateEcShards", Description: "Generate erasure coding shards"},
{StepNumber: 4, Name: "distributeEcShards", Description: "Distribute shards to data nodes"},
{StepNumber: 5, Name: "mountEcShards", Description: "Mount shards on target nodes"},
{StepNumber: 6, Name: "deleteOriginalVolume", Description: "Delete original volume"},
}
totalSteps := len(steps)
for i, step := range steps {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
glog.Infof("erasure_coding executor job=%s: executing step %d: %s", e.jobID, step.StepNumber, step.Name)
// Send progress update before executing step
progress := int32((i * 100) / totalSteps)
progressChan <- &plugin_pb.JobProgress{
ProgressPercent: progress,
CurrentStep: step.Name,
StatusMessage: step.Description,
UpdatedAt: timestamppb.Now(),
}
var err error
switch step.StepNumber {
case 1:
err = e.markVolumeReadonly(ctx)
case 2:
err = e.copyVolumeFilesToWorker(ctx)
case 3:
err = e.generateEcShards(ctx)
case 4:
err = e.distributeEcShards(ctx)
case 5:
err = e.mountEcShards(ctx)
case 6:
err = e.deleteOriginalVolume(ctx)
}
if err != nil {
glog.Errorf("erasure_coding executor job=%s: step %d failed: %v", e.jobID, step.StepNumber, err)
return fmt.Errorf("step %d (%s) failed: %w", step.StepNumber, step.Name, err)
}
glog.Infof("erasure_coding executor job=%s: step %d completed", e.jobID, step.StepNumber)
}
// Send final progress update
progressChan <- &plugin_pb.JobProgress{
ProgressPercent: 100,
CurrentStep: "complete",
StatusMessage: "Erasure coding completed successfully",
UpdatedAt: timestamppb.Now(),
}
glog.Infof("erasure_coding executor job=%s: all steps completed", e.jobID)
return nil
}
// Step 1: markVolumeReadonly marks the volume as read-only
func (e *Executor) markVolumeReadonly(ctx context.Context) error {
glog.Infof("erasure_coding executor job=%s: marking volume %s as read-only", e.jobID, e.volumeID)
// TODO: Connect to master and mark volume as read-only
// This prevents new writes during encoding
return nil
}
// Step 2: copyVolumeFilesToWorker copies volume data to the worker
func (e *Executor) copyVolumeFilesToWorker(ctx context.Context) error {
glog.Infof("erasure_coding executor job=%s: copying volume files for %s", e.jobID, e.volumeID)
// TODO: Transfer volume files from storage nodes to worker
// This includes the .idx and .dat files
return nil
}
// Step 3: generateEcShards generates erasure coding shards
func (e *Executor) generateEcShards(ctx context.Context) error {
glog.Infof("erasure_coding executor job=%s: generating EC shards for volume %s", e.jobID, e.volumeID)
// Extract config values
var ecM, ecN, stripeSize int64
for _, cfv := range e.config.WorkerConfig {
if cfv.FieldName == "ec_m" {
ecM = cfv.IntValue
} else if cfv.FieldName == "ec_n" {
ecN = cfv.IntValue
} else if cfv.FieldName == "stripe_size" {
stripeSize = cfv.IntValue
}
}
if ecM == 0 {
ecM = 10
}
if ecN == 0 {
ecN = 4
}
if stripeSize == 0 {
stripeSize = 65536
}
glog.Infof("erasure_coding executor job=%s: encoding with M=%d, N=%d, stripe_size=%d", e.jobID, ecM, ecN, stripeSize)
// TODO: Use libReedSolomon or similar library to generate EC shards
// This creates M data shards and N parity shards
return nil
}
// Step 4: distributeEcShards distributes shards to data nodes
func (e *Executor) distributeEcShards(ctx context.Context) error {
glog.Infof("erasure_coding executor job=%s: distributing EC shards to destination nodes", e.jobID)
// Extract destination nodes from config
var destinationNodes []string
for _, cfv := range e.config.AdminConfig {
if cfv.FieldName == "destination_data_nodes" {
if cfv.StringValue != "" {
destinationNodes = append(destinationNodes, cfv.StringValue)
}
}
}
glog.Infof("erasure_coding executor job=%s: distributing to %d destination nodes", e.jobID, len(destinationNodes))
// TODO: Send shards to each destination data node
// Balance shards across available nodes
return nil
}
// Step 5: mountEcShards mounts the shards on their target nodes
func (e *Executor) mountEcShards(ctx context.Context) error {
glog.Infof("erasure_coding executor job=%s: mounting EC shards", e.jobID)
// TODO: Notify data nodes to mount the EC shards
// Register shards with master server
return nil
}
// Step 6: deleteOriginalVolume deletes the original volume if configured
func (e *Executor) deleteOriginalVolume(ctx context.Context) error {
// Check if delete_source is enabled
var deleteSource bool
for _, cfv := range e.config.AdminConfig {
if cfv.FieldName == "delete_source" {
deleteSource = cfv.BoolValue
}
}
if !deleteSource {
glog.Infof("erasure_coding executor job=%s: skipping deletion of original volume (delete_source=false)", e.jobID)
return nil
}
glog.Infof("erasure_coding executor job=%s: deleting original volume %s", e.jobID, e.volumeID)
// TODO: Delete the original volume from the master
// This frees up space on the storage nodes
return nil
}
@@ -1,162 +0,0 @@
package erasure_coding
import (
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
// GetConfigurationSchema returns the configuration schema for the erasure coding job type
func GetConfigurationSchema() *plugin_pb.JobTypeConfigSchema {
return &plugin_pb.JobTypeConfigSchema{
JobType: "erasure_coding",
Version: "v1",
Description: "Automatically encode volumes using erasure coding for improved storage efficiency",
AdminFields: []*plugin_pb.ConfigField{
{
Name: "destination_data_nodes",
Label: "Destination Data Nodes",
Description: "List of data nodes where EC shards should be distributed",
FieldType: plugin_pb.ConfigField_STRING,
Required: true,
DefaultValue: "",
ValidationRules: []*plugin_pb.ValidationRule{
{
RuleType: plugin_pb.ValidationRule_MIN_LENGTH,
Value: "1",
ErrorMessage: "At least one destination node must be specified",
},
},
OptionsMsg: &plugin_pb.ConfigField_Options{
Placeholder: "node1,node2,node3",
},
},
{
Name: "threshold",
Label: "Failure Threshold",
Description: "Number of data shards that can be lost before data is unrecoverable (1-99)",
FieldType: plugin_pb.ConfigField_INT,
Required: true,
DefaultValue: "2",
ValidationRules: []*plugin_pb.ValidationRule{
{
RuleType: plugin_pb.ValidationRule_MIN_VALUE,
Value: "1",
ErrorMessage: "Threshold must be at least 1",
},
{
RuleType: plugin_pb.ValidationRule_MAX_VALUE,
Value: "99",
ErrorMessage: "Threshold must be at most 99",
},
},
OptionsMsg: &plugin_pb.ConfigField_Options{
MinValue: 1,
MaxValue: 99,
},
},
{
Name: "delete_source",
Label: "Delete Source After Encoding",
Description: "Whether to delete the original volume after successful EC encoding",
FieldType: plugin_pb.ConfigField_BOOL,
Required: false,
DefaultValue: "true",
},
},
WorkerFields: []*plugin_pb.ConfigField{
{
Name: "ec_m",
Label: "Data Shards (M)",
Description: "Number of data shards for erasure coding",
FieldType: plugin_pb.ConfigField_INT,
Required: true,
DefaultValue: "10",
ValidationRules: []*plugin_pb.ValidationRule{
{
RuleType: plugin_pb.ValidationRule_MIN_VALUE,
Value: "1",
ErrorMessage: "Data shards must be at least 1",
},
{
RuleType: plugin_pb.ValidationRule_MAX_VALUE,
Value: "32",
ErrorMessage: "Data shards must be at most 32",
},
},
OptionsMsg: &plugin_pb.ConfigField_Options{
MinValue: 1,
MaxValue: 32,
},
},
{
Name: "ec_n",
Label: "Parity Shards (N)",
Description: "Number of parity shards for erasure coding",
FieldType: plugin_pb.ConfigField_INT,
Required: true,
DefaultValue: "4",
ValidationRules: []*plugin_pb.ValidationRule{
{
RuleType: plugin_pb.ValidationRule_MIN_VALUE,
Value: "1",
ErrorMessage: "Parity shards must be at least 1",
},
{
RuleType: plugin_pb.ValidationRule_MAX_VALUE,
Value: "32",
ErrorMessage: "Parity shards must be at most 32",
},
},
OptionsMsg: &plugin_pb.ConfigField_Options{
MinValue: 1,
MaxValue: 32,
},
},
{
Name: "stripe_size",
Label: "Stripe Size (bytes)",
Description: "Size of data chunks used in erasure coding (must be power of 2)",
FieldType: plugin_pb.ConfigField_INT,
Required: true,
DefaultValue: "65536",
ValidationRules: []*plugin_pb.ValidationRule{
{
RuleType: plugin_pb.ValidationRule_MIN_VALUE,
Value: "4096",
ErrorMessage: "Stripe size must be at least 4096 bytes",
},
{
RuleType: plugin_pb.ValidationRule_MAX_VALUE,
Value: "1048576",
ErrorMessage: "Stripe size must be at most 1MB",
},
},
OptionsMsg: &plugin_pb.ConfigField_Options{
MinValue: 4096,
MaxValue: 1048576,
},
},
},
FieldGroups: []*plugin_pb.ConfigGroup{
{
Name: "general",
Label: "General",
Description: "Basic configuration for erasure coding",
FieldNames: []string{
"destination_data_nodes",
"threshold",
"delete_source",
},
},
{
Name: "advanced",
Label: "Advanced",
Description: "Advanced erasure coding parameters",
FieldNames: []string{
"ec_m",
"ec_n",
"stripe_size",
},
},
},
}
}
@@ -1,368 +0,0 @@
package erasure_coding
import (
"context"
"fmt"
"io"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Worker is the main erasure coding plugin worker
type Worker struct {
id string
name string
version string
masterAddr string
httpPort int
// gRPC connection
conn *grpc.ClientConn
client plugin_pb.PluginServiceClient
// Detector and executor
detector *Detector
// Context and coordination
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
// Running jobs
jobsMu sync.RWMutex
runningJobs map[string]context.CancelFunc
}
// NewWorker creates a new erasure coding worker
func NewWorker(id, masterAddr string, httpPort int) *Worker {
return &Worker{
id: id,
name: "erasure_coding_worker",
version: "v1",
masterAddr: masterAddr,
httpPort: httpPort,
runningJobs: make(map[string]context.CancelFunc),
detector: NewDetector(masterAddr),
}
}
// Start starts the worker and connects to the admin server
func (w *Worker) Start(ctx context.Context) error {
glog.Infof("erasure_coding worker: starting (id=%s, master=%s, httpPort=%d)", w.id, w.masterAddr, w.httpPort)
w.ctx, w.cancel = context.WithCancel(ctx)
// Connect to admin server via gRPC
// Admin server runs on httpPort + 10000
adminPort := w.httpPort + 10000
adminAddr := fmt.Sprintf("localhost:%d", adminPort)
glog.Infof("erasure_coding worker: connecting to admin at %s", adminAddr)
conn, err := grpc.Dial(adminAddr, grpc.WithInsecure())
if err != nil {
return fmt.Errorf("failed to connect to admin server: %w", err)
}
w.conn = conn
w.client = plugin_pb.NewPluginServiceClient(conn)
// Start connection handler in goroutine
w.wg.Add(1)
go w.handleConnection()
return nil
}
// Stop stops the worker and closes all connections
func (w *Worker) Stop() error {
glog.Infof("erasure_coding worker: stopping (id=%s)", w.id)
// Cancel context to signal all goroutines
if w.cancel != nil {
w.cancel()
}
// Wait for goroutines with timeout
done := make(chan struct{})
go func() {
w.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
glog.Warningf("erasure_coding worker: graceful shutdown timeout")
}
// Close gRPC connection
if w.conn != nil {
if err := w.conn.Close(); err != nil {
glog.Errorf("erasure_coding worker: error closing gRPC connection: %v", err)
}
}
glog.Infof("erasure_coding worker: stopped")
return nil
}
// handleConnection handles the bidirectional gRPC connection with the admin server
func (w *Worker) handleConnection() {
defer w.wg.Done()
glog.Infof("erasure_coding worker: establishing bidirectional stream")
// Create bidirectional stream
stream, err := w.client.Connect(w.ctx)
if err != nil {
glog.Errorf("erasure_coding worker: failed to create stream: %v", err)
return
}
// Send registration message
if err := w.sendRegistration(stream); err != nil {
glog.Errorf("erasure_coding worker: failed to send registration: %v", err)
return
}
// Start heartbeat goroutine
w.wg.Add(1)
go w.sendHeartbeats(stream)
// Start job executor goroutine
w.wg.Add(1)
go w.executeJobs(stream)
// Listen for messages from admin
w.listenForMessages(stream)
glog.Infof("erasure_coding worker: connection closed")
}
// sendRegistration sends the initial registration message
func (w *Worker) sendRegistration(stream plugin_pb.PluginService_ConnectClient) error {
capabilities := []*plugin_pb.JobTypeCapability{
{
JobType: "erasure_coding",
CanDetect: true,
CanExecute: true,
Version: "v1",
},
}
register := &plugin_pb.PluginRegister{
PluginId: w.id,
Name: w.name,
Version: w.version,
ProtocolVersion: "v1",
Capabilities: capabilities,
}
msg := &plugin_pb.PluginMessage{
Content: &plugin_pb.PluginMessage_Register{
Register: register,
},
}
return stream.Send(msg)
}
// sendHeartbeats sends periodic heartbeat messages
func (w *Worker) sendHeartbeats(stream plugin_pb.PluginService_ConnectClient) {
defer w.wg.Done()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-w.ctx.Done():
return
case <-ticker.C:
w.jobsMu.RLock()
pendingJobs := int32(len(w.runningJobs))
w.jobsMu.RUnlock()
heartbeat := &plugin_pb.PluginHeartbeat{
PluginId: w.id,
Timestamp: timestamppb.Now(),
UptimeSeconds: int64(time.Since(time.Now()).Seconds()),
PendingJobs: pendingJobs,
CpuUsagePercent: 0, // TODO: Get actual CPU usage
MemoryUsageMb: 0, // TODO: Get actual memory usage
}
msg := &plugin_pb.PluginMessage{
Content: &plugin_pb.PluginMessage_Heartbeat{
Heartbeat: heartbeat,
},
}
if err := stream.Send(msg); err != nil {
glog.Errorf("erasure_coding worker: failed to send heartbeat: %v", err)
return
}
}
}
}
// executeJobs periodically detects and executes jobs
func (w *Worker) executeJobs(stream plugin_pb.PluginService_ConnectClient) {
defer w.wg.Done()
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-w.ctx.Done():
return
case <-ticker.C:
// TODO: Implement job detection and execution
// Query admin for jobs or use detector to find jobs
}
}
}
// listenForMessages listens for messages from the admin server
func (w *Worker) listenForMessages(stream plugin_pb.PluginService_ConnectClient) {
for {
msg, err := stream.Recv()
if err == io.EOF {
glog.Infof("erasure_coding worker: admin closed connection")
return
}
if err != nil {
glog.Errorf("erasure_coding worker: failed to receive message: %v", err)
return
}
if msg.Content == nil {
continue
}
switch content := msg.Content.(type) {
case *plugin_pb.AdminMessage_JobRequest:
w.handleJobRequest(content.JobRequest)
case *plugin_pb.AdminMessage_ConfigUpdate:
glog.Infof("erasure_coding worker: received config update: %s", content.ConfigUpdate.JobType)
case *plugin_pb.AdminMessage_AdminCommand:
w.handleAdminCommand(content.AdminCommand)
}
}
}
// handleJobRequest processes a job request from the admin
func (w *Worker) handleJobRequest(jobReq *plugin_pb.JobRequest) {
glog.Infof("erasure_coding worker: received job request (id=%s, type=%s)", jobReq.JobId, jobReq.JobType)
// Create job context
jobCtx, cancel := context.WithCancel(w.ctx)
w.jobsMu.Lock()
w.runningJobs[jobReq.JobId] = cancel
w.jobsMu.Unlock()
// Execute job in goroutine
w.wg.Add(1)
go func() {
defer w.wg.Done()
defer func() {
w.jobsMu.Lock()
delete(w.runningJobs, jobReq.JobId)
w.jobsMu.Unlock()
}()
w.executeJobRequest(jobCtx, jobReq)
}()
}
// executeJobRequest executes a single job request
func (w *Worker) executeJobRequest(ctx context.Context, jobReq *plugin_pb.JobRequest) {
// Build job config from request
config := &plugin_pb.JobTypeConfig{
JobType: jobReq.JobType,
AdminConfig: nil,
WorkerConfig: jobReq.Config,
}
// Create executor
executor := NewExecutor(jobReq.JobId, config)
// Progress channel
progressChan := make(chan *plugin_pb.JobProgress, 10)
// Execute job
go func() {
if err := executor.Execute(ctx, jobReq.Metadata, progressChan); err != nil {
glog.Errorf("erasure_coding worker: job execution failed (id=%s): %v", jobReq.JobId, err)
}
close(progressChan)
}()
// TODO: Send progress updates back to admin via ExecuteJob RPC
for progress := range progressChan {
_ = progress
glog.Infof("erasure_coding worker: job %s progress: %d%%", jobReq.JobId, progress.ProgressPercent)
}
}
// handleAdminCommand processes admin commands
func (w *Worker) handleAdminCommand(cmd *plugin_pb.AdminCommand) {
glog.Infof("erasure_coding worker: received admin command: %v", cmd.CommandType)
switch cmd.CommandType {
case plugin_pb.AdminCommand_RELOAD_CONFIG:
glog.Infof("erasure_coding worker: reloading configuration")
case plugin_pb.AdminCommand_ENABLE_JOB_TYPE:
glog.Infof("erasure_coding worker: enabling erasure_coding job type")
case plugin_pb.AdminCommand_DISABLE_JOB_TYPE:
glog.Infof("erasure_coding worker: disabling erasure_coding job type")
case plugin_pb.AdminCommand_SHUTDOWN:
glog.Infof("erasure_coding worker: received shutdown command")
w.Stop()
}
}
// GetCapabilities returns the worker's capabilities
func (w *Worker) GetCapabilities() []*plugin_pb.JobTypeCapability {
return []*plugin_pb.JobTypeCapability{
{
JobType: "erasure_coding",
CanDetect: true,
CanExecute: true,
Version: "v1",
},
}
}
// GetConfigurationSchema returns the configuration schema
func (w *Worker) GetConfigurationSchema() *plugin_pb.JobTypeConfigSchema {
return GetConfigurationSchema()
}
// Run is a convenience method that starts the worker and waits for context cancellation
func (w *Worker) Run(ctx context.Context) error {
if err := w.Start(ctx); err != nil {
return err
}
defer w.Stop()
<-ctx.Done()
return nil
}
// StartWorkerServer starts the erasure coding worker with the given configuration
func StartWorkerServer(masterAddr string, httpPort int) error {
pluginID := fmt.Sprintf("ec_worker_%d", time.Now().Unix())
worker := NewWorker(pluginID, masterAddr, httpPort)
ctx := context.Background()
return worker.Run(ctx)
}
@@ -1,146 +0,0 @@
package vacuum
import (
"context"
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/protobuf/types/known/durationpb"
)
// Detector scans for volumes that are candidates for vacuuming
type Detector struct {
masterAddr string
}
// VolumeInfo represents volume information for vacuum detection
type VolumeInfo struct {
ID string
Collection string
SizeGB int64
UsedGB int64
GarbageRatio float64
CreatedAt time.Time
LastVacuumAt time.Time
Replicas int32
}
// NewDetector creates a new vacuum detector
func NewDetector(masterAddr string) *Detector {
return &Detector{
masterAddr: masterAddr,
}
}
// DetectJobs scans for volumes with high garbage ratio that meet vacuum criteria
// Returns a list of DetectedJob items sorted by garbage size (highest priority first)
func (d *Detector) DetectJobs(ctx context.Context, config *plugin_pb.JobTypeConfig) ([]*plugin_pb.DetectedJob, error) {
var detectedJobs []*plugin_pb.DetectedJob
// Extract configuration parameters
garbageThreshold := float64(30) // default 30%
minVolumeAge := 24 * time.Hour // default 24h
minInterval := 7 * 24 * time.Hour // default 7d
compactLevel := int64(2)
enableCleanup := true
for _, cfv := range config.AdminConfig {
if cfv.FieldName == "garbageThreshold" {
garbageThreshold = float64(cfv.IntValue)
} else if cfv.FieldName == "minVolumeAge" {
if dur, err := time.ParseDuration(cfv.StringValue); err == nil {
minVolumeAge = dur
}
} else if cfv.FieldName == "minInterval" {
if dur, err := time.ParseDuration(cfv.StringValue); err == nil {
minInterval = dur
}
}
}
for _, cfv := range config.WorkerConfig {
if cfv.FieldName == "compactLevel" {
compactLevel = cfv.IntValue
} else if cfv.FieldName == "enableCleanup" {
enableCleanup = cfv.BoolValue
}
}
glog.Infof("vacuum detector: scanning volumes (threshold=%.1f%%, minVolumeAge=%v, minInterval=%v)",
garbageThreshold, minVolumeAge, minInterval)
// Scan volumes from master
volumes := d.scanVolumes(ctx)
now := time.Now()
for _, vol := range volumes {
// Check if volume meets garbage ratio threshold
if vol.GarbageRatio < garbageThreshold/100.0 {
continue
}
// Check if volume is old enough
volumeAge := now.Sub(vol.CreatedAt)
if volumeAge < minVolumeAge {
continue
}
// Check if minimum interval has passed since last vacuum
timeSinceLastVacuum := now.Sub(vol.LastVacuumAt)
if timeSinceLastVacuum < minInterval {
continue
}
// Calculate priority based on garbage size (larger garbage = higher priority)
garbageGB := float64(vol.UsedGB) * vol.GarbageRatio
priority := int64(garbageGB * 1000) // Scale to get better ordering
jobKey := fmt.Sprintf("vacuum_%s_%s", vol.ID, now.Format("20060102150405"))
job := &plugin_pb.DetectedJob{
JobKey: jobKey,
JobType: "vacuum",
Description: fmt.Sprintf("Vacuum volume %s (garbage ratio %.1f%%, %dGB used, %.1fGB garbage)",
vol.ID, vol.GarbageRatio*100, vol.UsedGB, garbageGB),
Priority: priority,
EstimatedDuration: durationpb.New(time.Duration(vol.UsedGB) * time.Minute), // Rough estimate
Metadata: map[string]string{
"volume_id": vol.ID,
"collection": vol.Collection,
"garbage_ratio": fmt.Sprintf("%.2f", vol.GarbageRatio),
"garbage_size_gb": fmt.Sprintf("%.1f", garbageGB),
"used_gb": fmt.Sprintf("%d", vol.UsedGB),
"volume_age": volumeAge.String(),
"last_vacuum_ago": timeSinceLastVacuum.String(),
},
SuggestedConfig: []*plugin_pb.ConfigFieldValue{
{
FieldName: "compactLevel",
IntValue: compactLevel,
},
{
FieldName: "enableCleanup",
BoolValue: enableCleanup,
},
},
}
detectedJobs = append(detectedJobs, job)
glog.Infof("vacuum detector: detected job for volume %s (garbage=%.1fGB, priority=%d)",
vol.ID, garbageGB, priority)
}
glog.Infof("vacuum detector: found %d volumes to vacuum", len(detectedJobs))
return detectedJobs, nil
}
// scanVolumes performs a scan of available volumes from the master
// This is a placeholder that would connect to master in production
func (d *Detector) scanVolumes(ctx context.Context) []VolumeInfo {
// TODO: Connect to master server at d.masterAddr and get volume list
// For now, return empty list as a framework
var volumes []VolumeInfo
return volumes
}
@@ -1,186 +0,0 @@
package vacuum
import (
"context"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Executor handles the 3-step vacuum execution process
type Executor struct {
jobID string
volumeID string
config *plugin_pb.JobTypeConfig
}
// ExecutionStep represents a single step in the vacuum process
type ExecutionStep struct {
StepNumber int
Name string
Description string
}
// NewExecutor creates a new vacuum executor
func NewExecutor(jobID string, config *plugin_pb.JobTypeConfig) *Executor {
return &Executor{
jobID: jobID,
config: config,
}
}
// Execute runs the 3-step vacuum process
// Step 1: vacuumVolumeCheck - Verify conditions are still met
// Step 2: vacuumVolumeCompact - Compact and defragment volume
// Step 3: vacuumVolumeCleanup - Clean up deleted space if enableCleanup is true
// Returns progress updates via the progressChan
func (e *Executor) Execute(ctx context.Context, metadata map[string]string, progressChan chan<- *plugin_pb.JobProgress) error {
volumeID := metadata["volume_id"]
e.volumeID = volumeID
steps := []ExecutionStep{
{StepNumber: 1, Name: "vacuumVolumeCheck", Description: "Verify vacuum conditions are still met"},
{StepNumber: 2, Name: "vacuumVolumeCompact", Description: "Compact and defragment volume data"},
{StepNumber: 3, Name: "vacuumVolumeCleanup", Description: "Clean up deleted space"},
}
totalSteps := len(steps)
for i, step := range steps {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
glog.Infof("vacuum executor job=%s: executing step %d: %s", e.jobID, step.StepNumber, step.Name)
// Send progress update before executing step
progress := int32((i * 100) / totalSteps)
progressChan <- &plugin_pb.JobProgress{
ProgressPercent: progress,
CurrentStep: step.Name,
StatusMessage: step.Description,
UpdatedAt: timestamppb.Now(),
}
var err error
switch step.StepNumber {
case 1:
err = e.vacuumVolumeCheck(ctx)
case 2:
err = e.vacuumVolumeCompact(ctx)
case 3:
err = e.vacuumVolumeCleanup(ctx)
}
if err != nil {
glog.Errorf("vacuum executor job=%s: step %d failed: %v", e.jobID, step.StepNumber, err)
return fmt.Errorf("step %d (%s) failed: %w", step.StepNumber, step.Name, err)
}
glog.Infof("vacuum executor job=%s: step %d completed", e.jobID, step.StepNumber)
}
// Send final progress update
progressChan <- &plugin_pb.JobProgress{
ProgressPercent: 100,
CurrentStep: "complete",
StatusMessage: "Vacuum completed successfully",
UpdatedAt: timestamppb.Now(),
}
glog.Infof("vacuum executor job=%s: all steps completed", e.jobID)
return nil
}
// Step 1: vacuumVolumeCheck verifies that vacuum conditions are still met
// before proceeding with the operation
func (e *Executor) vacuumVolumeCheck(ctx context.Context) error {
glog.Infof("vacuum executor job=%s: checking vacuum eligibility for volume %s", e.jobID, e.volumeID)
// Extract garbage threshold from config
var garbageThreshold float64 = 30
for _, cfv := range e.config.AdminConfig {
if cfv.FieldName == "garbageThreshold" {
garbageThreshold = float64(cfv.IntValue)
}
}
// TODO: Connect to master and query volume status
// Check current garbage ratio
// Verify volume hasn't changed significantly since detection
// Return error if conditions no longer met
glog.Infof("vacuum executor job=%s: volume %s meets vacuum criteria (threshold=%.1f%%)",
e.jobID, e.volumeID, garbageThreshold)
return nil
}
// Step 2: vacuumVolumeCompact compacts and defragments the volume
// This is the main vacuum operation that reclaims space
func (e *Executor) vacuumVolumeCompact(ctx context.Context) error {
glog.Infof("vacuum executor job=%s: compacting volume %s", e.jobID, e.volumeID)
// Extract compaction level from config
var compactLevel int64 = 2
for _, cfv := range e.config.WorkerConfig {
if cfv.FieldName == "compactLevel" {
compactLevel = cfv.IntValue
}
}
glog.Infof("vacuum executor job=%s: using compaction level %d for volume %s",
e.jobID, compactLevel, e.volumeID)
// TODO: Connect to volume server and perform compaction
// 1. Read volume data, skip deleted entries
// 2. Rewrite volume file without garbage
// 3. Update volume metadata
// 4. Report compaction progress
//
// The compaction process should:
// - Read the volume's .dat and .idx files
// - Skip entries marked as deleted
// - Write new .dat file with only valid entries
// - Update .idx file with new offsets
// - Use compactLevel to determine aggressiveness:
// Level 0: Minimal compaction (fast, least space saved)
// Level 5: Aggressive compaction (slower, maximum space saved)
return nil
}
// Step 3: vacuumVolumeCleanup cleans up deleted space if enabled
// This step frees up disk space after compaction
func (e *Executor) vacuumVolumeCleanup(ctx context.Context) error {
// Extract enableCleanup flag from config
enableCleanup := true
for _, cfv := range e.config.WorkerConfig {
if cfv.FieldName == "enableCleanup" {
enableCleanup = cfv.BoolValue
}
}
if !enableCleanup {
glog.Infof("vacuum executor job=%s: skipping cleanup for volume %s (enableCleanup=false)",
e.jobID, e.volumeID)
return nil
}
glog.Infof("vacuum executor job=%s: cleaning up deleted space for volume %s", e.jobID, e.volumeID)
// TODO: Connect to volume server and perform cleanup
// 1. Frees unused space at the end of volume files
// 2. Truncates .dat file to actual size
// 3. Optimizes file system allocation
// 4. Updates volume metadata with new sizes
//
// The cleanup process should:
// - Trim excess capacity from the volume files
// - Update volume size metadata on master
// - Log space reclaimed
return nil
}
-131
View File
@@ -1,131 +0,0 @@
package vacuum
import (
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
)
// GetConfigurationSchema returns the configuration schema for the vacuum job type
func GetConfigurationSchema() *plugin_pb.JobTypeConfigSchema {
return &plugin_pb.JobTypeConfigSchema{
JobType: "vacuum",
Version: "v1",
Description: "Automatically vacuum volumes to reclaim space by removing deleted entries",
AdminFields: []*plugin_pb.ConfigField{
{
Name: "garbageThreshold",
Label: "Garbage Threshold (%)",
Description: "Vacuum volumes when garbage ratio exceeds this percentage (0-100)",
FieldType: plugin_pb.ConfigField_INT,
Required: true,
DefaultValue: "30",
ValidationRules: []*plugin_pb.ValidationRule{
{
RuleType: plugin_pb.ValidationRule_MIN_VALUE,
Value: "0",
ErrorMessage: "Garbage threshold must be at least 0",
},
{
RuleType: plugin_pb.ValidationRule_MAX_VALUE,
Value: "100",
ErrorMessage: "Garbage threshold must be at most 100",
},
},
OptionsMsg: &plugin_pb.ConfigField_Options{
MinValue: 0,
MaxValue: 100,
},
},
{
Name: "minVolumeAge",
Label: "Minimum Volume Age",
Description: "Skip vacuuming volumes created less than this duration ago (format: 24h, 7d, etc.)",
FieldType: plugin_pb.ConfigField_STRING,
Required: false,
DefaultValue: "24h",
ValidationRules: []*plugin_pb.ValidationRule{
{
RuleType: plugin_pb.ValidationRule_PATTERN,
Value: "^(\\d+[smhd])+$",
ErrorMessage: "Must be a valid duration (e.g., 24h, 7d, 30m)",
},
},
OptionsMsg: &plugin_pb.ConfigField_Options{
Placeholder: "24h",
},
},
{
Name: "minInterval",
Label: "Minimum Interval Between Vacuums",
Description: "Don't vacuum the same volume more frequently than this interval (format: 7d, 168h, etc.)",
FieldType: plugin_pb.ConfigField_STRING,
Required: false,
DefaultValue: "7d",
ValidationRules: []*plugin_pb.ValidationRule{
{
RuleType: plugin_pb.ValidationRule_PATTERN,
Value: "^(\\d+[smhd])+$",
ErrorMessage: "Must be a valid duration (e.g., 7d, 168h, 1w)",
},
},
OptionsMsg: &plugin_pb.ConfigField_Options{
Placeholder: "7d",
},
},
},
WorkerFields: []*plugin_pb.ConfigField{
{
Name: "compactLevel",
Label: "Compaction Level",
Description: "Compaction level for volume defragmentation (0-5, higher = more aggressive)",
FieldType: plugin_pb.ConfigField_INT,
Required: false,
DefaultValue: "2",
ValidationRules: []*plugin_pb.ValidationRule{
{
RuleType: plugin_pb.ValidationRule_MIN_VALUE,
Value: "0",
ErrorMessage: "Compaction level must be at least 0",
},
{
RuleType: plugin_pb.ValidationRule_MAX_VALUE,
Value: "5",
ErrorMessage: "Compaction level must be at most 5",
},
},
OptionsMsg: &plugin_pb.ConfigField_Options{
MinValue: 0,
MaxValue: 5,
},
},
{
Name: "enableCleanup",
Label: "Enable Space Cleanup",
Description: "Whether to clean up and release deleted space after compaction",
FieldType: plugin_pb.ConfigField_BOOL,
Required: false,
DefaultValue: "true",
},
},
FieldGroups: []*plugin_pb.ConfigGroup{
{
Name: "Vacuum Settings",
Label: "Vacuum Settings",
Description: "Configuration for volume vacuuming and garbage collection",
FieldNames: []string{
"garbageThreshold",
"minVolumeAge",
"minInterval",
},
},
{
Name: "advanced",
Label: "Advanced",
Description: "Advanced vacuum parameters",
FieldNames: []string{
"compactLevel",
"enableCleanup",
},
},
},
}
}
-368
View File
@@ -1,368 +0,0 @@
package vacuum
import (
"context"
"fmt"
"io"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Worker is the main vacuum plugin worker
type Worker struct {
id string
name string
version string
masterAddr string
httpPort int
// gRPC connection
conn *grpc.ClientConn
client plugin_pb.PluginServiceClient
// Detector and executor
detector *Detector
// Context and coordination
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
// Running jobs
jobsMu sync.RWMutex
runningJobs map[string]context.CancelFunc
}
// NewWorker creates a new vacuum worker
func NewWorker(id, masterAddr string, httpPort int) *Worker {
return &Worker{
id: id,
name: "vacuum_worker",
version: "v1",
masterAddr: masterAddr,
httpPort: httpPort,
runningJobs: make(map[string]context.CancelFunc),
detector: NewDetector(masterAddr),
}
}
// Start starts the worker and connects to the admin server
func (w *Worker) Start(ctx context.Context) error {
glog.Infof("vacuum worker: starting (id=%s, master=%s, httpPort=%d)", w.id, w.masterAddr, w.httpPort)
w.ctx, w.cancel = context.WithCancel(ctx)
// Connect to admin server via gRPC
// Admin server runs on httpPort + 10000
adminPort := w.httpPort + 10000
adminAddr := fmt.Sprintf("localhost:%d", adminPort)
glog.Infof("vacuum worker: connecting to admin at %s", adminAddr)
conn, err := grpc.Dial(adminAddr, grpc.WithInsecure())
if err != nil {
return fmt.Errorf("failed to connect to admin server: %w", err)
}
w.conn = conn
w.client = plugin_pb.NewPluginServiceClient(conn)
// Start connection handler in goroutine
w.wg.Add(1)
go w.handleConnection()
return nil
}
// Stop stops the worker and closes all connections
func (w *Worker) Stop() error {
glog.Infof("vacuum worker: stopping (id=%s)", w.id)
// Cancel context to signal all goroutines
if w.cancel != nil {
w.cancel()
}
// Wait for goroutines with timeout
done := make(chan struct{})
go func() {
w.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
glog.Warningf("vacuum worker: graceful shutdown timeout")
}
// Close gRPC connection
if w.conn != nil {
if err := w.conn.Close(); err != nil {
glog.Errorf("vacuum worker: error closing gRPC connection: %v", err)
}
}
glog.Infof("vacuum worker: stopped")
return nil
}
// handleConnection handles the bidirectional gRPC connection with the admin server
func (w *Worker) handleConnection() {
defer w.wg.Done()
glog.Infof("vacuum worker: establishing bidirectional stream")
// Create bidirectional stream
stream, err := w.client.Connect(w.ctx)
if err != nil {
glog.Errorf("vacuum worker: failed to create stream: %v", err)
return
}
// Send registration message
if err := w.sendRegistration(stream); err != nil {
glog.Errorf("vacuum worker: failed to send registration: %v", err)
return
}
// Start heartbeat goroutine
w.wg.Add(1)
go w.sendHeartbeats(stream)
// Start job executor goroutine
w.wg.Add(1)
go w.executeJobs(stream)
// Listen for messages from admin
w.listenForMessages(stream)
glog.Infof("vacuum worker: connection closed")
}
// sendRegistration sends the initial registration message
func (w *Worker) sendRegistration(stream plugin_pb.PluginService_ConnectClient) error {
capabilities := []*plugin_pb.JobTypeCapability{
{
JobType: "vacuum",
CanDetect: true,
CanExecute: true,
Version: "v1",
},
}
register := &plugin_pb.PluginRegister{
PluginId: w.id,
Name: w.name,
Version: w.version,
ProtocolVersion: "v1",
Capabilities: capabilities,
}
msg := &plugin_pb.PluginMessage{
Content: &plugin_pb.PluginMessage_Register{
Register: register,
},
}
return stream.Send(msg)
}
// sendHeartbeats sends periodic heartbeat messages
func (w *Worker) sendHeartbeats(stream plugin_pb.PluginService_ConnectClient) {
defer w.wg.Done()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-w.ctx.Done():
return
case <-ticker.C:
w.jobsMu.RLock()
pendingJobs := int32(len(w.runningJobs))
w.jobsMu.RUnlock()
heartbeat := &plugin_pb.PluginHeartbeat{
PluginId: w.id,
Timestamp: timestamppb.Now(),
UptimeSeconds: int64(time.Since(time.Now()).Seconds()),
PendingJobs: pendingJobs,
CpuUsagePercent: 0, // TODO: Get actual CPU usage
MemoryUsageMb: 0, // TODO: Get actual memory usage
}
msg := &plugin_pb.PluginMessage{
Content: &plugin_pb.PluginMessage_Heartbeat{
Heartbeat: heartbeat,
},
}
if err := stream.Send(msg); err != nil {
glog.Errorf("vacuum worker: failed to send heartbeat: %v", err)
return
}
}
}
}
// executeJobs periodically detects and executes jobs
func (w *Worker) executeJobs(stream plugin_pb.PluginService_ConnectClient) {
defer w.wg.Done()
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-w.ctx.Done():
return
case <-ticker.C:
// TODO: Implement job detection and execution
// Query admin for jobs or use detector to find jobs
}
}
}
// listenForMessages listens for messages from the admin server
func (w *Worker) listenForMessages(stream plugin_pb.PluginService_ConnectClient) {
for {
msg, err := stream.Recv()
if err == io.EOF {
glog.Infof("vacuum worker: admin closed connection")
return
}
if err != nil {
glog.Errorf("vacuum worker: failed to receive message: %v", err)
return
}
if msg.Content == nil {
continue
}
switch content := msg.Content.(type) {
case *plugin_pb.AdminMessage_JobRequest:
w.handleJobRequest(content.JobRequest)
case *plugin_pb.AdminMessage_ConfigUpdate:
glog.Infof("vacuum worker: received config update: %s", content.ConfigUpdate.JobType)
case *plugin_pb.AdminMessage_AdminCommand:
w.handleAdminCommand(content.AdminCommand)
}
}
}
// handleJobRequest processes a job request from the admin
func (w *Worker) handleJobRequest(jobReq *plugin_pb.JobRequest) {
glog.Infof("vacuum worker: received job request (id=%s, type=%s)", jobReq.JobId, jobReq.JobType)
// Create job context
jobCtx, cancel := context.WithCancel(w.ctx)
w.jobsMu.Lock()
w.runningJobs[jobReq.JobId] = cancel
w.jobsMu.Unlock()
// Execute job in goroutine
w.wg.Add(1)
go func() {
defer w.wg.Done()
defer func() {
w.jobsMu.Lock()
delete(w.runningJobs, jobReq.JobId)
w.jobsMu.Unlock()
}()
w.executeJobRequest(jobCtx, jobReq)
}()
}
// executeJobRequest executes a single job request
func (w *Worker) executeJobRequest(ctx context.Context, jobReq *plugin_pb.JobRequest) {
// Build job config from request
config := &plugin_pb.JobTypeConfig{
JobType: jobReq.JobType,
AdminConfig: nil,
WorkerConfig: jobReq.Config,
}
// Create executor
executor := NewExecutor(jobReq.JobId, config)
// Progress channel
progressChan := make(chan *plugin_pb.JobProgress, 10)
// Execute job
go func() {
if err := executor.Execute(ctx, jobReq.Metadata, progressChan); err != nil {
glog.Errorf("vacuum worker: job execution failed (id=%s): %v", jobReq.JobId, err)
}
close(progressChan)
}()
// TODO: Send progress updates back to admin via ExecuteJob RPC
for progress := range progressChan {
_ = progress
glog.Infof("vacuum worker: job %s progress: %d%%", jobReq.JobId, progress.ProgressPercent)
}
}
// handleAdminCommand processes admin commands
func (w *Worker) handleAdminCommand(cmd *plugin_pb.AdminCommand) {
glog.Infof("vacuum worker: received admin command: %v", cmd.CommandType)
switch cmd.CommandType {
case plugin_pb.AdminCommand_RELOAD_CONFIG:
glog.Infof("vacuum worker: reloading configuration")
case plugin_pb.AdminCommand_ENABLE_JOB_TYPE:
glog.Infof("vacuum worker: enabling vacuum job type")
case plugin_pb.AdminCommand_DISABLE_JOB_TYPE:
glog.Infof("vacuum worker: disabling vacuum job type")
case plugin_pb.AdminCommand_SHUTDOWN:
glog.Infof("vacuum worker: received shutdown command")
w.Stop()
}
}
// GetCapabilities returns the worker's capabilities
func (w *Worker) GetCapabilities() []*plugin_pb.JobTypeCapability {
return []*plugin_pb.JobTypeCapability{
{
JobType: "vacuum",
CanDetect: true,
CanExecute: true,
Version: "v1",
},
}
}
// GetConfigurationSchema returns the configuration schema
func (w *Worker) GetConfigurationSchema() *plugin_pb.JobTypeConfigSchema {
return GetConfigurationSchema()
}
// Run is a convenience method that starts the worker and waits for context cancellation
func (w *Worker) Run(ctx context.Context) error {
if err := w.Start(ctx); err != nil {
return err
}
defer w.Stop()
<-ctx.Done()
return nil
}
// StartWorkerServer starts the vacuum worker with the given configuration
func StartWorkerServer(masterAddr string, httpPort int) error {
pluginID := fmt.Sprintf("vacuum_worker_%d", time.Now().Unix())
worker := NewWorker(pluginID, masterAddr, httpPort)
ctx := context.Background()
return worker.Run(ctx)
}
-74
View File
@@ -1,74 +0,0 @@
package app
import (
"fmt"
)
// formatNumber formats a number with commas
func formatNumber(n interface{}) string {
switch v := n.(type) {
case int:
return fmt.Sprintf("%d", v)
case int64:
return fmt.Sprintf("%d", v)
case float64:
return fmt.Sprintf("%.2f", v)
default:
return fmt.Sprintf("%v", v)
}
}
// formatBytes formats bytes as human-readable size
func formatBytes(bytes interface{}) string {
switch v := bytes.(type) {
case int:
return formatBytesValue(int64(v))
case int64:
return formatBytesValue(v)
case float64:
return formatBytesValue(int64(v))
default:
return fmt.Sprintf("%v", v)
}
}
func formatBytesValue(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
// calculatePercent calculates percentage
func calculatePercent(current, total interface{}) float64 {
var c, t float64
switch v := current.(type) {
case int:
c = float64(v)
case int64:
c = float64(v)
case float64:
c = v
}
switch v := total.(type) {
case int:
t = float64(v)
case int64:
t = float64(v)
case float64:
t = v
}
if t == 0 {
return 0
}
return (c / t) * 100
}
-102
View File
@@ -1,102 +0,0 @@
package app
import (
"fmt"
)
templ PluginConfigPage(data map[string]interface{}) {
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-cog me-2"></i>{ fmt.Sprintf("%v", data["job_type"]) } Config</h1>
<a href="/plugins" class="btn btn-sm btn-secondary">Back</a>
</div>
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item"><button class="nav-link active" id="config-tab" data-bs-toggle="tab" data-bs-target="#config" type="button">Configuration</button></li>
<li class="nav-item"><button class="nav-link" id="detection-tab" data-bs-toggle="tab" data-bs-target="#detection" type="button">Detection History</button></li>
<li class="nav-item"><button class="nav-link" id="execution-tab" data-bs-toggle="tab" data-bs-target="#execution" type="button">Execution History</button></li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="config" role="tabpanel">
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Configuration</h6>
</div>
<div class="card-body">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enabled" checked?={ data["enabled"].(bool) }>
<label class="form-check-label" for="enabled">Enabled</label>
</div>
</div>
<div class="alert alert-info"><i class="fas fa-info-circle me-2"></i>Configuration UI coming soon</div>
<button type="button" class="btn btn-primary">Save</button>
</div>
</div>
</div>
<div class="tab-pane fade" id="detection" role="tabpanel">
<div class="card shadow">
<div class="card-header py-3">
<div class="d-flex justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">Detection History</h6>
<button type="button" class="btn btn-sm btn-primary">Run Detection</button>
</div>
</div>
<div class="card-body">
if len(data["detection_history"].([]map[string]interface{})) == 0 {
<div class="alert alert-info">No detection history</div>
} else {
<table class="table table-sm">
<thead><tr><th>Timestamp</th><th>Duration</th><th>Jobs Found</th><th>Status</th></tr></thead>
<tbody>
for _, rec := range data["detection_history"].([]map[string]interface{}) {
<tr>
<td>{ fmt.Sprintf("%v", rec["timestamp"]) }</td>
<td>{ fmt.Sprintf("%vms", rec["duration_ms"]) }</td>
<td>{ fmt.Sprintf("%v", rec["jobs_detected"]) }</td>
<td><span class="badge bg-success">{ fmt.Sprintf("%v", rec["status"]) }</span></td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
<div class="tab-pane fade" id="execution" role="tabpanel">
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Execution History</h6>
</div>
<div class="card-body">
if len(data["execution_history"].([]map[string]interface{})) == 0 {
<div class="alert alert-info">No execution history</div>
} else {
<table class="table table-sm">
<thead><tr><th>Job ID</th><th>Started</th><th>Duration</th><th>Items</th><th>Status</th></tr></thead>
<tbody>
for _, rec := range data["execution_history"].([]map[string]interface{}) {
<tr>
<td><code>{ fmt.Sprintf("%v", rec["job_id"]) }</code></td>
<td>{ fmt.Sprintf("%v", rec["started_at"]) }</td>
<td>{ fmt.Sprintf("%vms", rec["duration_ms"]) }</td>
<td>{ fmt.Sprintf("%v", rec["items_processed"]) }</td>
<td>
if rec["success"].(bool) {
<span class="badge bg-success">Success</span>
} else {
<span class="badge bg-danger">Failed</span>
}
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
</div>
</div>
}
-236
View File
@@ -1,236 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.977
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
)
func PluginConfigPage(data map[string]interface{}) 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 {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-cog me-2\"></i>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["job_type"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 9, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " Config</h1><a href=\"/plugins\" class=\"btn btn-sm btn-secondary\">Back</a></div><ul class=\"nav nav-tabs mb-4\" role=\"tablist\"><li class=\"nav-item\"><button class=\"nav-link active\" id=\"config-tab\" data-bs-toggle=\"tab\" data-bs-target=\"#config\" type=\"button\">Configuration</button></li><li class=\"nav-item\"><button class=\"nav-link\" id=\"detection-tab\" data-bs-toggle=\"tab\" data-bs-target=\"#detection\" type=\"button\">Detection History</button></li><li class=\"nav-item\"><button class=\"nav-link\" id=\"execution-tab\" data-bs-toggle=\"tab\" data-bs-target=\"#execution\" type=\"button\">Execution History</button></li></ul><div class=\"tab-content\"><div class=\"tab-pane fade show active\" id=\"config\" role=\"tabpanel\"><div class=\"card shadow\"><div class=\"card-header py-3\"><h6 class=\"m-0 font-weight-bold text-primary\">Configuration</h6></div><div class=\"card-body\"><div class=\"mb-3\"><div class=\"form-check form-switch\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enabled\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data["enabled"].(bool) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "> <label class=\"form-check-label\" for=\"enabled\">Enabled</label></div></div><div class=\"alert alert-info\"><i class=\"fas fa-info-circle me-2\"></i>Configuration UI coming soon</div><button type=\"button\" class=\"btn btn-primary\">Save</button></div></div></div><div class=\"tab-pane fade\" id=\"detection\" role=\"tabpanel\"><div class=\"card shadow\"><div class=\"card-header py-3\"><div class=\"d-flex justify-content-between\"><h6 class=\"m-0 font-weight-bold text-primary\">Detection History</h6><button type=\"button\" class=\"btn btn-sm btn-primary\">Run Detection</button></div></div><div class=\"card-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data["detection_history"].([]map[string]interface{})) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"alert alert-info\">No detection history</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<table class=\"table table-sm\"><thead><tr><th>Timestamp</th><th>Duration</th><th>Jobs Found</th><th>Status</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, rec := range data["detection_history"].([]map[string]interface{}) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<tr><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", rec["timestamp"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 55, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%vms", rec["duration_ms"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 56, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", rec["jobs_detected"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 57, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</td><td><span class=\"badge bg-success\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", rec["status"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 58, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</span></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</tbody></table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div></div></div><div class=\"tab-pane fade\" id=\"execution\" role=\"tabpanel\"><div class=\"card shadow\"><div class=\"card-header py-3\"><h6 class=\"m-0 font-weight-bold text-primary\">Execution History</h6></div><div class=\"card-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data["execution_history"].([]map[string]interface{})) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"alert alert-info\">No execution history</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<table class=\"table table-sm\"><thead><tr><th>Job ID</th><th>Started</th><th>Duration</th><th>Items</th><th>Status</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, rec := range data["execution_history"].([]map[string]interface{}) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<tr><td><code>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", rec["job_id"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 82, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</code></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", rec["started_at"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 83, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%vms", rec["duration_ms"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 84, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", rec["items_processed"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_config.templ`, Line: 85, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if rec["success"].(bool) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span class=\"badge bg-success\">Success</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"badge bg-danger\">Failed</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</tbody></table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
-60
View File
@@ -1,60 +0,0 @@
package app
import (
"fmt"
)
templ PluginJobsPage(data map[string]interface{}) {
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-list me-2"></i>{ fmt.Sprintf("%v", data["job_type"]) } Jobs</h1>
<div>
<a href="/plugins" class="btn btn-sm btn-secondary me-2">Back</a>
<button type="button" class="btn btn-sm btn-primary" onclick="location.reload();">Refresh</button>
</div>
</div>
<div class="row mb-4">
<div class="col-lg-3"><div class="card"><div class="card-body"><div class="text-xs font-weight-bold text-warning text-uppercase mb-1">Pending</div><div class="h5 mb-0">{ fmt.Sprintf("%v", data["pending_count"]) }</div></div></div></div>
<div class="col-lg-3"><div class="card"><div class="card-body"><div class="text-xs font-weight-bold text-info text-uppercase mb-1">Running</div><div class="h5 mb-0">{ fmt.Sprintf("%v", data["running_count"]) }</div></div></div></div>
<div class="col-lg-3"><div class="card"><div class="card-body"><div class="text-xs font-weight-bold text-success text-uppercase mb-1">Completed</div><div class="h5 mb-0">{ fmt.Sprintf("%v", data["completed_count"]) }</div></div></div></div>
<div class="col-lg-3"><div class="card"><div class="card-body"><div class="text-xs font-weight-bold text-danger text-uppercase mb-1">Failed</div><div class="h5 mb-0">{ fmt.Sprintf("%v", data["failed_count"]) }</div></div></div></div>
</div>
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Job List</h6>
</div>
<div class="card-body">
if len(data["jobs"].([]map[string]interface{})) == 0 {
<div class="alert alert-info">No jobs found</div>
} else {
<table class="table table-sm table-hover">
<thead><tr><th>Job ID</th><th>Description</th><th>State</th><th>Progress</th><th>Created</th></tr></thead>
<tbody>
for _, job := range data["jobs"].([]map[string]interface{}) {
<tr>
<td><code>{ fmt.Sprintf("%v", job["id"]) }</code></td>
<td>{ fmt.Sprintf("%v", job["description"]) }</td>
<td><span class={ "badge", jobStateClass(fmt.Sprintf("%v", job["state"])) }>{ fmt.Sprintf("%v", job["state"]) }</span></td>
<td>
<div class="progress" style="height: 20px;">
<div class="progress-bar" style={ "width: " + fmt.Sprintf("%v%%", job["progress"]) }></div>
</div>
</td>
<td><small>{ fmt.Sprintf("%v", job["created_at"]) }</small></td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
}
func jobStateClass(s string) string {
if s == "pending" { return "bg-warning" }
if s == "running" { return "bg-info" }
if s == "completed" { return "bg-success" }
if s == "failed" { return "bg-danger" }
return "bg-secondary"
}
-237
View File
@@ -1,237 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.977
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
)
func PluginJobsPage(data map[string]interface{}) 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 {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-list me-2\"></i>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["job_type"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 9, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " Jobs</h1><div><a href=\"/plugins\" class=\"btn btn-sm btn-secondary me-2\">Back</a> <button type=\"button\" class=\"btn btn-sm btn-primary\" onclick=\"location.reload();\">Refresh</button></div></div><div class=\"row mb-4\"><div class=\"col-lg-3\"><div class=\"card\"><div class=\"card-body\"><div class=\"text-xs font-weight-bold text-warning text-uppercase mb-1\">Pending</div><div class=\"h5 mb-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["pending_count"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 17, Col: 212}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div></div></div><div class=\"col-lg-3\"><div class=\"card\"><div class=\"card-body\"><div class=\"text-xs font-weight-bold text-info text-uppercase mb-1\">Running</div><div class=\"h5 mb-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["running_count"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 18, Col: 209}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div></div></div><div class=\"col-lg-3\"><div class=\"card\"><div class=\"card-body\"><div class=\"text-xs font-weight-bold text-success text-uppercase mb-1\">Completed</div><div class=\"h5 mb-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["completed_count"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 19, Col: 216}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></div></div></div><div class=\"col-lg-3\"><div class=\"card\"><div class=\"card-body\"><div class=\"text-xs font-weight-bold text-danger text-uppercase mb-1\">Failed</div><div class=\"h5 mb-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["failed_count"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 20, Col: 209}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div></div></div></div></div><div class=\"card shadow\"><div class=\"card-header py-3\"><h6 class=\"m-0 font-weight-bold text-primary\">Job List</h6></div><div class=\"card-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data["jobs"].([]map[string]interface{})) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"alert alert-info\">No jobs found</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<table class=\"table table-sm table-hover\"><thead><tr><th>Job ID</th><th>Description</th><th>State</th><th>Progress</th><th>Created</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, job := range data["jobs"].([]map[string]interface{}) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<tr><td><code>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", job["id"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 36, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</code></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", job["description"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 37, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 = []any{"badge", jobStateClass(fmt.Sprintf("%v", job["state"]))}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var9).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", job["state"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 38, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></td><td><div class=\"progress\" style=\"height: 20px;\"><div class=\"progress-bar\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width: " + fmt.Sprintf("%v%%", job["progress"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 41, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"></div></div></td><td><small>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", job["created_at"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_jobs.templ`, Line: 44, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</small></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</tbody></table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func jobStateClass(s string) string {
if s == "pending" {
return "bg-warning"
}
if s == "running" {
return "bg-info"
}
if s == "completed" {
return "bg-success"
}
if s == "failed" {
return "bg-danger"
}
return "bg-secondary"
}
var _ = templruntime.GeneratedTemplate
-118
View File
@@ -1,118 +0,0 @@
package app
import (
"fmt"
)
templ PluginsPage(data map[string]interface{}) {
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-puzzle-piece me-2"></i>Plugin System</h1>
<button type="button" class="btn btn-sm btn-primary" onclick="location.reload();"><i class="fas fa-sync me-1"></i>Refresh</button>
</div>
<div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">Connected Plugins</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{ fmt.Sprintf("%v", data["total_plugins"]) }</div>
</div>
<div class="col-auto"><i class="fas fa-plug fa-2x text-gray-300"></i></div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">Total Jobs</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{ fmt.Sprintf("%v", data["total_jobs"]) }</div>
</div>
<div class="col-auto"><i class="fas fa-tasks fa-2x text-gray-300"></i></div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">Job Types</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{ fmt.Sprintf("%v", len(data["job_types"].([]string))) }</div>
</div>
<div class="col-auto"><i class="fas fa-cubes fa-2x text-gray-300"></i></div>
</div>
</div>
</div>
</div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary"><i class="fas fa-plug me-2"></i>Connected Plugins</h6>
</div>
<div class="card-body">
if len(data["plugins"].([]map[string]interface{})) == 0 {
<div class="alert alert-info"><i class="fas fa-info-circle me-2"></i>No plugins connected</div>
} else {
<table class="table table-hover table-sm">
<thead><tr><th>Name</th><th>Version</th><th>State</th><th>Health</th><th>Jobs</th></tr></thead>
<tbody>
for _, p := range data["plugins"].([]map[string]interface{}) {
<tr>
<td>{ fmt.Sprintf("%v", p["name"]) }</td>
<td>{ fmt.Sprintf("%v", p["version"]) }</td>
<td><span class={ "badge", stateClass(fmt.Sprintf("%v", p["state"])) }>{ fmt.Sprintf("%v", p["state"]) }</span></td>
<td>
if p["healthy"].(bool) {
<span class="badge bg-success">Healthy</span>
} else {
<span class="badge bg-danger">Unhealthy</span>
}
</td>
<td>Running: { fmt.Sprintf("%v", p["running_jobs"]) }</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary"><i class="fas fa-cubes me-2"></i>Job Types</h6>
</div>
<div class="card-body">
if len(data["job_types"].([]string)) == 0 {
<div class="alert alert-info">No job types available</div>
} else {
<div class="row">
for _, jt := range data["job_types"].([]string) {
<div class="col-lg-4 mb-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">{ jt }</h5>
<a href={ templ.SafeURL("/plugins/config/" + jt) } class="btn btn-sm btn-primary">Config</a>
<a href={ templ.SafeURL("/plugins/jobs/" + jt) } class="btn btn-sm btn-info">Jobs</a>
</div>
</div>
</div>
}
</div>
}
</div>
</div>
}
func stateClass(s string) string {
if s == "healthy" { return "bg-success" }
if s == "connected" { return "bg-info" }
if s == "unhealthy" { return "bg-warning" }
return "bg-danger"
}
-274
View File
@@ -1,274 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.977
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"fmt"
)
func PluginsPage(data map[string]interface{}) 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 {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom\"><h1 class=\"h2\"><i class=\"fas fa-puzzle-piece me-2\"></i>Plugin System</h1><button type=\"button\" class=\"btn btn-sm btn-primary\" onclick=\"location.reload();\"><i class=\"fas fa-sync me-1\"></i>Refresh</button></div><div class=\"row mb-4\"><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-primary shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-primary text-uppercase mb-1\">Connected Plugins</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["total_plugins"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 20, Col: 101}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></div><div class=\"col-auto\"><i class=\"fas fa-plug fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-success shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-success text-uppercase mb-1\">Total Jobs</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", data["total_jobs"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 33, Col: 98}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div><div class=\"col-auto\"><i class=\"fas fa-tasks fa-2x text-gray-300\"></i></div></div></div></div></div><div class=\"col-xl-3 col-md-6 mb-4\"><div class=\"card border-left-info shadow h-100 py-2\"><div class=\"card-body\"><div class=\"row no-gutters align-items-center\"><div class=\"col mr-2\"><div class=\"text-xs font-weight-bold text-info text-uppercase mb-1\">Job Types</div><div class=\"h5 mb-0 font-weight-bold text-gray-800\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", len(data["job_types"].([]string))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 46, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div><div class=\"col-auto\"><i class=\"fas fa-cubes fa-2x text-gray-300\"></i></div></div></div></div></div></div><div class=\"card shadow mb-4\"><div class=\"card-header py-3\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-plug me-2\"></i>Connected Plugins</h6></div><div class=\"card-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data["plugins"].([]map[string]interface{})) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"alert alert-info\"><i class=\"fas fa-info-circle me-2\"></i>No plugins connected</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<table class=\"table table-hover table-sm\"><thead><tr><th>Name</th><th>Version</th><th>State</th><th>Health</th><th>Jobs</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, p := range data["plugins"].([]map[string]interface{}) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<tr><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", p["name"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 68, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", p["version"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 69, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 = []any{"badge", stateClass(fmt.Sprintf("%v", p["state"]))}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", p["state"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 70, Col: 110}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</span></td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if p["healthy"].(bool) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"badge bg-success\">Healthy</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"badge bg-danger\">Unhealthy</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</td><td>Running: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", p["running_jobs"]))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 78, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</tbody></table>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div></div><div class=\"card shadow mb-4\"><div class=\"card-header py-3\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-cubes me-2\"></i>Job Types</h6></div><div class=\"card-body\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(data["job_types"].([]string)) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"alert alert-info\">No job types available</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"row\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, jt := range data["job_types"].([]string) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"col-lg-4 mb-3\"><div class=\"card\"><div class=\"card-body\"><h5 class=\"card-title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(jt)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 100, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</h5><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/plugins/config/" + jt))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 101, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"btn btn-sm btn-primary\">Config</a> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/plugins/jobs/" + jt))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugins.templ`, Line: 102, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"btn btn-sm btn-info\">Jobs</a></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func stateClass(s string) string {
if s == "healthy" {
return "bg-success"
}
if s == "connected" {
return "bg-info"
}
if s == "unhealthy" {
return "bg-warning"
}
return "bg-danger"
}
var _ = templruntime.GeneratedTemplate
@@ -0,0 +1,232 @@
package app
import (
"testing"
)
// Test structs that mirror the actual configuration structure
type TestBaseConfigForTemplate struct {
Enabled bool `json:"enabled"`
ScanIntervalSeconds int `json:"scan_interval_seconds"`
MaxConcurrent int `json:"max_concurrent"`
}
type TestTaskConfigForTemplate struct {
TestBaseConfigForTemplate
TaskSpecificField float64 `json:"task_specific_field"`
AnotherSpecificField string `json:"another_specific_field"`
}
func TestGetTaskFieldValue_EmbeddedStructFields(t *testing.T) {
config := &TestTaskConfigForTemplate{
TestBaseConfigForTemplate: TestBaseConfigForTemplate{
Enabled: true,
ScanIntervalSeconds: 2400,
MaxConcurrent: 5,
},
TaskSpecificField: 0.18,
AnotherSpecificField: "test_value",
}
// Test embedded struct fields
tests := []struct {
fieldName string
expectedValue interface{}
description string
}{
{"enabled", true, "BaseConfig boolean field"},
{"scan_interval_seconds", 2400, "BaseConfig integer field"},
{"max_concurrent", 5, "BaseConfig integer field"},
{"task_specific_field", 0.18, "Task-specific float field"},
{"another_specific_field", "test_value", "Task-specific string field"},
}
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
result := getTaskFieldValue(config, test.fieldName)
if result != test.expectedValue {
t.Errorf("Field %s: expected %v (%T), got %v (%T)",
test.fieldName, test.expectedValue, test.expectedValue, result, result)
}
})
}
}
func TestGetTaskFieldValue_NonExistentField(t *testing.T) {
config := &TestTaskConfigForTemplate{
TestBaseConfigForTemplate: TestBaseConfigForTemplate{
Enabled: true,
ScanIntervalSeconds: 1800,
MaxConcurrent: 3,
},
}
result := getTaskFieldValue(config, "non_existent_field")
if result != nil {
t.Errorf("Expected nil for non-existent field, got %v", result)
}
}
func TestGetTaskFieldValue_NilConfig(t *testing.T) {
var config *TestTaskConfigForTemplate = nil
result := getTaskFieldValue(config, "enabled")
if result != nil {
t.Errorf("Expected nil for nil config, got %v", result)
}
}
func TestGetTaskFieldValue_EmptyStruct(t *testing.T) {
config := &TestTaskConfigForTemplate{}
// Test that we can extract zero values
tests := []struct {
fieldName string
expectedValue interface{}
description string
}{
{"enabled", false, "Zero value boolean"},
{"scan_interval_seconds", 0, "Zero value integer"},
{"max_concurrent", 0, "Zero value integer"},
{"task_specific_field", 0.0, "Zero value float"},
{"another_specific_field", "", "Zero value string"},
}
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
result := getTaskFieldValue(config, test.fieldName)
if result != test.expectedValue {
t.Errorf("Field %s: expected %v (%T), got %v (%T)",
test.fieldName, test.expectedValue, test.expectedValue, result, result)
}
})
}
}
func TestGetTaskFieldValue_NonStructConfig(t *testing.T) {
var config interface{} = "not a struct"
result := getTaskFieldValue(config, "enabled")
if result != nil {
t.Errorf("Expected nil for non-struct config, got %v", result)
}
}
func TestGetTaskFieldValue_PointerToStruct(t *testing.T) {
config := &TestTaskConfigForTemplate{
TestBaseConfigForTemplate: TestBaseConfigForTemplate{
Enabled: false,
ScanIntervalSeconds: 900,
MaxConcurrent: 2,
},
TaskSpecificField: 0.35,
}
// Test that pointers are handled correctly
enabledResult := getTaskFieldValue(config, "enabled")
if enabledResult != false {
t.Errorf("Expected false for enabled field, got %v", enabledResult)
}
intervalResult := getTaskFieldValue(config, "scan_interval_seconds")
if intervalResult != 900 {
t.Errorf("Expected 900 for scan_interval_seconds field, got %v", intervalResult)
}
}
func TestGetTaskFieldValue_FieldsWithJSONOmitempty(t *testing.T) {
// Test struct with omitempty tags
type TestConfigWithOmitempty struct {
TestBaseConfigForTemplate
OptionalField string `json:"optional_field,omitempty"`
}
config := &TestConfigWithOmitempty{
TestBaseConfigForTemplate: TestBaseConfigForTemplate{
Enabled: true,
ScanIntervalSeconds: 1200,
MaxConcurrent: 4,
},
OptionalField: "optional_value",
}
// Test that fields with omitempty are still found
result := getTaskFieldValue(config, "optional_field")
if result != "optional_value" {
t.Errorf("Expected 'optional_value' for optional_field, got %v", result)
}
// Test embedded fields still work
enabledResult := getTaskFieldValue(config, "enabled")
if enabledResult != true {
t.Errorf("Expected true for enabled field, got %v", enabledResult)
}
}
func TestGetTaskFieldValue_DeepEmbedding(t *testing.T) {
// Test with multiple levels of embedding
type DeepBaseConfig struct {
DeepField string `json:"deep_field"`
}
type MiddleConfig struct {
DeepBaseConfig
MiddleField int `json:"middle_field"`
}
type TopConfig struct {
MiddleConfig
TopField bool `json:"top_field"`
}
config := &TopConfig{
MiddleConfig: MiddleConfig{
DeepBaseConfig: DeepBaseConfig{
DeepField: "deep_value",
},
MiddleField: 123,
},
TopField: true,
}
// Test that deeply embedded fields are found
deepResult := getTaskFieldValue(config, "deep_field")
if deepResult != "deep_value" {
t.Errorf("Expected 'deep_value' for deep_field, got %v", deepResult)
}
middleResult := getTaskFieldValue(config, "middle_field")
if middleResult != 123 {
t.Errorf("Expected 123 for middle_field, got %v", middleResult)
}
topResult := getTaskFieldValue(config, "top_field")
if topResult != true {
t.Errorf("Expected true for top_field, got %v", topResult)
}
}
// Benchmark to ensure performance is reasonable
func BenchmarkGetTaskFieldValue(b *testing.B) {
config := &TestTaskConfigForTemplate{
TestBaseConfigForTemplate: TestBaseConfigForTemplate{
Enabled: true,
ScanIntervalSeconds: 1800,
MaxConcurrent: 3,
},
TaskSpecificField: 0.25,
AnotherSpecificField: "benchmark_test",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Test both embedded and regular fields
_ = getTaskFieldValue(config, "enabled")
_ = getTaskFieldValue(config, "task_specific_field")
}
}
+68
View File
@@ -0,0 +1,68 @@
package app
import (
"fmt"
"strconv"
)
// getStatusColor returns Bootstrap color class for status
func getStatusColor(status string) string {
switch status {
case "active", "healthy":
return "success"
case "warning":
return "warning"
case "critical", "unreachable":
return "danger"
default:
return "secondary"
}
}
// formatBytes converts bytes to human readable format
func formatBytes(bytes int64) string {
if bytes == 0 {
return "0 B"
}
units := []string{"B", "KB", "MB", "GB", "TB", "PB"}
var i int
value := float64(bytes)
for value >= 1024 && i < len(units)-1 {
value /= 1024
i++
}
if i == 0 {
return fmt.Sprintf("%.0f %s", value, units[i])
}
return fmt.Sprintf("%.1f %s", value, units[i])
}
// formatNumber formats large numbers with commas
func formatNumber(num int64) string {
if num == 0 {
return "0"
}
str := strconv.FormatInt(num, 10)
result := ""
for i, char := range str {
if i > 0 && (len(str)-i)%3 == 0 {
result += ","
}
result += string(char)
}
return result
}
// calculatePercent calculates percentage for progress bars
func calculatePercent(current, max int) int {
if max == 0 {
return 0
}
return (current * 100) / max
}
+2 -20
View File
@@ -332,27 +332,9 @@ templ Layout(c *gin.Context, content templ.Component) {
</li>
</ul>
</div>
<!-- PLUGIN SECTION -->
<div class="sidebar-menu-group">
<h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted">
<span>PLUGINS</span>
</h6>
<ul class="nav flex-column">
<li class="nav-item">
if currentPath == "/plugins" {
<a class="nav-link active" href="/plugins">
<i class="fas fa-puzzle-piece me-2"></i>Plugins
</a>
} else {
<a class="nav-link" href="/plugins">
<i class="fas fa-puzzle-piece me-2"></i>Plugins
</a>
}
</li>
</ul>
</div>
</div>
<!-- Main content -->
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<div class="pt-3">
@content
+17 -32
View File
@@ -487,22 +487,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</li></ul></div><!-- PLUGIN SECTION --><div class=\"sidebar-menu-group\"><h6 class=\"sidebar-heading px-3 mt-4 mb-1 text-muted\"><span>PLUGINS</span></h6><ul class=\"nav flex-column\"><li class=\"nav-item\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if currentPath == "/plugins" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<a class=\"nav-link active\" href=\"/plugins\"><i class=\"fas fa-puzzle-piece me-2\"></i>Plugins</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<a class=\"nav-link\" href=\"/plugins\"><i class=\"fas fa-puzzle-piece me-2\"></i>Plugins</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</li></ul></div></div><main class=\"col-md-9 ms-sm-auto col-lg-10 px-md-4\"><div class=\"pt-3\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</li></ul></div></div><!-- Main content --><main class=\"col-md-9 ms-sm-auto col-lg-10 px-md-4\"><div class=\"pt-3\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -510,43 +495,43 @@ func Layout(c *gin.Context, content templ.Component) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</div></main></div></div><!-- Footer --><footer class=\"footer mt-auto py-3 bg-light\"><div class=\"container-fluid text-center\"><small class=\"text-muted\">&copy; ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</div></main></div></div><!-- Footer --><footer class=\"footer mt-auto py-3 bg-light\"><div class=\"container-fluid text-center\"><small class=\"text-muted\">&copy; ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year()))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 368, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 350, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " SeaweedFS Admin v")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " SeaweedFS Admin v")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 368, Col: 102}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 350, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !strings.Contains(version.VERSION, "enterprise") {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<span class=\"mx-2\">•</span> <a href=\"https://seaweedfs.com\" target=\"_blank\" class=\"text-decoration-none\"><i class=\"fas fa-star me-1\"></i>Enterprise Version Available</a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<span class=\"mx-2\">•</span> <a href=\"https://seaweedfs.com\" target=\"_blank\" class=\"text-decoration-none\"><i class=\"fas fa-star me-1\"></i>Enterprise Version Available</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</small></div></footer><!-- Bootstrap JS --><script src=\"/static/js/bootstrap.bundle.min.js\"></script><!-- Modal Alerts JS (replaces native alert/confirm) --><script src=\"/static/js/modal-alerts.js\"></script><!-- Custom JS --><script src=\"/static/js/admin.js\"></script><script src=\"/static/js/iam-utils.js\"></script><script src=\"/static/js/s3tables.js\"></script></body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</small></div></footer><!-- Bootstrap JS --><script src=\"/static/js/bootstrap.bundle.min.js\"></script><!-- Modal Alerts JS (replaces native alert/confirm) --><script src=\"/static/js/modal-alerts.js\"></script><!-- Custom JS --><script src=\"/static/js/admin.js\"></script><script src=\"/static/js/iam-utils.js\"></script><script src=\"/static/js/s3tables.js\"></script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -575,56 +560,56 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen
templ_7745c5c3_Var28 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 396, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 378, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " - Login</title><link rel=\"icon\" href=\"/static/favicon.ico\" type=\"image/x-icon\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"/static/css/bootstrap.min.css\" rel=\"stylesheet\"><link href=\"/static/css/fontawesome.min.css\" rel=\"stylesheet\"></head><body class=\"bg-light\"><div class=\"container\"><div class=\"row justify-content-center min-vh-100 align-items-center\"><div class=\"col-md-6 col-lg-4\"><div class=\"card shadow\"><div class=\"card-body p-5\"><div class=\"text-center mb-4\"><i class=\"fas fa-server fa-3x text-primary mb-3\"></i><h4 class=\"card-title\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " - Login</title><link rel=\"icon\" href=\"/static/favicon.ico\" type=\"image/x-icon\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"/static/css/bootstrap.min.css\" rel=\"stylesheet\"><link href=\"/static/css/fontawesome.min.css\" rel=\"stylesheet\"></head><body class=\"bg-light\"><div class=\"container\"><div class=\"row justify-content-center min-vh-100 align-items-center\"><div class=\"col-md-6 col-lg-4\"><div class=\"card shadow\"><div class=\"card-body p-5\"><div class=\"text-center mb-4\"><i class=\"fas fa-server fa-3x text-primary mb-3\"></i><h4 class=\"card-title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 410, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 392, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</h4><p class=\"text-muted\">Please sign in to continue</p></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</h4><p class=\"text-muted\">Please sign in to continue</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if errorMessage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<div class=\"alert alert-danger\" role=\"alert\"><i class=\"fas fa-exclamation-triangle me-2\"></i> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<div class=\"alert alert-danger\" role=\"alert\"><i class=\"fas fa-exclamation-triangle me-2\"></i> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 417, Col: 45}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 399, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<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, 65, "<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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
-1
View File
@@ -14,7 +14,6 @@ gen:
protoc mq_schema.proto --go_out=./schema_pb --go-grpc_out=./schema_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
protoc mq_agent.proto --go_out=./mq_agent_pb --go-grpc_out=./mq_agent_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
protoc worker.proto --go_out=./worker_pb --go-grpc_out=./worker_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
protoc plugin.proto --go_out=./plugin_pb --go-grpc_out=./plugin_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
# protoc filer.proto --java_out=../../other/java/client/src/main/java
cp filer.proto ../../other/java/client/src/main/proto
-558
View File
@@ -1,558 +0,0 @@
syntax = "proto3";
package seaweed_pb;
option go_package = "github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb";
import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
// PluginService: Worker-to-Admin communication service
// Workers establish bidirectional streaming connections to register and execute jobs
service PluginService {
// Connect: Bidirectional stream for plugin registration, heartbeat, and lifecycle
rpc Connect(stream PluginMessage) returns (stream AdminMessage);
// ExecuteJob: Bidirectional stream for job execution with progress updates
rpc ExecuteJob(stream JobExecutionMessage) returns (stream JobProgressMessage);
}
// AdminQueryService: Read-only queries for UI and monitoring
service AdminQueryService {
// GetPluginStats: Get current plugin system statistics
rpc GetPluginStats(google.protobuf.Empty) returns (PluginStats);
// ListPlugins: List all connected plugins with their capabilities
rpc ListPlugins(google.protobuf.Empty) returns (PluginList);
// ListJobs: List jobs by type and status
rpc ListJobs(ListJobsRequest) returns (JobList);
// GetJob: Get detailed job information
rpc GetJob(GetJobRequest) returns (JobDetail);
// GetJobExecutionLog: Stream job execution logs
rpc GetJobExecutionLog(GetJobExecutionLogRequest) returns (stream ExecutionLogEntry);
}
// AdminCommandService: Write operations for job management
service AdminCommandService {
// UpdateJobTypeConfig: Update configuration for a job type
rpc UpdateJobTypeConfig(UpdateJobTypeConfigRequest) returns (JobTypeConfig);
// CreateJob: Manually create a job
rpc CreateJob(CreateJobRequest) returns (Job);
// CancelJob: Cancel a running job
rpc CancelJob(CancelJobRequest) returns (google.protobuf.Empty);
// RetryJob: Retry a failed job
rpc RetryJob(RetryJobRequest) returns (Job);
}
// ============================================================================
// LIFECYCLE AND REGISTRATION MESSAGES
// ============================================================================
// PluginMessage: Messages sent by worker to admin
message PluginMessage {
oneof content {
PluginRegister register = 1;
PluginHeartbeat heartbeat = 2;
ExecutionStatusUpdate status_update = 3;
}
}
// AdminMessage: Messages sent by admin to worker
message AdminMessage {
oneof content {
JobRequest job_request = 1;
ConfigUpdate config_update = 2;
AdminCommand admin_command = 3;
}
}
// PluginRegister: Initial registration message from worker
message PluginRegister {
string plugin_id = 1; // Unique plugin identifier
string name = 2; // Human-readable plugin name
string version = 3; // Plugin version
string protocol_version = 4; // Protocol version (e.g., "v1")
repeated JobTypeCapability capabilities = 5; // Job types this plugin can handle
}
// JobTypeCapability: Declares what job type a plugin handles
message JobTypeCapability {
string job_type = 1; // Job type identifier (e.g., "erasure_coding", "vacuum")
bool can_detect = 2; // Plugin can detect jobs of this type
bool can_execute = 3; // Plugin can execute jobs of this type
string version = 4; // Job type version
}
// PluginHeartbeat: Periodic heartbeat to keep connection alive
message PluginHeartbeat {
string plugin_id = 1;
google.protobuf.Timestamp timestamp = 2;
int64 uptime_seconds = 3;
int32 pending_jobs = 4;
float cpu_usage_percent = 5;
float memory_usage_mb = 6;
}
// ============================================================================
// CONFIGURATION SCHEMA MESSAGES
// ============================================================================
// ConfigField: Declarative field definition for UI generation
message ConfigField {
enum FieldType {
BOOL = 0;
INT = 1;
FLOAT = 2;
STRING = 3;
DURATION = 4;
SELECT = 5;
MULTISELECT = 6;
SECRET = 7;
TEXTAREA = 8;
JSON = 9;
PERCENTAGE = 10;
BYTES = 11;
CRON = 12;
}
string name = 1; // Field identifier
string label = 2; // Display label
string description = 3; // Help text
FieldType field_type = 4; // UI control type
bool required = 5; // Is required
string default_value = 6; // Default value
repeated ValidationRule validation_rules = 7; // Validation constraints
repeated ConfigOption options = 8; // For SELECT/MULTISELECT
ConfigField.Options options_msg = 9; // Additional options
message Options {
string placeholder = 1;
repeated string suggestions = 2;
int32 min_length = 3;
int32 max_length = 4;
int32 min_value = 5;
int32 max_value = 6;
float min_float = 7;
float max_float = 8;
}
}
// ValidationRule: Constraint for field validation
message ValidationRule {
enum RuleType {
MIN_VALUE = 0;
MAX_VALUE = 1;
PATTERN = 2;
MIN_LENGTH = 3;
MAX_LENGTH = 4;
CUSTOM = 5;
}
RuleType rule_type = 1;
string value = 2;
string error_message = 3;
}
// ConfigOption: Selection option for SELECT/MULTISELECT
message ConfigOption {
string value = 1;
string label = 2;
string description = 3;
}
// ConfigFieldValue: Value for a config field
message ConfigFieldValue {
string field_name = 1;
string string_value = 2; // For STRING, CRON
int64 int_value = 3; // For INT, BYTES
float float_value = 4; // For FLOAT, PERCENTAGE
bool bool_value = 5; // For BOOL
google.protobuf.Duration duration_value = 6; // For DURATION
repeated string multiselect_values = 7; // For MULTISELECT
string json_value = 8; // For JSON
}
// JobTypeConfig: Configuration for a job type
message JobTypeConfig {
string job_type = 1;
bool enabled = 2;
repeated ConfigFieldValue admin_config = 3; // Admin-managed settings
repeated ConfigFieldValue worker_config = 4; // Worker-managed settings
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp updated_at = 6;
string created_by = 7;
}
// ConfigUpdateRequest: Request to update job type configuration
message ConfigUpdateRequest {
string job_type = 1;
repeated ConfigFieldValue config_values = 2;
}
// ============================================================================
// CONFIGURATION SCHEMA DISCOVERY
// ============================================================================
// GetConfigurationSchemaRequest: Request for config schema
message GetConfigurationSchemaRequest {
string job_type = 1;
}
// JobTypeConfigSchema: Complete configuration schema for a job type
message JobTypeConfigSchema {
string job_type = 1;
string version = 2;
string description = 3;
repeated ConfigField admin_fields = 4; // Fields managed by admin UI
repeated ConfigField worker_fields = 5; // Fields managed by worker
repeated ConfigGroup field_groups = 6; // Field grouping for UI
}
// ConfigGroup: Grouping of related fields for UI organization
message ConfigGroup {
string name = 1;
string label = 2;
string description = 3;
repeated string field_names = 4;
}
// ============================================================================
// JOB DETECTION MESSAGES
// ============================================================================
// DetectionRequest: Request to detect jobs
message DetectionRequest {
string job_type = 1;
JobTypeConfig config = 2; // Current configuration
repeated string filter_tags = 3; // Optional filtering
}
// DetectionResponse: Response from detection
message DetectionResponse {
repeated DetectedJob detected_jobs = 1;
google.protobuf.Timestamp detection_time = 2;
string detector_plugin_id = 3;
}
// DetectedJob: A job candidate detected by worker
message DetectedJob {
string job_key = 1; // Unique key for deduplication
string job_type = 2;
string description = 3; // Human-readable description
int64 priority = 4; // Higher = higher priority
google.protobuf.Duration estimated_duration = 5; // Estimated execution time
map<string, string> metadata = 6; // Job-specific metadata
repeated ConfigFieldValue suggested_config = 7; // Suggested overrides
}
// ============================================================================
// JOB EXECUTION MESSAGES
// ============================================================================
// JobRequest: Request to execute a job
message JobRequest {
string job_id = 1;
string job_type = 2;
string description = 3;
int64 priority = 4;
google.protobuf.Timestamp created_at = 5;
repeated ConfigFieldValue config = 6; // Job-specific config
map<string, string> metadata = 7; // Job metadata
string request_source = 8; // admin | detection | retry
}
// JobExecutionMessage: Messages from worker during job execution
message JobExecutionMessage {
string job_id = 1;
oneof content {
JobStarted job_started = 2;
JobProgress progress = 3;
JobCheckpoint checkpoint = 4;
JobCompleted job_completed = 5;
JobFailed job_failed = 6;
ExecutionLog log_entry = 7;
}
}
// JobStarted: Job execution started
message JobStarted {
google.protobuf.Timestamp started_at = 1;
string executor_id = 2; // Plugin executor ID
}
// JobProgress: Progress update during execution
message JobProgress {
int32 progress_percent = 1; // 0-100
string current_step = 2;
string status_message = 3;
google.protobuf.Timestamp updated_at = 4;
}
// JobCheckpoint: Checkpoint for resuming jobs
message JobCheckpoint {
string checkpoint_id = 1;
bytes checkpoint_data = 2; // Serialized checkpoint
google.protobuf.Timestamp created_at = 3;
}
// JobCompleted: Job execution completed successfully
message JobCompleted {
google.protobuf.Timestamp completed_at = 1;
string summary = 2;
map<string, string> output = 3; // Job output data
}
// JobFailed: Job execution failed
message JobFailed {
string error_code = 1; // Error classification
string error_message = 2; // Human-readable error
bytes error_details = 3; // Detailed error data
bool retryable = 4; // Can this job be retried
google.protobuf.Timestamp failed_at = 5;
int32 retry_count = 6;
}
// ExecutionLog: Log entry during job execution
message ExecutionLog {
enum LogLevel {
DEBUG = 0;
INFO = 1;
WARNING = 2;
ERROR = 3;
}
google.protobuf.Timestamp timestamp = 1;
LogLevel level = 2;
string message = 3;
map<string, string> context = 4;
}
// JobProgressMessage: Messages from admin to worker
message JobProgressMessage {
string job_id = 1;
oneof content {
JobProgressUpdate update = 2;
ExecutionCommand command = 3;
}
}
// JobProgressUpdate: Status update for UI
message JobProgressUpdate {
string job_id = 1;
int32 progress_percent = 2;
string current_step = 3;
string status_message = 4;
google.protobuf.Timestamp timestamp = 5;
}
// ExecutionCommand: Command from admin to worker
message ExecutionCommand {
enum CommandType {
PAUSE = 0;
RESUME = 1;
CANCEL = 2;
GET_STATUS = 3;
}
string job_id = 1;
CommandType command_type = 2;
map<string, string> parameters = 3;
}
// ============================================================================
// STATUS UPDATE MESSAGES
// ============================================================================
// ExecutionStatusUpdate: Status update from worker
message ExecutionStatusUpdate {
string plugin_id = 1;
string job_id = 2;
string status = 3; // running | completed | failed | paused
int32 progress_percent = 4;
google.protobuf.Timestamp timestamp = 5;
}
// ConfigUpdate: Configuration update message
message ConfigUpdate {
string job_type = 1;
repeated ConfigFieldValue config_values = 2;
google.protobuf.Timestamp updated_at = 3;
}
// AdminCommand: Admin command to worker
message AdminCommand {
enum CommandType {
RELOAD_CONFIG = 0;
ENABLE_JOB_TYPE = 1;
DISABLE_JOB_TYPE = 2;
SHUTDOWN = 3;
}
CommandType command_type = 1;
map<string, string> parameters = 2;
}
// ============================================================================
// QUERY/COMMAND REQUEST/RESPONSE MESSAGES
// ============================================================================
// ListJobsRequest: Request to list jobs
message ListJobsRequest {
string job_type = 1;
enum StatusFilter {
ALL = 0;
PENDING = 1;
RUNNING = 2;
COMPLETED = 3;
FAILED = 4;
}
StatusFilter status = 2;
int32 limit = 3;
int32 offset = 4;
}
// GetJobRequest: Request for specific job
message GetJobRequest {
string job_id = 1;
}
// GetJobExecutionLogRequest: Request for job logs
message GetJobExecutionLogRequest {
string job_id = 1;
enum LogLevel {
DEBUG = 0;
INFO = 1;
WARNING = 2;
ERROR = 3;
ALL = 4;
}
LogLevel level = 2;
int32 tail_lines = 3;
}
// ============================================================================
// RESPONSE MESSAGES
// ============================================================================
// Job: Job information
message Job {
string job_id = 1;
string job_type = 2;
string description = 3;
int64 priority = 4;
enum Status {
PENDING = 0;
RUNNING = 1;
COMPLETED = 2;
FAILED = 3;
CANCELLED = 4;
PAUSED = 5;
}
Status status = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
string executor_plugin_id = 8;
int32 progress_percent = 9;
repeated ConfigFieldValue config = 10;
}
// JobDetail: Detailed job information with execution history
message JobDetail {
Job job = 1;
repeated ExecutionLogEntry log_entries = 2;
JobCompleted completion_info = 3;
JobFailed failure_info = 4;
repeated string checkpoint_ids = 5;
}
// ExecutionLogEntry: Log entry with metadata
message ExecutionLogEntry {
google.protobuf.Timestamp timestamp = 1;
ExecutionLog.LogLevel level = 2;
string message = 3;
map<string, string> context = 4;
}
// JobList: List of jobs
message JobList {
repeated Job jobs = 1;
int32 total_count = 2;
int32 returned_count = 3;
}
// PluginStats: System-wide statistics
message PluginStats {
int32 total_plugins = 1;
int32 active_plugins = 2;
int32 total_jobs = 3;
int32 pending_jobs = 4;
int32 running_jobs = 5;
int32 completed_jobs = 6;
int32 failed_jobs = 7;
google.protobuf.Timestamp last_update = 8;
map<string, JobTypeStats> job_type_stats = 9;
}
// JobTypeStats: Statistics for a job type
message JobTypeStats {
string job_type = 1;
int32 total = 2;
int32 pending = 3;
int32 running = 4;
int32 completed = 5;
int32 failed = 6;
float success_rate = 7;
google.protobuf.Duration avg_execution_time = 8;
}
// PluginList: List of connected plugins
message PluginList {
repeated PluginInfo plugins = 1;
}
// PluginInfo: Information about a connected plugin
message PluginInfo {
string plugin_id = 1;
string name = 2;
string version = 3;
string protocol_version = 4;
google.protobuf.Timestamp connected_at = 5;
google.protobuf.Timestamp last_heartbeat = 6;
repeated JobTypeCapability capabilities = 7;
bool healthy = 8;
string status = 9;
}
// UpdateJobTypeConfigRequest: Request to update config
message UpdateJobTypeConfigRequest {
JobTypeConfig config = 1;
}
// CreateJobRequest: Request to create a job
message CreateJobRequest {
string job_type = 1;
string description = 2;
int64 priority = 3;
repeated ConfigFieldValue config = 4;
map<string, string> metadata = 5;
}
// CancelJobRequest: Request to cancel a job
message CancelJobRequest {
string job_id = 1;
}
// RetryJobRequest: Request to retry a job
message RetryJobRequest {
string job_id = 1;
}
File diff suppressed because it is too large Load Diff
-658
View File
@@ -1,658 +0,0 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v6.33.4
// source: plugin.proto
package plugin_pb
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
PluginService_Connect_FullMethodName = "/seaweed_pb.PluginService/Connect"
PluginService_ExecuteJob_FullMethodName = "/seaweed_pb.PluginService/ExecuteJob"
)
// PluginServiceClient is the client API for PluginService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// PluginService: Worker-to-Admin communication service
// Workers establish bidirectional streaming connections to register and execute jobs
type PluginServiceClient interface {
// Connect: Bidirectional stream for plugin registration, heartbeat, and lifecycle
Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[PluginMessage, AdminMessage], error)
// ExecuteJob: Bidirectional stream for job execution with progress updates
ExecuteJob(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[JobExecutionMessage, JobProgressMessage], error)
}
type pluginServiceClient struct {
cc grpc.ClientConnInterface
}
func NewPluginServiceClient(cc grpc.ClientConnInterface) PluginServiceClient {
return &pluginServiceClient{cc}
}
func (c *pluginServiceClient) Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[PluginMessage, AdminMessage], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &PluginService_ServiceDesc.Streams[0], PluginService_Connect_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[PluginMessage, AdminMessage]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type PluginService_ConnectClient = grpc.BidiStreamingClient[PluginMessage, AdminMessage]
func (c *pluginServiceClient) ExecuteJob(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[JobExecutionMessage, JobProgressMessage], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &PluginService_ServiceDesc.Streams[1], PluginService_ExecuteJob_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[JobExecutionMessage, JobProgressMessage]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type PluginService_ExecuteJobClient = grpc.BidiStreamingClient[JobExecutionMessage, JobProgressMessage]
// PluginServiceServer is the server API for PluginService service.
// All implementations must embed UnimplementedPluginServiceServer
// for forward compatibility.
//
// PluginService: Worker-to-Admin communication service
// Workers establish bidirectional streaming connections to register and execute jobs
type PluginServiceServer interface {
// Connect: Bidirectional stream for plugin registration, heartbeat, and lifecycle
Connect(grpc.BidiStreamingServer[PluginMessage, AdminMessage]) error
// ExecuteJob: Bidirectional stream for job execution with progress updates
ExecuteJob(grpc.BidiStreamingServer[JobExecutionMessage, JobProgressMessage]) error
mustEmbedUnimplementedPluginServiceServer()
}
// UnimplementedPluginServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedPluginServiceServer struct{}
func (UnimplementedPluginServiceServer) Connect(grpc.BidiStreamingServer[PluginMessage, AdminMessage]) error {
return status.Errorf(codes.Unimplemented, "method Connect not implemented")
}
func (UnimplementedPluginServiceServer) ExecuteJob(grpc.BidiStreamingServer[JobExecutionMessage, JobProgressMessage]) error {
return status.Errorf(codes.Unimplemented, "method ExecuteJob not implemented")
}
func (UnimplementedPluginServiceServer) mustEmbedUnimplementedPluginServiceServer() {}
func (UnimplementedPluginServiceServer) testEmbeddedByValue() {}
// UnsafePluginServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to PluginServiceServer will
// result in compilation errors.
type UnsafePluginServiceServer interface {
mustEmbedUnimplementedPluginServiceServer()
}
func RegisterPluginServiceServer(s grpc.ServiceRegistrar, srv PluginServiceServer) {
// If the following call pancis, it indicates UnimplementedPluginServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&PluginService_ServiceDesc, srv)
}
func _PluginService_Connect_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(PluginServiceServer).Connect(&grpc.GenericServerStream[PluginMessage, AdminMessage]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type PluginService_ConnectServer = grpc.BidiStreamingServer[PluginMessage, AdminMessage]
func _PluginService_ExecuteJob_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(PluginServiceServer).ExecuteJob(&grpc.GenericServerStream[JobExecutionMessage, JobProgressMessage]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type PluginService_ExecuteJobServer = grpc.BidiStreamingServer[JobExecutionMessage, JobProgressMessage]
// PluginService_ServiceDesc is the grpc.ServiceDesc for PluginService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var PluginService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "seaweed_pb.PluginService",
HandlerType: (*PluginServiceServer)(nil),
Methods: []grpc.MethodDesc{},
Streams: []grpc.StreamDesc{
{
StreamName: "Connect",
Handler: _PluginService_Connect_Handler,
ServerStreams: true,
ClientStreams: true,
},
{
StreamName: "ExecuteJob",
Handler: _PluginService_ExecuteJob_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "plugin.proto",
}
const (
AdminQueryService_GetPluginStats_FullMethodName = "/seaweed_pb.AdminQueryService/GetPluginStats"
AdminQueryService_ListPlugins_FullMethodName = "/seaweed_pb.AdminQueryService/ListPlugins"
AdminQueryService_ListJobs_FullMethodName = "/seaweed_pb.AdminQueryService/ListJobs"
AdminQueryService_GetJob_FullMethodName = "/seaweed_pb.AdminQueryService/GetJob"
AdminQueryService_GetJobExecutionLog_FullMethodName = "/seaweed_pb.AdminQueryService/GetJobExecutionLog"
)
// AdminQueryServiceClient is the client API for AdminQueryService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// AdminQueryService: Read-only queries for UI and monitoring
type AdminQueryServiceClient interface {
// GetPluginStats: Get current plugin system statistics
GetPluginStats(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*PluginStats, error)
// ListPlugins: List all connected plugins with their capabilities
ListPlugins(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*PluginList, error)
// ListJobs: List jobs by type and status
ListJobs(ctx context.Context, in *ListJobsRequest, opts ...grpc.CallOption) (*JobList, error)
// GetJob: Get detailed job information
GetJob(ctx context.Context, in *GetJobRequest, opts ...grpc.CallOption) (*JobDetail, error)
// GetJobExecutionLog: Stream job execution logs
GetJobExecutionLog(ctx context.Context, in *GetJobExecutionLogRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExecutionLogEntry], error)
}
type adminQueryServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAdminQueryServiceClient(cc grpc.ClientConnInterface) AdminQueryServiceClient {
return &adminQueryServiceClient{cc}
}
func (c *adminQueryServiceClient) GetPluginStats(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*PluginStats, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PluginStats)
err := c.cc.Invoke(ctx, AdminQueryService_GetPluginStats_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminQueryServiceClient) ListPlugins(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*PluginList, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PluginList)
err := c.cc.Invoke(ctx, AdminQueryService_ListPlugins_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminQueryServiceClient) ListJobs(ctx context.Context, in *ListJobsRequest, opts ...grpc.CallOption) (*JobList, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(JobList)
err := c.cc.Invoke(ctx, AdminQueryService_ListJobs_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminQueryServiceClient) GetJob(ctx context.Context, in *GetJobRequest, opts ...grpc.CallOption) (*JobDetail, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(JobDetail)
err := c.cc.Invoke(ctx, AdminQueryService_GetJob_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminQueryServiceClient) GetJobExecutionLog(ctx context.Context, in *GetJobExecutionLogRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExecutionLogEntry], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &AdminQueryService_ServiceDesc.Streams[0], AdminQueryService_GetJobExecutionLog_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[GetJobExecutionLogRequest, ExecutionLogEntry]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AdminQueryService_GetJobExecutionLogClient = grpc.ServerStreamingClient[ExecutionLogEntry]
// AdminQueryServiceServer is the server API for AdminQueryService service.
// All implementations must embed UnimplementedAdminQueryServiceServer
// for forward compatibility.
//
// AdminQueryService: Read-only queries for UI and monitoring
type AdminQueryServiceServer interface {
// GetPluginStats: Get current plugin system statistics
GetPluginStats(context.Context, *emptypb.Empty) (*PluginStats, error)
// ListPlugins: List all connected plugins with their capabilities
ListPlugins(context.Context, *emptypb.Empty) (*PluginList, error)
// ListJobs: List jobs by type and status
ListJobs(context.Context, *ListJobsRequest) (*JobList, error)
// GetJob: Get detailed job information
GetJob(context.Context, *GetJobRequest) (*JobDetail, error)
// GetJobExecutionLog: Stream job execution logs
GetJobExecutionLog(*GetJobExecutionLogRequest, grpc.ServerStreamingServer[ExecutionLogEntry]) error
mustEmbedUnimplementedAdminQueryServiceServer()
}
// UnimplementedAdminQueryServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAdminQueryServiceServer struct{}
func (UnimplementedAdminQueryServiceServer) GetPluginStats(context.Context, *emptypb.Empty) (*PluginStats, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetPluginStats not implemented")
}
func (UnimplementedAdminQueryServiceServer) ListPlugins(context.Context, *emptypb.Empty) (*PluginList, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListPlugins not implemented")
}
func (UnimplementedAdminQueryServiceServer) ListJobs(context.Context, *ListJobsRequest) (*JobList, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListJobs not implemented")
}
func (UnimplementedAdminQueryServiceServer) GetJob(context.Context, *GetJobRequest) (*JobDetail, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetJob not implemented")
}
func (UnimplementedAdminQueryServiceServer) GetJobExecutionLog(*GetJobExecutionLogRequest, grpc.ServerStreamingServer[ExecutionLogEntry]) error {
return status.Errorf(codes.Unimplemented, "method GetJobExecutionLog not implemented")
}
func (UnimplementedAdminQueryServiceServer) mustEmbedUnimplementedAdminQueryServiceServer() {}
func (UnimplementedAdminQueryServiceServer) testEmbeddedByValue() {}
// UnsafeAdminQueryServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AdminQueryServiceServer will
// result in compilation errors.
type UnsafeAdminQueryServiceServer interface {
mustEmbedUnimplementedAdminQueryServiceServer()
}
func RegisterAdminQueryServiceServer(s grpc.ServiceRegistrar, srv AdminQueryServiceServer) {
// If the following call pancis, it indicates UnimplementedAdminQueryServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AdminQueryService_ServiceDesc, srv)
}
func _AdminQueryService_GetPluginStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(emptypb.Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminQueryServiceServer).GetPluginStats(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminQueryService_GetPluginStats_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminQueryServiceServer).GetPluginStats(ctx, req.(*emptypb.Empty))
}
return interceptor(ctx, in, info, handler)
}
func _AdminQueryService_ListPlugins_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(emptypb.Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminQueryServiceServer).ListPlugins(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminQueryService_ListPlugins_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminQueryServiceServer).ListPlugins(ctx, req.(*emptypb.Empty))
}
return interceptor(ctx, in, info, handler)
}
func _AdminQueryService_ListJobs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListJobsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminQueryServiceServer).ListJobs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminQueryService_ListJobs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminQueryServiceServer).ListJobs(ctx, req.(*ListJobsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminQueryService_GetJob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetJobRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminQueryServiceServer).GetJob(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminQueryService_GetJob_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminQueryServiceServer).GetJob(ctx, req.(*GetJobRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminQueryService_GetJobExecutionLog_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(GetJobExecutionLogRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(AdminQueryServiceServer).GetJobExecutionLog(m, &grpc.GenericServerStream[GetJobExecutionLogRequest, ExecutionLogEntry]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type AdminQueryService_GetJobExecutionLogServer = grpc.ServerStreamingServer[ExecutionLogEntry]
// AdminQueryService_ServiceDesc is the grpc.ServiceDesc for AdminQueryService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AdminQueryService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "seaweed_pb.AdminQueryService",
HandlerType: (*AdminQueryServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetPluginStats",
Handler: _AdminQueryService_GetPluginStats_Handler,
},
{
MethodName: "ListPlugins",
Handler: _AdminQueryService_ListPlugins_Handler,
},
{
MethodName: "ListJobs",
Handler: _AdminQueryService_ListJobs_Handler,
},
{
MethodName: "GetJob",
Handler: _AdminQueryService_GetJob_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "GetJobExecutionLog",
Handler: _AdminQueryService_GetJobExecutionLog_Handler,
ServerStreams: true,
},
},
Metadata: "plugin.proto",
}
const (
AdminCommandService_UpdateJobTypeConfig_FullMethodName = "/seaweed_pb.AdminCommandService/UpdateJobTypeConfig"
AdminCommandService_CreateJob_FullMethodName = "/seaweed_pb.AdminCommandService/CreateJob"
AdminCommandService_CancelJob_FullMethodName = "/seaweed_pb.AdminCommandService/CancelJob"
AdminCommandService_RetryJob_FullMethodName = "/seaweed_pb.AdminCommandService/RetryJob"
)
// AdminCommandServiceClient is the client API for AdminCommandService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// AdminCommandService: Write operations for job management
type AdminCommandServiceClient interface {
// UpdateJobTypeConfig: Update configuration for a job type
UpdateJobTypeConfig(ctx context.Context, in *UpdateJobTypeConfigRequest, opts ...grpc.CallOption) (*JobTypeConfig, error)
// CreateJob: Manually create a job
CreateJob(ctx context.Context, in *CreateJobRequest, opts ...grpc.CallOption) (*Job, error)
// CancelJob: Cancel a running job
CancelJob(ctx context.Context, in *CancelJobRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
// RetryJob: Retry a failed job
RetryJob(ctx context.Context, in *RetryJobRequest, opts ...grpc.CallOption) (*Job, error)
}
type adminCommandServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAdminCommandServiceClient(cc grpc.ClientConnInterface) AdminCommandServiceClient {
return &adminCommandServiceClient{cc}
}
func (c *adminCommandServiceClient) UpdateJobTypeConfig(ctx context.Context, in *UpdateJobTypeConfigRequest, opts ...grpc.CallOption) (*JobTypeConfig, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(JobTypeConfig)
err := c.cc.Invoke(ctx, AdminCommandService_UpdateJobTypeConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminCommandServiceClient) CreateJob(ctx context.Context, in *CreateJobRequest, opts ...grpc.CallOption) (*Job, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Job)
err := c.cc.Invoke(ctx, AdminCommandService_CreateJob_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminCommandServiceClient) CancelJob(ctx context.Context, in *CancelJobRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, AdminCommandService_CancelJob_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminCommandServiceClient) RetryJob(ctx context.Context, in *RetryJobRequest, opts ...grpc.CallOption) (*Job, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Job)
err := c.cc.Invoke(ctx, AdminCommandService_RetryJob_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminCommandServiceServer is the server API for AdminCommandService service.
// All implementations must embed UnimplementedAdminCommandServiceServer
// for forward compatibility.
//
// AdminCommandService: Write operations for job management
type AdminCommandServiceServer interface {
// UpdateJobTypeConfig: Update configuration for a job type
UpdateJobTypeConfig(context.Context, *UpdateJobTypeConfigRequest) (*JobTypeConfig, error)
// CreateJob: Manually create a job
CreateJob(context.Context, *CreateJobRequest) (*Job, error)
// CancelJob: Cancel a running job
CancelJob(context.Context, *CancelJobRequest) (*emptypb.Empty, error)
// RetryJob: Retry a failed job
RetryJob(context.Context, *RetryJobRequest) (*Job, error)
mustEmbedUnimplementedAdminCommandServiceServer()
}
// UnimplementedAdminCommandServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAdminCommandServiceServer struct{}
func (UnimplementedAdminCommandServiceServer) UpdateJobTypeConfig(context.Context, *UpdateJobTypeConfigRequest) (*JobTypeConfig, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateJobTypeConfig not implemented")
}
func (UnimplementedAdminCommandServiceServer) CreateJob(context.Context, *CreateJobRequest) (*Job, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateJob not implemented")
}
func (UnimplementedAdminCommandServiceServer) CancelJob(context.Context, *CancelJobRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method CancelJob not implemented")
}
func (UnimplementedAdminCommandServiceServer) RetryJob(context.Context, *RetryJobRequest) (*Job, error) {
return nil, status.Errorf(codes.Unimplemented, "method RetryJob not implemented")
}
func (UnimplementedAdminCommandServiceServer) mustEmbedUnimplementedAdminCommandServiceServer() {}
func (UnimplementedAdminCommandServiceServer) testEmbeddedByValue() {}
// UnsafeAdminCommandServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AdminCommandServiceServer will
// result in compilation errors.
type UnsafeAdminCommandServiceServer interface {
mustEmbedUnimplementedAdminCommandServiceServer()
}
func RegisterAdminCommandServiceServer(s grpc.ServiceRegistrar, srv AdminCommandServiceServer) {
// If the following call pancis, it indicates UnimplementedAdminCommandServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AdminCommandService_ServiceDesc, srv)
}
func _AdminCommandService_UpdateJobTypeConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateJobTypeConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminCommandServiceServer).UpdateJobTypeConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminCommandService_UpdateJobTypeConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminCommandServiceServer).UpdateJobTypeConfig(ctx, req.(*UpdateJobTypeConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminCommandService_CreateJob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateJobRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminCommandServiceServer).CreateJob(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminCommandService_CreateJob_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminCommandServiceServer).CreateJob(ctx, req.(*CreateJobRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminCommandService_CancelJob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CancelJobRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminCommandServiceServer).CancelJob(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminCommandService_CancelJob_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminCommandServiceServer).CancelJob(ctx, req.(*CancelJobRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminCommandService_RetryJob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RetryJobRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminCommandServiceServer).RetryJob(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminCommandService_RetryJob_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminCommandServiceServer).RetryJob(ctx, req.(*RetryJobRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminCommandService_ServiceDesc is the grpc.ServiceDesc for AdminCommandService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AdminCommandService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "seaweed_pb.AdminCommandService",
HandlerType: (*AdminCommandServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "UpdateJobTypeConfig",
Handler: _AdminCommandService_UpdateJobTypeConfig_Handler,
},
{
MethodName: "CreateJob",
Handler: _AdminCommandService_CreateJob_Handler,
},
{
MethodName: "CancelJob",
Handler: _AdminCommandService_CancelJob_Handler,
},
{
MethodName: "RetryJob",
Handler: _AdminCommandService_RetryJob_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "plugin.proto",
}
+37 -41
View File
@@ -19,6 +19,21 @@ const (
deleteMultipleObjectsLimit = 1000
)
// objectLockVersionToCheckForDelete resolves which version should be validated for Object Lock protections.
// For versioned delete without explicit versionId, this targets the latest version (empty versionId for enabled,
// "null" for suspended) because DeleteObject affects that version's visibility.
// This always returns a version ID to check; object lock protections are fail-closed.
func objectLockVersionToCheckForDelete(versioningState, requestedVersionID string) string {
if requestedVersionID != "" {
return requestedVersionID
}
if versioningState == s3_constants.VersioningSuspended {
return "null"
}
return ""
}
func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Request) {
bucket, object := s3_constants.GetBucketAndObject(r)
@@ -52,18 +67,18 @@ func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Reque
auditLog = s3err.GetAccessLog(r, http.StatusNoContent, s3err.ErrNone)
}
lockCheckVersionID := objectLockVersionToCheckForDelete(versioningState, versionId)
governanceBypassAllowed := s3a.evaluateGovernanceBypassRequest(r, bucket, object)
if err := s3a.enforceObjectLockProtections(r, bucket, object, lockCheckVersionID, governanceBypassAllowed); err != nil {
glog.V(2).Infof("DeleteObjectHandler: object lock check failed for %s/%s (version: %s): %v", bucket, object, lockCheckVersionID, err)
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
if versioningConfigured {
// Handle versioned delete based on specific versioning state
if versionId != "" {
// Delete specific version (same for both enabled and suspended)
// Check object lock permissions before deleting specific version
governanceBypassAllowed := s3a.evaluateGovernanceBypassRequest(r, bucket, object)
if err := s3a.enforceObjectLockProtections(r, bucket, object, versionId, governanceBypassAllowed); err != nil {
glog.V(2).Infof("DeleteObjectHandler: object lock check failed for %s/%s: %v", bucket, object, err)
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
// Delete specific version
err := s3a.deleteSpecificObjectVersion(bucket, object, versionId)
if err != nil {
@@ -77,9 +92,7 @@ func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Reque
} else {
// Delete without version ID - behavior depends on versioning state
if versioningEnabled {
// Enabled versioning: Create delete marker (logical delete)
// AWS S3 behavior: Delete marker creation is NOT blocked by object retention
// because it's a logical delete that doesn't actually remove the retained version
// Enabled versioning: create delete marker (logical delete)
deleteMarkerVersionId, err := s3a.createDeleteMarker(bucket, object)
if err != nil {
glog.Errorf("Failed to create delete marker: %v", err)
@@ -94,14 +107,6 @@ func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Reque
// Suspended versioning: Actually delete the "null" version object
glog.V(2).Infof("DeleteObjectHandler: deleting null version for suspended versioning %s/%s", bucket, object)
// Check object lock permissions before deleting "null" version
governanceBypassAllowed := s3a.evaluateGovernanceBypassRequest(r, bucket, object)
if err := s3a.enforceObjectLockProtections(r, bucket, object, "null", governanceBypassAllowed); err != nil {
glog.V(2).Infof("DeleteObjectHandler: object lock check failed for %s/%s: %v", bucket, object, err)
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
// Delete the "null" version (the regular file)
err := s3a.deleteSpecificObjectVersion(bucket, object, "null")
if err != nil {
@@ -116,14 +121,6 @@ func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Reque
}
} else {
// Handle regular delete (non-versioned)
// Check object lock permissions before deleting object
governanceBypassAllowed := s3a.evaluateGovernanceBypassRequest(r, bucket, object)
if err := s3a.enforceObjectLockProtections(r, bucket, object, "", governanceBypassAllowed); err != nil {
glog.V(2).Infof("DeleteObjectHandler: object lock check failed for %s/%s: %v", bucket, object, err)
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
// Normalize trailing-slash object keys (e.g. "path/") to the
// underlying directory entry path so DeleteEntry gets a valid name.
target := util.NewFullPath(s3a.bucketDir(bucket), object)
@@ -249,20 +246,19 @@ func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *h
continue
}
// Check object lock permissions before deletion (only for versioned buckets)
if versioningConfigured {
// Validate governance bypass for this specific object
governanceBypassAllowed := s3a.evaluateGovernanceBypassRequest(r, bucket, object.Key)
if err := s3a.enforceObjectLockProtections(r, bucket, object.Key, object.VersionId, governanceBypassAllowed); err != nil {
glog.V(2).Infof("DeleteMultipleObjectsHandler: object lock check failed for %s/%s (version: %s): %v", bucket, object.Key, object.VersionId, err)
deleteErrors = append(deleteErrors, DeleteError{
Code: s3err.GetAPIError(s3err.ErrAccessDenied).Code,
Message: s3err.GetAPIError(s3err.ErrAccessDenied).Description,
Key: object.Key,
VersionId: object.VersionId,
})
continue
}
// Check object lock permissions before deletion (applies to all buckets: versioned or non-versioned)
lockCheckVersionID := objectLockVersionToCheckForDelete(versioningState, object.VersionId)
// Validate governance bypass for this specific object
governanceBypassAllowed := s3a.evaluateGovernanceBypassRequest(r, bucket, object.Key)
if err := s3a.enforceObjectLockProtections(r, bucket, object.Key, lockCheckVersionID, governanceBypassAllowed); err != nil {
glog.V(2).Infof("DeleteMultipleObjectsHandler: object lock check failed for %s/%s (version: %s): %v", bucket, object.Key, lockCheckVersionID, err)
deleteErrors = append(deleteErrors, DeleteError{
Code: s3err.GetAPIError(s3err.ErrAccessDenied).Code,
Message: s3err.GetAPIError(s3err.ErrAccessDenied).Description,
Key: object.Key,
VersionId: object.VersionId,
})
continue
}
var deleteVersionId string
@@ -0,0 +1,61 @@
package s3api
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/assert"
)
func TestObjectLockVersionToCheckForDelete(t *testing.T) {
tests := []struct {
name string
versioningState string
requestedVersionID string
expectedVersionID string
}{
{
name: "enabled versioning without version id checks latest version",
versioningState: s3_constants.VersioningEnabled,
requestedVersionID: "",
expectedVersionID: "",
},
{
name: "suspended versioning without version id checks null version",
versioningState: s3_constants.VersioningSuspended,
requestedVersionID: "",
expectedVersionID: "null",
},
{
name: "specific version id is always checked",
versioningState: s3_constants.VersioningEnabled,
requestedVersionID: "3LgYQ7f7VxQ3",
expectedVersionID: "3LgYQ7f7VxQ3",
},
{
name: "non-versioned buckets still check current object",
versioningState: "",
requestedVersionID: "",
expectedVersionID: "",
},
{
name: "unknown versioning state defaults to empty version",
versioningState: "UnexpectedState",
requestedVersionID: "",
expectedVersionID: "",
},
{
name: "suspended versioning with specific version id checks that version",
versioningState: s3_constants.VersioningSuspended,
requestedVersionID: "abc123",
expectedVersionID: "abc123",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
versionID := objectLockVersionToCheckForDelete(tc.versioningState, tc.requestedVersionID)
assert.Equal(t, tc.expectedVersionID, versionID)
})
}
}