diff --git a/.gitignore b/.gitignore index 83e6913e..511d5a82 100644 --- a/.gitignore +++ b/.gitignore @@ -180,6 +180,7 @@ logs/ .husky/ sdk/python-gen/ sdk/python/src/rcabench/openapi/ +sdk/typescript/ src/docs/converted/ src/docs/openapi2/ src/docs/openapi3/ @@ -197,6 +198,7 @@ scripts/command/command.bin CLAUDE.md +.codex .claude/ .vscode/ diff --git a/.openapi-generator/typescript/client/config.json b/.openapi-generator/typescript/config.json similarity index 83% rename from .openapi-generator/typescript/client/config.json rename to .openapi-generator/typescript/config.json index 88cd130d..e12ab4d1 100644 --- a/.openapi-generator/typescript/client/config.json +++ b/.openapi-generator/typescript/config.json @@ -1,7 +1,7 @@ { - "npmName": "@OperationsPAI/client", + "npmName": "@OperationsPAI/sdk", "npmVersion": "0.0.0", - "npmDescription": "TypeScript client for RCABench API", + "npmDescription": "TypeScript SDK for RCABench API", "githost": "github.com", "gitUserId": "OperationsPAI", "gitRepoId": "AegisLab", @@ -17,4 +17,4 @@ "disallowAdditionalPropertiesIfNotPresent": false, "sortParamsByRequiredFlag": true, "stringEnums": true -} \ No newline at end of file +} diff --git a/.openapi-generator/typescript/client/templates/package.mustache b/.openapi-generator/typescript/templates/package.mustache similarity index 100% rename from .openapi-generator/typescript/client/templates/package.mustache rename to .openapi-generator/typescript/templates/package.mustache diff --git a/.openapi-generator/typescript/client/templates/tsconfig.mustache b/.openapi-generator/typescript/templates/tsconfig.mustache similarity index 100% rename from .openapi-generator/typescript/client/templates/tsconfig.mustache rename to .openapi-generator/typescript/templates/tsconfig.mustache diff --git a/README.md b/README.md index 490905d5..5a6a7980 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,69 @@ RCABench enables researchers and practitioners to: ## 🏗️ Architecture -The platform consists of several key components: +The current backend architecture is a single repository with a single `go.mod`, but it supports both: -- **Core API Server** (Go): REST API for managing experiments, algorithms, and evaluations -- **Python SDK**: Client library for programmatic interaction with the platform -- **Fault Injection Engine**: Kubernetes-native chaos engineering capabilities -- **Algorithm Registry**: Extensible framework for RCA algorithm integration -- **Evaluation Framework**: Automated metrics calculation and comparison tools -- **Observability Stack**: Integration with tracing, metrics, and logging systems +- **local monolith-style development modes** for speed +- **split-service runtime modes** for service-boundary validation + +The main service boundaries are: + +- **`api-gateway`**: external HTTP/OpenAPI entrypoint +- **`iam-service`**: auth, user, RBAC, team, api-key +- **`resource-service`**: project, label, container, dataset, evaluation metadata/query +- **`orchestrator-service`**: submit, task, trace, retry, dead-letter, workflow control-plane +- **`runtime-worker-service`**: Redis async consumption, K8s/BuildKit/Helm/Chaos runtime execution +- **`system-service`**: config, audit, monitor, health, metrics + +Key implementation rules: + +- External APIs are HTTP/OpenAPI. +- Internal synchronous calls are gRPC via `src/internalclient/*`. +- Long-running execution stays asynchronous on Redis; it is not converted into synchronous execution RPC. +- Module-owned DB access lives in `src/module/*/repository.go`. +- Infra connectivity and low-level operations live in `src/infra/*`. + +## 🧩 Runtime Modes And Injection Rules + +The backend now has two categories of startup modes: + +- **local integrated modes**: `producer`, `consumer`, `both` +- **dedicated service modes**: `api-gateway`, `iam-service`, `resource-service`, `orchestrator-service`, `runtime-worker-service`, `system-service` + +### What `both` Actually Means + +`both` is **not** the six-service topology. + +It starts: + +- the local HTTP stack +- the local worker/consumer stack + +It is the fastest option for local end-to-end debugging such as: + +- submit -> queue -> worker -> state update +- task/trace/log flow +- API + async worker integration + +### Injection Matrix + +| Mode / Service | What Starts | Local Owner Implementations Injected | Internal Clients Required | Best For | +| --- | --- | --- | --- | --- | +| `producer` | HTTP server only | Yes, local HTTP-facing modules | No | API, handler/service, Swagger, frontend integration | +| `consumer` | worker/controller/receiver side only | Yes, local runtime-side owners | Optional depending on config | queue/runtime/worker-only debugging | +| `both` | HTTP + worker/controller/receiver | Yes, local owners for integrated debugging | Optional depending on config | full local async loop | +| `api-gateway` | external HTTP gateway | No cross-owner local fallback as main path; service-specific remote wiring is expected | Yes | gateway boundary and remote-first debugging | +| `iam-service` | IAM gRPC service | Yes, IAM-local owners only | Only if a specific cross-service read path needs it | auth/user/rbac/team/api-key | +| `resource-service` | Resource gRPC service | Yes, resource-local owners only | Yes for orchestrator-backed queries like some statistics/evaluation views | project/container/dataset/label/evaluation | +| `orchestrator-service` | Orchestrator gRPC service | Yes, orchestrator-local owners only | Optional runtime/resource dependencies as needed | submit/task/trace/workflow | +| `runtime-worker-service` | runtime worker + runtime gRPC | Yes, runtime-side execution infrastructure only | Yes, especially orchestrator target | Redis consumer, K8s/build/helm runtime | +| `system-service` | system gRPC service | Yes, system-local owners only | Yes, especially runtime target | config/audit/monitor/metrics | + +### Rule Of Thumb + +- Use **`producer`** for normal API development. +- Use **`both`** when you need the local async loop. +- Use the **six dedicated services** when you need to verify service boundaries, internal gRPC, or remote-first behavior. ## 📋 Prerequisites @@ -46,43 +101,91 @@ The platform consists of several key components: ## 🚀 Quick Start -### Option 1: Local Development with Docker Compose +### Option 1: Local Dependencies ```bash # Clone the repository git clone https://github.com/OperationsPAI/AegisLab.git cd AegisLab -# Start local environment -make local-debug +# Start core dependencies +docker compose up -d redis mysql etcd jaeger buildkitd loki prometheus grafana +``` + +### Option 2: Fast Local API Debugging + +```bash +cd src && go run . producer -conf ./config.dev.toml -port 8082 -# The API will be available at http://localhost:8082 -# Swagger documentation at http://localhost:8082/swagger/index.html +# HTTP: http://localhost:8082 +# Health: http://localhost:8082/system/health +# Docs: http://localhost:8082/docs/doc.json ``` -### Option 2: Kubernetes Deployment +### Option 3: Fast Local End-To-End Debugging + +```bash +cd src && go run . both -conf ./config.dev.toml -port 8082 +``` + +Use this mode when you need: + +- HTTP + worker in one local process set +- submit -> queue -> consumer -> query loop +- task / trace / logs integration + +### Option 4: Split-Service Debugging + +```bash +# terminal 1 +cd src && go run ./cmd/iam-service -conf ./config.dev.toml + +# terminal 2 +cd src && go run ./cmd/orchestrator-service -conf ./config.dev.toml + +# terminal 3 +cd src && go run ./cmd/resource-service -conf ./config.dev.toml + +# terminal 4 +cd src && go run ./cmd/runtime-worker-service -conf ./config.dev.toml + +# terminal 5 +cd src && go run ./cmd/system-service -conf ./config.dev.toml + +# terminal 6 +cd src && go run ./cmd/api-gateway -conf ./config.dev.toml -port 8082 +``` + +### Option 5: Kubernetes Deployment ```bash # Check prerequisites -make check-prerequisites +just check-prerequisites # Deploy to Kubernetes cluster -make run +just run +``` -# Check deployment status -make status +If you use `scripts/start.sh` directly, the external install URLs can now be overridden with env vars such as: -# View logs -make logs -``` +- `CERT_MANAGER_MANIFEST_URL` +- `CHAOS_MESH_REPO_URL` +- `CLICKSTACK_REPO_URL` +- `OPEN_TELEMETRY_REPO_URL` +- `OTEL_DEMO_REPO_URL` +- `JUICEFS_REPO_URL` +- `TEST_HTTP_PROXY` +- `TEST_HTTPS_PROXY` +- `TEST_NO_PROXY` ## 📖 Documentation -- **[User Guide](docs/user-guide.md)**: Complete guide for using RCABench -- **[Installation Guide](docs/installation.md)**: Detailed setup instructions -- **[API Reference](docs/api-reference.md)**: Complete API documentation -- **[Algorithm Development](docs/algorithm-development.md)**: Guide for implementing RCA algorithms -- **[Examples](docs/examples.md)**: Usage examples and tutorials +- **[Report Index](docs/report-index.md)**: Consolidated backend refactor, runtime, governance, SDK/auth, and validation notes +- **[Refactor TODO](docs/todo.md)**: Source-of-truth task list and final acceptance checklist +- **[API Key Auth TODO](docs/api-key-auth-execution-todo.md)**: Key ID / Key Secret auth execution checklist and signing contract +- **[Package Rename TODO](docs/package-rename-todo.md)**: Go package naming cleanup record for `interface/module/infra/app` +- **[Frontend Redesign](docs/frontend-redesign.md)**: Frontend redesign plan and IA notes +- **[Frontend UI Guidelines](docs/frontend-ui-guidelines.md)**: Frontend visual/system guidelines ## 🔧 Configuration @@ -110,11 +213,50 @@ host = "localhost:6379" [k8s] namespace = "default" +[clients.iam] +target = "127.0.0.1:9091" + +[clients.resource] +target = "127.0.0.1:9093" + +[clients.orchestrator] +target = "127.0.0.1:9092" + +[clients.runtime] +target = "127.0.0.1:9094" + +[clients.system] +target = "127.0.0.1:9095" + +[iam.grpc] +addr = ":9091" + +[resource.grpc] +addr = ":9093" + +[orchestrator.grpc] +addr = ":9092" + +[runtime_worker.grpc] +addr = ":9094" + +[system.grpc] +addr = ":9095" + [injection] benchmark = ["workload-name"] target_label_key = "app" ``` +Important config rules: + +- `producer` and `both` can use local owner implementations for fast debugging. +- dedicated services should use the appropriate `clients.*.target` values when a remote dependency is required. +- `api-gateway` validates `clients.iam.target`, `clients.resource.target`, `clients.orchestrator.target`, and `clients.system.target`. +- `runtime-worker-service` validates `clients.orchestrator.target`. +- `system-service` validates `clients.runtime.target`. +- `resource-service` validates `clients.orchestrator.target` for remote-backed query paths. + ### Storage Configuration For production deployment, configure persistent volumes: @@ -213,6 +355,128 @@ Access monitoring: ## 🛠️ Development +### Recommended Debug Flow + +Choose the mode first: + +- **API-only debugging** -> `producer` +- **local async loop debugging** -> `both` +- **service-boundary / gRPC debugging** -> six dedicated services + +### Where To Put Breakpoints + +#### HTTP issues + +Start here: + +- `src/router/*` +- `src/module/*/handler.go` +- `src/module/*/service.go` +- `src/module/*/repository.go` + +If the problem only appears in split-service mode, then also check: + +- `src/app/gateway/*` +- `src/internalclient/*` + +#### gRPC / service-boundary issues + +Start here: + +- `src/internalclient/*` +- `src/interface/grpc/*` +- `src/app/{gateway,iam,resource,orchestrator,runtime,system}/*` + +#### async runtime issues + +Start here: + +- `src/service/consumer/*` +- `src/interface/worker/*` +- `src/interface/controller/*` +- `src/infra/k8s/*` +- `src/infra/buildkit/*` +- `src/infra/helm/*` +- `src/infra/chaos/*` + +### Module-Oriented Debug Map + +#### Auth / User / RBAC / Team + +Check: + +- `src/module/auth/*` +- `src/module/user/*` +- `src/module/rbac/*` +- `src/module/team/*` + +Split-service path: + +- `src/app/gateway/{auth,user,rbac,team}_services.go` +- `src/internalclient/iamclient/*` +- `src/interface/grpc/iam/*` + +#### Project / Label / Container / Dataset + +Check: + +- `src/module/project/*` +- `src/module/label/*` +- `src/module/container/*` +- `src/module/dataset/*` + +Split-service path: + +- `src/app/gateway/resource_services.go` +- `src/internalclient/resourceclient/*` +- `src/interface/grpc/resource/*` + +#### Injection / Execution / Task / Trace / Group / Notification + +Check: + +- `src/module/injection/*` +- `src/module/execution/*` +- `src/module/task/*` +- `src/module/trace/*` +- `src/module/group/*` +- `src/module/notification/*` + +Split-service path: + +- `src/app/gateway/orchestrator_services.go` +- `src/internalclient/orchestratorclient/*` +- `src/interface/grpc/orchestrator/*` +- `src/service/consumer/*` + +#### System / Metrics / Monitor / Config / Audit + +Check: + +- `src/module/system/*` +- `src/module/systemmetric/*` + +Split-service path: + +- `src/app/gateway/system_services.go` +- `src/internalclient/systemclient/*` +- `src/internalclient/runtimeclient/*` +- `src/interface/grpc/system/*` +- `src/interface/grpc/runtime/*` + +#### Runtime / K8s / Build / Helm / Chaos + +Check: + +- `src/service/consumer/*` +- `src/interface/worker/*` +- `src/interface/controller/*` +- `src/infra/k8s/*` +- `src/infra/buildkit/*` +- `src/infra/helm/*` +- `src/infra/chaos/*` +- `src/infra/redis/*` + ### Building from Source ```bash @@ -220,10 +484,17 @@ Access monitoring: cd src go build -o rcabench main.go -# Generate API documentation -make swagger +# Regenerate OpenAPI / Swagger artifacts +cd .. +just swagger-init 1.2.3 + +# Generate SDK packages +just generate-portal 1.2.3 +just generate-admin 1.2.3 +just generate-python-sdk 1.2.3 # Run tests +cd src go test ./... ``` @@ -239,17 +510,21 @@ pip install -e . python -m pytest tests/ ``` -## 📦 Available Make Targets +## 📦 Available Just Recipes ```bash -make help # Show all available commands -make run # Build and deploy application -make local-debug # Start local debugging environment -make build # Build application only -make status # Check application status -make logs # View application logs -make clean-all # Clean all resources -make swagger # Generate API documentation +just --list # Show all available commands +just run # Deploy to the configured Kubernetes target +just local-deploy # Boot local infra dependencies with Docker Compose +just local-debug # Start local producer+consumer debug process +just swagger-init 1.2.3 # Regenerate OpenAPI / Swagger artifacts +just generate-portal 1.2.3 # Generate portal TypeScript SDK +just generate-admin 1.2.3 # Generate admin TypeScript SDK +just generate-python-sdk 1.2.3 # Generate Python SDK +just release-portal 1.2.3 # Generate release-ready portal TypeScript SDK +just release-admin 1.2.3 # Generate release-ready admin TypeScript SDK +just release-python-sdk 1.2.3 # Generate release-ready Python SDK +just test-regression # Run the Python SDK regression workflow ``` ## 🐛 Troubleshooting @@ -262,8 +537,8 @@ make swagger # Generate API documentation # Check database status kubectl get pods | grep mysql - # Reset database - make reset-db + # Re-run the local debug stack after fixing config/env + just local-debug ``` 2. **Pod Scheduling Issues** @@ -282,11 +557,44 @@ make swagger # Generate API documentation kubectl auth can-i create pods --namespace=default ``` +4. **A Request Works In `producer` But Fails In Split-Service Mode** + + Check in this order: + + - are the dedicated services actually running? + - are the required `clients.*.target` values configured? + - is the request going through `src/internalclient/*` as expected? + - is the destination gRPC service registered and listening? + +5. **Submit Works But Task State Does Not Move** + + Check in this order: + + - Redis queue health + - `src/service/consumer/*` + - runtime infra (`src/infra/k8s/*`, `src/infra/buildkit/*`, `src/infra/helm/*`) + - orchestrator owner write-back path + +### Quick Validation Commands + +```bash +cd src && go test ./... +cd src && go test ./app -run 'TestProducerOptionsValidate|TestProducerOptionsStartStopSmoke|TestProducerOptionsHTTPIntegrationSmoke' +cd src && go test ./app -run 'TestConsumerOptions|TestBothOptions' +cd src && go test ./router ./docs ./interface/http +``` + +Real-cluster K8s validation: + +```bash +cd src && RUN_K8S_INTEGRATION=1 go test ./infra/k8s -run TestK8sGatewayJobLifecycleIntegration +``` + ### Getting Help -- Check the [troubleshooting guide](docs/troubleshooting.md) -- Review application logs with `make logs` -- Verify configuration in `src/config.toml` +- Review the consolidated notes in `docs/report-index.md` +- Run `just --list` to inspect the supported local workflows +- Verify configuration in `src/config.dev.toml` ## 📊 Performance Considerations diff --git a/config.dev.toml b/config.dev.toml index b0b0eedc..265bf1dd 100644 --- a/config.dev.toml +++ b/config.dev.toml @@ -60,6 +60,36 @@ experiment_storage_path = "/tmp/aegislab/experiment_storage" [buildkit] address = "localhost:1234" +[clients.iam] +target = "localhost:9091" + +[clients.orchestrator] +target = "localhost:9092" + +[clients.resource] +target = "localhost:9093" + +[clients.runtime] +target = "localhost:9094" + +[clients.system] +target = "localhost:9095" + +[iam.grpc] +addr = ":9091" + +[orchestrator.grpc] +addr = ":9092" + +[resource.grpc] +addr = ":9093" + +[runtime_worker.grpc] +addr = ":9094" + +[system.grpc] +addr = ":9095" + [loki] address = "http://10.10.10.161:3100" timeout = "10s" diff --git a/docker-compose.microservices.yaml b/docker-compose.microservices.yaml new file mode 100644 index 00000000..ffd6edd7 --- /dev/null +++ b/docker-compose.microservices.yaml @@ -0,0 +1,109 @@ +name: aegislab-microservices +services: + iam-service: + image: golang:1.24-bookworm + working_dir: /workspace/src + command: ["go", "run", "./cmd/iam-service", "-conf", "/workspace/src/config.dev.toml"] + volumes: + - ./:/workspace + environment: + GOPROXY: https://proxy.golang.org,direct + ports: + - "9091:9091" + depends_on: + redis: + condition: service_healthy + mysql: + condition: service_healthy + + orchestrator-service: + image: golang:1.24-bookworm + working_dir: /workspace/src + command: ["go", "run", "./cmd/orchestrator-service", "-conf", "/workspace/src/config.dev.toml"] + volumes: + - ./:/workspace + environment: + GOPROXY: https://proxy.golang.org,direct + ports: + - "9092:9092" + depends_on: + redis: + condition: service_healthy + mysql: + condition: service_healthy + + resource-service: + image: golang:1.24-bookworm + working_dir: /workspace/src + command: ["go", "run", "./cmd/resource-service", "-conf", "/workspace/src/config.dev.toml"] + volumes: + - ./:/workspace + environment: + GOPROXY: https://proxy.golang.org,direct + ports: + - "9093:9093" + depends_on: + mysql: + condition: service_healthy + orchestrator-service: + condition: service_started + + runtime-worker-service: + image: golang:1.24-bookworm + working_dir: /workspace/src + command: ["go", "run", "./cmd/runtime-worker-service", "-conf", "/workspace/src/config.dev.toml"] + volumes: + - ./:/workspace + environment: + GOPROXY: https://proxy.golang.org,direct + ports: + - "9094:9094" + depends_on: + redis: + condition: service_healthy + mysql: + condition: service_healthy + etcd: + condition: service_started + orchestrator-service: + condition: service_started + + system-service: + image: golang:1.24-bookworm + working_dir: /workspace/src + command: ["go", "run", "./cmd/system-service", "-conf", "/workspace/src/config.dev.toml"] + volumes: + - ./:/workspace + environment: + GOPROXY: https://proxy.golang.org,direct + ports: + - "9095:9095" + depends_on: + redis: + condition: service_healthy + mysql: + condition: service_healthy + etcd: + condition: service_started + runtime-worker-service: + condition: service_started + + api-gateway: + image: golang:1.24-bookworm + working_dir: /workspace/src + command: ["go", "run", "./cmd/api-gateway", "-conf", "/workspace/src/config.dev.toml", "-port", "8082"] + volumes: + - ./:/workspace + environment: + GOPROXY: https://proxy.golang.org,direct + ports: + - "8082:8082" + depends_on: + iam-service: + condition: service_started + orchestrator-service: + condition: service_started + resource-service: + condition: service_started + system-service: + condition: service_started diff --git a/docs/aegisctl-cli-spec.md b/docs/aegisctl-cli-spec.md deleted file mode 100644 index 68736f6d..00000000 --- a/docs/aegisctl-cli-spec.md +++ /dev/null @@ -1,1476 +0,0 @@ -# aegisctl CLI Client Specification - -## Overview - -`aegisctl` is a Go-based command-line client for the AegisLab (RCABench) backend API. It enables AI agents and human operators to drive the full RCA experiment lifecycle from the terminal — fault injection, progress monitoring, algorithm execution, and result inspection — without manual curl commands or browser interaction. - -## Design Principles - -### 1. Name-First, Semantic-Driven - -All resource references use **names** instead of numeric IDs. The CLI internally resolves names to IDs via the API, keeping the interface human-readable and agent-friendly. - -```bash -# Good — semantic -aegisctl inject get pod-kill-ts-order-20260413 -aegisctl execute submit --project train-ticket --spec exec.yaml - -# Bad — opaque IDs -aegisctl inject get 42 -aegisctl execute submit --project 7 --spec exec.yaml -``` - -**Exception**: Resources without semantic names (task IDs, trace IDs, execution IDs) use their UUID/numeric identifiers directly. - -### 2. Machine-Parseable Output - -Every command supports `--output json` (alias `-o json`) for agent consumption. Default output is `table` for human readability. - -### 3. Exit Code Convention - -| Code | Meaning | -|------|---------| -| 0 | Success | -| 1 | Client error (invalid input, missing config, validation failure) | -| 2 | Server error (API returned 4xx/5xx) | -| 3 | Timeout (used by `wait` command) | - -### 4. Stream Separation - -- `stdout`: structured output only (data, tables, JSON) -- `stderr`: errors, warnings, progress messages - -This allows agents to pipe stdout to `jq` while still seeing errors. - ---- - -## Authentication & Configuration - -### Config File - -Location: `~/.aegisctl/config.yaml` - -```yaml -current-context: dev - -contexts: - dev: - server: http://localhost:8082 - token: eyJhbGci... - default-project: train-ticket - token-expiry: 2026-04-14T03:00:00Z - staging: - server: https://aegislab-staging.example.com - token: eyJhbGci... - -preferences: - output: table # Default output format (table|json|wide|yaml) - request-timeout: 30s # HTTP request timeout -``` - -### Token Resolution Priority (highest to lowest) - -1. `--token` command-line flag -2. `AEGIS_TOKEN` environment variable -3. `token` field in the active context of `config.yaml` - -### Environment Variable Overrides - -| Variable | Purpose | -|----------|---------| -| `AEGIS_SERVER` | API server URL | -| `AEGIS_TOKEN` | Authentication token | -| `AEGIS_PROJECT` | Default project name | -| `AEGIS_OUTPUT` | Default output format | -| `AEGIS_TIMEOUT` | Default request timeout | - -### Token Auto-Refresh - -Before each request, check `token-expiry`. If the token will expire within 5 minutes, automatically call `/api/v2/auth/refresh` and update the config file. - ---- - -## Command Tree - -### Global Flags - -Available on all commands: - -| Flag | Short | Env Var | Description | -|------|-------|---------|-------------| -| `--server` | `-s` | `AEGIS_SERVER` | API server URL | -| `--token` | `-t` | `AEGIS_TOKEN` | Authentication token | -| `--project` | `-p` | `AEGIS_PROJECT` | Default project name | -| `--output` | `-o` | `AEGIS_OUTPUT` | Output format: `table`, `json`, `wide`, `yaml` | -| `--request-timeout` | | `AEGIS_TIMEOUT` | HTTP request timeout (default: 30s) | -| `--quiet` | `-q` | | Suppress progress/info messages on stderr | -| `--dry-run` | | | Validate input without submitting (where applicable) | - ---- - -### `aegisctl auth` — Authentication - -#### `aegisctl auth login` - -Authenticate and persist token. - -```bash -# Interactive (prompts for password) -aegisctl auth login --server http://localhost:8082 --username admin - -# Non-interactive (for scripts/agents) -aegisctl auth login --server http://localhost:8082 --username admin --password admin - -# With context name -aegisctl auth login --server http://localhost:8082 --username admin --password admin --context dev -``` - -**Behavior**: -- Calls `POST /api/v2/auth/login` -- Saves token + server + expiry to `~/.aegisctl/config.yaml` -- Sets as `current-context` if no context exists yet -- Prints authentication status to stdout - -**Flags**: - -| Flag | Required | Description | -|------|----------|-------------| -| `--server` | Yes | API server URL | -| `--username` | Yes | Username | -| `--password` | No | Password (prompts if omitted) | -| `--context` | No | Context name to save as (default: hostname-derived) | - -#### `aegisctl auth status` - -Show current authentication status. - -```bash -aegisctl auth status -# Output: -# Context: dev -# Server: http://localhost:8082 -# User: admin -# Token: eyJh...xyz (expires: 2026-04-14T03:00:00Z) -# Status: valid -``` - -**Behavior**: Calls `GET /api/v2/auth/profile` to verify token validity. - -#### `aegisctl auth token` - -Directly set an API token without login flow. - -```bash -aegisctl auth token --set eyJhbGci... -``` - -**Use case**: CI/CD pipelines or agents that receive tokens from external secret managers. - ---- - -### `aegisctl context` — Multi-Environment Management - -#### `aegisctl context set` - -Create or update a context. - -```bash -aegisctl context set --name staging --server https://aegislab-staging.example.com -aegisctl context set --name dev --default-project train-ticket -``` - -#### `aegisctl context use` - -Switch active context. - -```bash -aegisctl context use staging -``` - -#### `aegisctl context list` - -List all configured contexts. - -```bash -aegisctl context list -# Output: -# NAME SERVER DEFAULT-PROJECT CURRENT -# dev http://localhost:8082 train-ticket * -# staging https://aegislab-staging.example.com - -``` - ---- - -### `aegisctl project` — Project Management - -#### `aegisctl project list` - -```bash -aegisctl project list -aegisctl project list -o json -``` - -**API**: `GET /api/v2/projects` - -#### `aegisctl project get` - -```bash -aegisctl project get train-ticket -aegisctl project get train-ticket -o json -``` - -**API**: `GET /api/v2/projects/:project_id` (resolved from name) - -#### `aegisctl project create` - -```bash -aegisctl project create --name train-ticket --description "Train ticket microservice system" -``` - -**API**: `POST /api/v2/projects` - ---- - -### `aegisctl container` — Container Management - -#### `aegisctl container list` - -```bash -aegisctl container list -aegisctl container list --type algorithm -aegisctl container list --type pedestal -aegisctl container list --type benchmark -``` - -**API**: `GET /api/v2/containers` - -**Flags**: - -| Flag | Description | -|------|-------------| -| `--type` | Filter by container type: `algorithm`, `benchmark`, `pedestal` | - -#### `aegisctl container get` - -```bash -aegisctl container get train-ticket -``` - -**API**: `GET /api/v2/containers/:container_id` - -#### `aegisctl container versions` - -```bash -aegisctl container versions train-ticket -``` - -**API**: `GET /api/v2/containers/:container_id/versions` - -Output columns: `Version`, `Image`, `IMAGE`, `Usage`, `Updated`. The `IMAGE` -column is composed from `(registry, namespace, repository, tag)` as -`//:` (the namespace segment is -omitted when empty). - -#### `aegisctl container build` - -```bash -aegisctl container build train-ticket --version v1.0.0 -``` - -**API**: `POST /api/v2/containers/build` - -#### `aegisctl container version list-versions` - -List container versions for a container, including an `IMAGE` column built -from `(registry, namespace, repository, tag)`. - -```bash -aegisctl container version list-versions train-ticket -aegisctl container version list-versions --name train-ticket -o json -``` - -**API**: `GET /api/v2/containers/:container_id/versions` - -Output columns: `ID`, `Version`, `IMAGE`, `Usage`, `Updated`. - -#### `aegisctl container version set-image` - -Rewrite the image reference of a container version directly in the -database. This is the supported alternative to `mysql -e UPDATE ...` for -swapping an unreachable registry for a working one. - -```bash -aegisctl container version set-image --id 42 --ref ghcr.io/org/team/app:v1.2.3 -aegisctl container version set-image --id 42 --ref ghcr.io/org/team/app:v1.2.3 --dry-run -aegisctl container version set-image --id 42 --ref nginx:1.25 # defaults registry to docker.io -``` - -**API**: `PATCH /api/v2/container-versions/:id/image` with body -`{"registry":"...", "namespace":"...", "repository":"...", "tag":"..."}`. - -**Flags**: - -| Flag | Required | Description | -|------|----------|-------------| -| `--id` | Yes | Container version ID | -| `--ref` | Yes | Full image reference `//:` | -| `--dry-run` | No | Print the current vs proposed diff and exit without writing | - -**Reference parsing rules**: - -- A tag is **required**. `nginx` (no `:tag`) is rejected. -- A registry is **optional**. If the first path segment contains no `.` or `:` - and is not `localhost`, the registry defaults to `docker.io`. Examples: - - `nginx:1.25` → `docker.io/nginx:1.25` (no namespace) - - `library/nginx:1.25` → `docker.io/library/nginx:1.25` -- **Nested namespaces are preserved.** Only the last path segment is the - repository; everything between the registry and the repository is the - namespace. Example: `docker.io/foo/bar/baz:tag` → - `registry=docker.io, namespace=foo/bar, repository=baz, tag=tag`. -- `localhost[:port]` is treated as a registry host. -- **Digest references (`@sha256:...`) are rejected.** The backend row stores a - tag, not a digest; accepting digests would silently drop the digest. - -**Auth**: requires container version write permission (reuses -`RequireContainerVersionUpdate`, the same middleware as -`PATCH /api/v2/containers/:container_id/versions/:version_id`). - -**TODO (future issue)**: `pedestal helm values rewrite-images` is not -implemented here — the `helm_configs` table is intentionally left -untouched. - ---- - -### `aegisctl inject` — Fault Injection (Core) - -#### `aegisctl inject submit` - -Submit a fault injection experiment. - -```bash -aegisctl inject submit --project train-ticket --spec injection-spec.yaml -aegisctl inject submit --project train-ticket --spec injection-spec.yaml --dry-run -aegisctl inject submit --project train-ticket --spec injection-spec.yaml -o json -``` - -**API**: `POST /api/v2/projects/:project_id/injections/inject` - -**Spec File Format** (`injection-spec.yaml`): - -```yaml -pedestal: - name: train-ticket - version: v1.0.0 -benchmark: - name: jaeger-collector - version: v1.0.0 -interval: 30 # Total experiment interval in minutes -pre_duration: 10 # Normal data collection duration before fault injection (minutes) -specs: - # Each top-level element is a batch; faults within a batch run in parallel - - - type: pod-kill - namespace: ts - target: ts-order-service - duration: 60s - - - type: cpu-stress - namespace: ts - target: ts-payment-service - duration: 120s - - type: network-delay - namespace: ts - target: ts-order-service - duration: 120s -algorithms: # Optional: RCA algorithms to execute after injection - - name: rca-algo-1 - version: v1.0.0 - env_vars: - - key: THRESHOLD - value: "0.5" -labels: # Optional: labels to attach - - key: experiment - value: batch-001 - - key: scenario - value: cascade-failure -``` - -**JSON Output** (on success): - -```json -{ - "trace_id": "abc-123-def-456", - "group_id": "grp-789", - "tasks": [ - {"task_id": "task-001", "type": "RestartPedestal", "state": "Pending"} - ] -} -``` - -**`--dry-run` behavior**: Validate the spec file against the server (check container names exist, spec structure valid) without submitting. Exit 0 if valid, exit 1 with validation errors. - -**`--wait` (opt-in, blocks until terminal state)** - -```bash -aegisctl inject submit --project train-ticket --spec injection.yaml --wait -aegisctl inject submit --project train-ticket --spec injection.yaml --wait --timeout 10m -aegisctl inject submit --project train-ticket --spec injection.yaml --wait --wait-until datapack_ready -``` - -When `--wait` is set, the CLI subscribes to `GET /api/v2/traces/:trace_id/stream` (SSE) for the returned `trace_id` and blocks until the trace reaches a terminal state, or `--timeout` elapses (default `600s`). The `injection_name` field is populated once the consumer emits `fault.injection.started` on the trace stream (the event payload is the CRD name). - -**`--wait-until ` (optional, early-exit)** - -| CLI value | Trace-stream `event_name` it waits for | -|-----------|-----------------------------------------| -| `injection_created` | `fault.injection.started` (consumer sets CRD name) | -| `fault_injection_started` | `fault.injection.started` (alias of above) | -| `datapack_ready` | `datapack.build.succeed` or `datapack.result.collection` | -| `finished` | terminal `end` SSE frame (default) | - -Event names above are the literal `event_name` strings emitted by `service/consumer/*` to the Redis trace stream (see `src/consts/consts.go` `EventType` constants). Terminal success/failure events recognized by the CLI: - -- success → `datapack.build.succeed`, `datapack.result.collection`, `datapack.no_anomaly`, `datapack.no_detector_data`, `algorithm.run.succeed`, `algorithm.result.collection`, plus the SSE `event: end` framing event emitted by `handlers/v2/traces.go` -- failure → `restart.pedestal.failed`, `fault.injection.failed`, `datapack.build.failed`, `algorithm.run.failed`, `image.build.failed` - -**Wait-mode JSON output** (stdout, emitted whether exit is 0/2/3): - -```json -{ - "injection_name": "otel-demo0-checkout-delay-p7qd5c", - "injection_id": 42, - "trace_id": "abc-123-def-456", - "trace_state": "Succeeded", - "datapack_id": 17, - "duration_seconds": 348 -} -``` - -`injection_id` is resolved via `GET /api/v2/injections?page=1&size=100` (name→id lookup) once the name is known; omitted if the name hasn't been emitted yet. `datapack_id` is omitted if the pipeline exits before the datapack stage. - -**Exit codes (`--wait` mode)** - -| Code | Meaning | -|------|---------| -| 0 | `trace_state=Succeeded` | -| 2 | `trace_state=Failed`; human-readable reason to stderr | -| 3 | `--timeout` exceeded; stderr shows current stage + `aegisctl trace watch ` hint | -| 1 | Other CLI error (network, auth, spec parse) | - -Without `--wait`, exit/output behavior is unchanged (raw response JSON on stdout). - -#### `aegisctl inject list` - -```bash -aegisctl inject list --project train-ticket -aegisctl inject list --project train-ticket --state build_success -aegisctl inject list --project train-ticket --fault-type pod-kill -aegisctl inject list --project train-ticket --labels experiment=batch-001 -aegisctl inject list --project train-ticket --page 1 --size 20 -``` - -**API**: `GET /api/v2/projects/:project_id/injections` - -**Flags**: - -| Flag | Description | -|------|-------------| -| `--state` | Filter by datapack state: `initial`, `inject_failed`, `inject_success`, `build_failed`, `build_success`, `detector_failed`, `detector_success` | -| `--fault-type` | Filter by chaos type | -| `--labels` | Filter by labels (comma-separated `key=value` pairs) | -| `--page` | Page number (default: 1) | -| `--size` | Page size (default: 20) | - -**Table Output**: - -``` -NAME STATE FAULT-TYPE START-TIME LABELS -pod-kill-ts-order-20260413 build_success pod-kill 2026-04-13T10:00:00Z experiment=batch-001 -cpu-stress-ts-payment-20260413 inject_success cpu-stress 2026-04-13T10:05:00Z experiment=batch-001 -``` - -#### `aegisctl inject get` - -```bash -aegisctl inject get pod-kill-ts-order-20260413 -aegisctl inject get pod-kill-ts-order-20260413 -o json -``` - -**API**: `GET /api/v2/injections/:id` - -#### `aegisctl inject search` - -Advanced search with multiple filters. - -```bash -aegisctl inject search --project train-ticket --name-pattern "pod-kill-*" --labels experiment=batch-001 -``` - -**API**: `POST /api/v2/projects/:project_id/injections/search` - -#### `aegisctl inject logs` - -```bash -aegisctl inject logs pod-kill-ts-order-20260413 -``` - -**API**: `GET /api/v2/injections/:id/logs` - -#### `aegisctl inject files` - -```bash -aegisctl inject files pod-kill-ts-order-20260413 -``` - -**API**: `GET /api/v2/injections/:id/files` - -**Table Output**: - -``` -PATH SIZE TYPE -traces/trace.parquet 12.3 MB parquet -metrics/cpu.parquet 5.1 MB parquet -logs/service.log 2.0 MB text -groundtruth.yaml 0.1 KB yaml -``` - -#### `aegisctl inject download` - -```bash -aegisctl inject download pod-kill-ts-order-20260413 -o /tmp/datapack/ -``` - -**API**: `GET /api/v2/injections/:id/download` - -#### `aegisctl inject metadata` - -Show available fault types, resources, and status mappings. - -```bash -aegisctl inject metadata -``` - -**API**: `GET /api/v2/injections/metadata` - -**Output**: - -``` -FAULT TYPES: - pod-kill Kill target pods - cpu-stress Inject CPU stress - memory-stress Inject memory stress - network-delay Add network latency - network-loss Inject packet loss - ... - -DATAPACK STATES: - initial, inject_failed, inject_success, build_failed, - build_success, detector_failed, detector_success -``` - ---- - -### `aegisctl execute` — Algorithm Execution - -#### `aegisctl execute submit` - -```bash -aegisctl execute submit --project train-ticket --spec execution-spec.yaml -aegisctl execute submit --project train-ticket --spec execution-spec.yaml -o json -``` - -**API**: `POST /api/v2/projects/:project_id/executions/execute` - -**Spec File Format** (`execution-spec.yaml`): - -```yaml -specs: - - algorithm: - name: rca-algo-1 - version: v1.0.0 - datapack: pod-kill-ts-order-20260413 # Reference by injection name - - algorithm: - name: rca-algo-2 - version: v2.0.0 - dataset: # Or reference by dataset - name: train-ticket-dataset - version: v1.0.0 -labels: - - key: batch - value: comparison-run -``` - -**Note**: `project_name` is automatically set from `--project` flag; do not include in spec file. - -#### `aegisctl execute list` - -```bash -aegisctl execute list --project train-ticket -``` - -**API**: `GET /api/v2/projects/:project_id/executions` - -#### `aegisctl execute get` - -```bash -aegisctl execute get 123 -aegisctl execute get 123 -o json -``` - -**API**: `GET /api/v2/executions/:execution_id` - ---- - -### `aegisctl task` — Task Monitoring - -#### `aegisctl task list` - -```bash -aegisctl task list -aegisctl task list --state Running -aegisctl task list --type FaultInjection -``` - -**API**: `GET /api/v2/tasks` - -**Flags**: - -| Flag | Description | -|------|-------------| -| `--state` | Filter: `Pending`, `Running`, `Completed`, `Error`, `Cancelled`, `Rescheduled` | -| `--type` | Filter: `BuildContainer`, `RestartPedestal`, `FaultInjection`, `RunAlgorithm`, `BuildDatapack`, `CollectResult`, `CronJob` | -| `--overdue` | Show only Pending tasks whose `execute_time` is already in the past (WAIT < 0) | - -**Table Output**: - -``` -TASK-ID TYPE STATE WAIT TRACE-ID PROJECT CREATED -task-abc123 RestartPedestal Running - trace-def456 train-ticket 2m ago -task-xyz789 FaultInjection Pending +01:23 trace-def456 train-ticket 1m ago -task-overdue BuildDatapack Pending -00:05 trace-def456 train-ticket 3m ago -``` - -**`WAIT` column**: for `Pending` rows, shows the signed remaining time until -`execute_time`, rendered as `+MM:SS` (still waiting) or `-MM:SS` (overdue — the -scheduler has not picked it up yet). Non-Pending rows show `-`. - -#### `aegisctl task expedite` - -Force a `Pending` task to run on the next scheduler tick. - -```bash -aegisctl task expedite -``` - -**API**: `POST /api/v2/tasks/:task_id/expedite` - -Atomically resets the task's `execute_time` to now in both the MySQL `tasks` -table and the Redis `task:delayed` sorted set. The consumer emits a -`task.scheduled` trace event with `reason=expedite`. - -- Rejects with `state=, cannot expedite` if the task is not in `Pending`. -- Idempotent: expediting an already-due task succeeds silently. -- The CLI never talks to Redis directly — all atomic work happens server-side. - -#### `aegisctl task get` - -```bash -aegisctl task get task-abc123 -aegisctl task get task-abc123 -o json -``` - -**API**: `GET /api/v2/tasks/:task_id` - -#### `aegisctl task logs` - -Stream task logs in real-time. - -```bash -aegisctl task logs task-abc123 -aegisctl task logs task-abc123 --follow # Continuously stream via WebSocket -``` - -**API**: `GET /api/v2/tasks/:task_id/logs/ws` (WebSocket) - -**`--follow` behavior**: Keep WebSocket connection open, print new log lines as they arrive. Ctrl+C to stop. - -**Without `--follow`**: Connect, read available logs, disconnect. - ---- - -### `aegisctl trace` — Experiment Tracing - -#### `aegisctl trace list` - -```bash -aegisctl trace list -aegisctl trace list --project train-ticket -aegisctl trace list --state Running -``` - -**API**: `GET /api/v2/traces` - -**Flags**: - -| Flag | Description | -|------|-------------| -| `--project` | Filter by project name | -| `--state` | Filter: `Pending`, `Running`, `Completed`, `Failed` | -| `--group-id` | Filter by group ID | - -**Table Output**: - -``` -TRACE-ID TYPE STATE PROJECT START-TIME TASKS -trace-abc123 FullPipeline Running train-ticket 2026-04-13T10:00:00Z 3/5 -trace-def456 AlgorithmRun Completed train-ticket 2026-04-13T09:30:00Z 2/2 -``` - -#### `aegisctl trace get` - -```bash -aegisctl trace get trace-abc123 -aegisctl trace get trace-abc123 -o json -``` - -**API**: `GET /api/v2/traces/:trace_id` - -**Detailed Output** (includes child tasks): - -``` -Trace: trace-abc123 -Type: FullPipeline -State: Running -Start: 2026-04-13T10:00:00Z - -Tasks: - TASK-ID TYPE STATE DURATION - task-001 RestartPedestal Completed 45s - task-002 FaultInjection Completed 5m30s - task-003 BuildDatapack Running 2m10s (in progress) - task-004 RunAlgorithm Pending - - task-005 CollectResult Pending - -``` - -#### `aegisctl trace watch` - -Real-time SSE event stream for a trace. - -```bash -aegisctl trace watch trace-abc123 -``` - -**API**: `GET /api/v2/traces/:trace_id/stream` (SSE) - -**Output** (streaming): - -``` -[10:00:05] RestartPedestal task-001 Running Restarting pedestal... -[10:00:45] RestartPedestal task-001 Completed Pedestal restarted successfully -[10:00:46] FaultInjection task-002 Running Injecting pod-kill on ts-order-service -[10:06:16] FaultInjection task-002 Completed Fault injection completed -[10:06:17] BuildDatapack task-003 Running Building datapack... -... -``` - -**Termination**: Stream ends when trace reaches terminal state (`Completed` or `Failed`), or on Ctrl+C. - ---- - -### `aegisctl dataset` — Dataset Management - -#### `aegisctl dataset list` - -```bash -aegisctl dataset list -``` - -**API**: `GET /api/v2/datasets` - -#### `aegisctl dataset get` - -```bash -aegisctl dataset get train-ticket-dataset -``` - -**API**: `GET /api/v2/datasets/:dataset_id` - -#### `aegisctl dataset versions` - -```bash -aegisctl dataset versions train-ticket-dataset -``` - -**API**: `GET /api/v2/datasets/:dataset_id/versions` - ---- - -### `aegisctl eval` — Evaluation Results - -#### `aegisctl eval list` - -```bash -aegisctl eval list -``` - -**API**: `GET /api/v2/evaluations` - -#### `aegisctl eval get` - -```bash -aegisctl eval get 123 -``` - -**API**: `GET /api/v2/evaluations/:id` - ---- - -### `aegisctl wait` — Block Until Completion - -Block execution until a trace or task reaches a terminal state. This is the primary synchronization primitive for agents. - -```bash -aegisctl wait trace-abc123 -aegisctl wait trace-abc123 --timeout 600s -aegisctl wait task-xyz789 --timeout 300s --interval 5s -aegisctl wait trace-abc123 --exit-on error -``` - -**Behavior**: -1. Detect whether the argument is a trace ID or task ID (by format or API probe) -2. Poll the status at `--interval` (default: 5s) -3. Print status line on each poll (to stderr, unless `--quiet`) -4. Exit when terminal state is reached or timeout - -**Flags**: - -| Flag | Default | Description | -|------|---------|-------------| -| `--timeout` | `600s` | Maximum wait time | -| `--interval` | `5s` | Poll interval | -| `--exit-on` | `completed,error` | Which terminal states to exit on | -| `--quiet` | `false` | Suppress polling status output | - -**Exit codes**: -- `0`: Completed successfully -- `2`: Completed with error/failure -- `3`: Timeout - -**JSON output** (`-o json`): On exit, prints the final resource state to stdout. - -```json -{ - "id": "trace-abc123", - "state": "Completed", - "duration": "5m30s", - "tasks_completed": 5, - "tasks_total": 5 -} -``` - -**Polling status** (stderr): - -``` -Waiting for trace-abc123... [Running] BuildDatapack (3/5 tasks) 2m10s elapsed -Waiting for trace-abc123... [Running] RunAlgorithm (4/5 tasks) 4m30s elapsed -Waiting for trace-abc123... [Completed] 5/5 tasks in 5m30s -``` - ---- - -### `aegisctl status` — Global Overview - -Show cluster status, task summary, recent traces, and infrastructure health. - -```bash -aegisctl status -aegisctl status -o json -``` - -**Output**: - -``` -Server: http://localhost:8082 (dev) -User: admin -Connected: yes - -Active Tasks: 3 - Running: 2 - Pending: 1 - -Recent Traces: -Trace-ID State Type Project -trace-abc123 Running FullPipeline train-ticket -trace-def456 Completed AlgorithmRun train-ticket - -Infrastructure Health: - ✓ buildkit 2ms - ✓ database 3.5ms - ✓ jaeger 1ms - ✓ kubernetes 5ms - ✓ redis 1.2ms -``` - -**Unhealthy service output**: - -``` -Infrastructure Health: - ✓ buildkit 2ms - ✗ database N/A (connection refused) - ✓ jaeger 1ms - ✓ kubernetes 5ms - ✓ redis 1.2ms -``` - -**API**: Aggregates `GET /api/v2/auth/profile`, `GET /api/v2/tasks`, `GET /api/v2/traces`, and `GET /system/health`. - -**Behavior**: -- Calls `/system/health` to check Redis, MySQL/database, Kubernetes, Jaeger, and BuildKit connectivity. -- Displays green (`✓`) for healthy services and red (`✗`) for unhealthy services with ANSI color codes. -- Services are listed in alphabetical order. -- If the health endpoint is unreachable, a single `✗` line indicates the failure. -- `--output json` returns a combined JSON object with `server`, `context`, `connected`, `username`, `tasks`, `recent_traces`, and `health` fields. - ---- - -### `aegisctl cluster` — Cluster Dependency Management - -Operations that target the AegisLab cluster and its backing services -(Kubernetes, MySQL, ClickHouse, Redis, etcd). - -#### `aegisctl cluster preflight` - -Verify that every dependency required by AegisLab is reachable and -configured. The command prints one row per check with `[OK]` / `[FAIL]` / -`[WARN]` and a suggested fix on failure. Overall exit code is `0` when -every executed check is OK, `1` otherwise. - -```bash -aegisctl cluster preflight -aegisctl cluster preflight --check k8s.rcabench-sa -aegisctl cluster preflight --fix -aegisctl cluster preflight --config /path/to/config.dev.toml -``` - -**Flags**: - -| Flag | Description | -|------|-------------| -| `--check ` | Run only the named check | -| `--fix` | Apply idempotent remediation for failing checks that support it | -| `--config ` | Path to a specific config TOML (defaults to `config.$ENV_MODE.toml` in cwd) | -| `--check-timeout ` | Per-check timeout (default: 10s) | - -**Check catalog**: - -| ID | Description | `--fix` support | -|----|-------------|-----------------| -| `k8s.exp-namespace` | namespace `exp` exists | — | -| `k8s.rcabench-sa` | ServiceAccount `rcabench-sa` in `exp` exists | yes (kubectl create sa) | -| `k8s.dataset-pvc` | PVC `rcabench-juicefs-dataset` in `exp` exists & Bound | — (storage-class decision required) | -| `k8s.chaosmesh-crds` | `chaos-mesh.org` CRDs present | — | -| `db.mysql` | TCP reachable using `database.mysql.host:port` | — | -| `db.clickhouse` | TCP reachable using `database.clickhouse.host:port` | — | -| `db.redis` | TCP reachable using `redis.host` | — | -| `db.etcd` | TCP reachable using `etcd.endpoints[0]` | — | -| `clickhouse.otel-tables` | `otel_traces`, `otel_metrics_gauge`, `otel_metrics_sum`, `otel_metrics_histogram`, `otel_logs` tables exist in the `otel` db | — | -| `redis.token-bucket-leaks` | no terminal tasks leaking slots in `token_bucket:restart_service` | yes (SREM leaked task_ids) | - -**Output** (truncated): - -``` -CHECK STATUS DETAIL ------------------------- ------ -------------------- -k8s.exp-namespace [OK] namespace "exp" present -k8s.rcabench-sa [FAIL] ServiceAccount exp/rcabench-sa missing - fix: kubectl -n exp create serviceaccount rcabench-sa (or rerun with --fix) -k8s.dataset-pvc [OK] exp/rcabench-juicefs-dataset Bound -... -``` - -**Config resolution**: The command reads `config.$ENV_MODE.toml` (default -`ENV_MODE=dev`) from the current working directory. Required keys: -`[database.mysql] host/port`, `[database.clickhouse] host/port/database`, -`redis.host`, `etcd.endpoints`, `k8s.namespace`, and the JuiceFS PVC + -service-account names under `[k8s.job.*]`. - -**Not yet implemented** (intentionally, to keep preflight fast): - -- container_versions registry pullability — too slow for synchronous run. -- `helm_configs.repo_url` reachability — too slow. - -Both are tracked as TODO comments in `src/cmd/aegisctl/cluster/checks.go`. - ---- - -### `aegisctl completion` — Shell Completion - -```bash -aegisctl completion bash > /etc/bash_completion.d/aegisctl -aegisctl completion zsh > "${fpath[1]}/_aegisctl" -aegisctl completion fish > ~/.config/fish/completions/aegisctl.fish -``` - ---- - -### `aegisctl pedestal` — Pedestal (SUT) Infrastructure - -Commands for managing the pedestal container's helm chart configuration -(the `helm_configs` table) without resorting to `mysql -e UPDATE ...` and -without triggering a real `restart_pedestal` task. - -Typical workflow when a pedestal fails to start because the `helm_configs` -row points at a bad repo URL or wrong chart version: - -```bash -aegisctl pedestal helm get --container-version-id 42 -aegisctl pedestal helm set --container-version-id 42 \ - --chart-name pedestal --version 1.2.3 \ - --repo-url https://charts.example.com --repo-name aegis -aegisctl pedestal helm verify --container-version-id 42 -``` - -#### `aegisctl pedestal helm get` - -```bash -aegisctl pedestal helm get --container-version-id 42 -aegisctl pedestal helm get --container-version-id 42 --output json -``` - -**API**: `GET /api/v2/pedestal/helm/:container_version_id` - -Returns all columns of the matching `helm_configs` row. Requires an -authenticated user. - -**Flags**: - -| Flag | Description | -|------|-------------| -| `--container-version-id` | Container version ID (required, > 0) | - -#### `aegisctl pedestal helm set` - -```bash -aegisctl pedestal helm set \ - --container-version-id 42 \ - --chart-name pedestal \ - --version 1.2.3 \ - --repo-url https://charts.example.com \ - --repo-name aegis \ - --values-file /pvc/values.yaml \ - --local-path /pvc/charts/pedestal-1.2.3.tgz -``` - -**API**: `PUT /api/v2/pedestal/helm/:container_version_id` - -Upserts the `helm_configs` row bound to the container version. Requires -project / container-version upload permission (system admin or an -equivalent admin role). Idempotent: calling twice with the same flags -produces the same row. - -**Flags**: - -| Flag | Required | Description | -|------|----------|-------------| -| `--container-version-id` | yes | Container version ID (> 0) | -| `--chart-name` | yes | Helm chart name | -| `--version` | yes | Helm chart version (semver) | -| `--repo-url` | yes | Helm repository URL | -| `--repo-name` | yes | Helm repository name / alias | -| `--values-file` | no | Path to values YAML file | -| `--local-path` | no | Local chart fallback path | - -#### `aegisctl pedestal helm verify` - -```bash -aegisctl pedestal helm verify --container-version-id 42 -aegisctl pedestal helm verify --container-version-id 42 --output json -``` - -**API**: `POST /api/v2/pedestal/helm/:container_version_id/verify` - -Runs a dry-run check pipeline on the server side: - -1. `helm repo add --force-update` -2. `helm repo update` -3. `helm pull / --version ` into a tmp dir which is - discarded afterward -4. If `value_file` is set: open and `yaml.Unmarshal` to assert it parses, - plus a shallow check that `image.repository` / `image.tag` are scalar - when present. Image reachability is **not** checked (TODO: add - `skopeo inspect` once the round-trip is fast enough). - -The command exits **0** on success, **1** on any failed check. On -failure, each check's `detail` includes the helm CLI stderr — the CLI -output is never hidden. - -Sample JSON response body: - -```json -{ - "ok": false, - "checks": [ - {"name": "repo_add", "ok": true}, - {"name": "repo_update", "ok": true}, - {"name": "helm_pull", "ok": false, - "detail": "helm pull failed: exit status 1\nError: chart \"pedestal\" matching 9.9.9 not found"} - ] -} -``` - -**Flags**: - -| Flag | Description | -|------|-------------| -| `--container-version-id` | Container version ID (required, > 0) | - ---- - -## Internal Architecture - -### Directory Structure - -``` -src/cmd/aegisctl/ -├── main.go # Entry point -├── cmd/ -│ ├── root.go # Cobra root command + global flags -│ ├── auth.go # auth login, status, token -│ ├── context.go # context set, use, list -│ ├── project.go # project list, get, create -│ ├── container.go # container list, get, versions, build -│ ├── inject.go # inject submit, list, get, search, logs, files, download, metadata -│ ├── execute.go # execute submit, list, get -│ ├── task.go # task list, get, logs -│ ├── trace.go # trace list, get, watch -│ ├── dataset.go # dataset list, get, versions -│ ├── eval.go # eval list, get -│ ├── wait.go # wait (poll trace/task state) -│ ├── status.go # status overview -│ └── completion.go # shell completion generation -├── client/ -│ ├── client.go # Core HTTP client (request/response/error handling) -│ ├── auth.go # Token management + auto-refresh -│ ├── sse.go # SSE streaming (trace watch, group stream) -│ ├── ws.go # WebSocket (task logs --follow) -│ └── resolver.go # Name-to-ID resolution + cache -├── config/ -│ └── config.go # ~/.aegisctl/config.yaml read/write -└── output/ - ├── format.go # Output dispatcher (table/json/wide/yaml) - ├── table.go # Table formatting with column alignment - └── printer.go # stdout/stderr stream separation -``` - -### Name-to-ID Resolver - -The resolver is the core abstraction that makes name-based references work. It maintains a short-lived cache to avoid redundant API calls within a single command session. - -```go -type Resolver struct { - client *Client - cache map[string]int // key format: "resource_type:name" -> ID - ttl time.Duration // Cache TTL (default: 5 minutes) -} - -// Core resolution methods -func (r *Resolver) ProjectID(name string) (int, error) -func (r *Resolver) ContainerID(name string) (int, error) -func (r *Resolver) InjectionID(name string) (int, error) -func (r *Resolver) DatasetID(name string) (int, error) -``` - -**Resolution strategy**: -1. Check local cache -2. Call list API with name filter (e.g., `GET /api/v2/projects?name=train-ticket`) -3. If exactly one match, cache and return ID -4. If zero matches, return error: `project "train-ticket" not found` -5. If multiple matches, return error with disambiguation hint - -### HTTP Client - -```go -type Client struct { - baseURL string - token string - httpClient *http.Client - resolver *Resolver -} - -// APIResponse is the standard response envelope -type APIResponse[T any] struct { - Code int `json:"code"` - Message string `json:"message"` - Data T `json:"data"` - Timestamp string `json:"timestamp"` - Errors []string `json:"errors,omitempty"` -} - -// PaginatedData wraps list responses -type PaginatedData[T any] struct { - Items []T `json:"items"` - Pagination Pagination `json:"pagination"` -} - -type Pagination struct { - Page int `json:"page"` - Size int `json:"size"` - Total int `json:"total"` - Pages int `json:"pages"` -} -``` - -### SSE Reader - -```go -type SSEReader struct { - url string - client *http.Client - token string - lastID string -} - -func (r *SSEReader) Stream(ctx context.Context) (<-chan SSEEvent, error) -``` - -### WebSocket Reader - -```go -type WSReader struct { - url string - token string -} - -func (r *WSReader) Stream(ctx context.Context) (<-chan string, error) -``` - ---- - -## Agent Workflow Examples - -### Example 1: Full Pipeline Experiment - -```bash -#!/bin/bash -set -e - -# Setup -aegisctl auth login --server http://aegislab:8082 --username agent --password secret - -# Discover resources -ALGORITHMS=$(aegisctl container list --type algorithm -o json) -PEDESTALS=$(aegisctl container list --type pedestal -o json) - -# Generate spec file (agent generates this programmatically) -cat > /tmp/inject-spec.yaml < /tmp/exec-spec.yaml <` (e.g. `token_bucket:restart_service`, capacity 2) -whose members are task_ids currently holding a token. - -### `aegisctl rate-limiter status` - -List all buckets with columns `BUCKET | HELD/CAP | HOLDERS`. Holders in -a terminal task state (Completed / Error / Cancelled) are marked -`(LEAKED)` and colored red. - -- Auth: any authenticated user. -- Calls: `GET /api/v2/rate-limiters`. - -### `aegisctl rate-limiter reset --bucket --force` - -Delete a bucket key from Redis. Errors on unknown or missing bucket. -`--force` required. - -- Auth: system admin only. -- Calls: `DELETE /api/v2/rate-limiters/:bucket`. - -### `aegisctl rate-limiter gc` - -Release tokens held by terminal-state tasks across all buckets. Prints -`released N leaked tokens from M buckets`. - -- Auth: system admin only. -- Calls: `POST /api/v2/rate-limiters/gc`. - -### Auto-GC on consumer startup - -The consumer runs one GC pass on startup and logs -`released N leaked tokens`, preventing the `restart_service` bucket from -getting stuck at HELD=CAP after a process crash. diff --git a/docs/frontend-redesign.md b/docs/frontend-redesign.md index 44b55aa4..6432b38a 100644 --- a/docs/frontend-redesign.md +++ b/docs/frontend-redesign.md @@ -509,8 +509,8 @@ FaultInjection CRD → HandleCRDSucceeded → BuildDatapack Job → HandleJobSuc `types/api.ts` 中手写的 `Team`, `TeamMember` 等类型需迁移到 SDK 生成类型 (`@rcabench/client`)。 步骤: -1. 确保所有相关 API 在后端标注了 `@x-api-type {"sdk":"true"}` -2. `just swag-init && just generate-typescript-client` +1. 确保 OpenAPI3 中相关接口带有正确的 `x-api-type` audience 标记(如 `portal` / `admin`) +2. `just swag-init && just generate-typescript-sdk` 3. 前端 `import type { ... } from '@rcabench/client'` 替换手写类型 ## 8. UI/UX Guidelines diff --git a/docs/log-streaming-plan.md b/docs/log-streaming-plan.md deleted file mode 100644 index 0275792e..00000000 --- a/docs/log-streaming-plan.md +++ /dev/null @@ -1,527 +0,0 @@ -# 实时 K8s Job 日志流架构方案 - -> 创建日期:2026-02-17 -> 状态:Draft -> 范围:仅 K8s Job 日志(后端自身日志后续迭代) - -## TL;DR - -实现 K8s Job 日志的**生产级**实时流式传输。利用已部署的 Alloy DaemonSet 采集 Job 日志,新增 OTLP HTTP 输出到后端;后端实现 OTLP HTTP 日志接收器,提取 `task_id` 后通过 Redis Pub/Sub 分发到 WebSocket 端点;WebSocket 端点先查询 Loki 获取历史日志,再切换到实时推送。按 `task_id` 维度查询。 - -## 架构总览 - -``` - K8s Job Pods (stdout/stderr) - │ - ▼ - ┌──────────────────────────────┐ - │ Alloy DaemonSet │ - │ /var/log/pods/*.log │ - │ (已部署, 按 rcabench labels │ - │ 过滤 Job pods) │ - └─────────┬────────────────────┘ - │ loki.process "pipeline" - │ forward_to (dual-write) - │ - ┌─────────┴───────────────────────────┐ - ▼ ▼ -┌──────────┐ ┌─────────────────────┐ -│ Loki │ │ 后端 OTLP HTTP │ -│ :3100 │ │ Receiver :4319 │ -│ (持久化) │ │ /v1/logs │ -└────┬─────┘ └──────────┬──────────┘ - │ │ - │ (历史查询) │ 解析 OTLP LogRecord - │ │ 提取 task_id - │ ▼ - │ ┌─────────────────────┐ - │ │ Redis Pub/Sub │ - │ │ channel: joblogs:{task_id} - │ └──────────┬──────────┘ - │ │ - ▼ ▼ -┌────────────────────────────────────────────────────┐ -│ WebSocket Handler │ -│ GET /api/v2/tasks/{task_id}/logs/ws │ -│ │ -│ 连接流程: │ -│ 1. JWT 认证 (query param: ?token=xxx) │ -│ 2. HTTP → WebSocket 升级 │ -│ 3. 查 Loki 历史日志 → 发送 type:"history" │ -│ 4. 订阅 Redis Pub/Sub → 转发 type:"realtime" │ -│ 5. 监听 task 完成 → 发送 type:"end" → 关闭 │ -└────────────────────┬───────────────────────────────┘ - │ ws:// - ▼ - ┌──────────┐ - │ 前端 │ - │ LogsTab │ - └──────────┘ -``` - -## 为什么选择 OTLP 而非 client-go - -| 对比维度 | OTLP (Alloy → 后端) | client-go Pod Log Stream | -| ---------- | --------------------------------------- | -------------------------------- | -| **解耦** | 后端不直接连 K8s API,Alloy 负责采集 | 后端直接维护 Pod log Follow 连接 | -| **可靠性** | Alloy 有重试/缓冲机制,后端重启不丢日志 | 后端重启 = 日志流中断 | -| **扩展性** | 新增日志源只需改 Alloy 配置 | 每种日志源需要新 goroutine | -| **标准化** | OTLP 是 OpenTelemetry 标准协议 | K8s 专有 API | -| **运维** | 与现有 Alloy→Loki 管道一致 | 额外的连接管理和资源清理 | -| **生产级** | 工业标准,可对接任何 OTLP 兼容后端 | 仅适合小规模/开发环境 | - -**结论**:生产环境应使用 OTLP。Alloy 已在采集 Job 日志,只需加一路 OTLP 输出,后端作为标准 OTLP 接收器处理实时分发。 - -## 现有基础设施 - -### 已有 - -- **Alloy DaemonSet**:已部署在 `exp` namespace,通过 `rcabench_app_id` + `job_name` label 过滤 Job pods -- **Loki**:`http://10.10.10.161:3100`,已接收 Alloy 推送的日志,支持 LogQL 查询 -- **Redis**:已有 `client.RedisPublish()` / `client.GetRedisClient().Subscribe()` 方法 -- **Redis Stream**:已用于 SSE 事件推送(`StreamLogKey = "trace:%s:log"`) -- **gorilla/websocket**:`v1.5.4` 已在 go.mod(间接依赖) -- **OTel proto**:`go.opentelemetry.io/proto/otlp v1.5.0` 已在 go.mod(间接依赖) -- **前端 LogsTab**:已有基础 UI 组件 - -### 需要新增 - -- 后端 OTLP HTTP 日志接收器(`/v1/logs`,端口 4319) -- Alloy 配置增加 OTLP 输出(dual-write) -- Loki 查询客户端 -- WebSocket handler + 路由 -- 日志相关 DTO - -## 实现步骤 - -### Phase 1:后端 OTLP HTTP 日志接收器 - -**新建 `src/service/logreceiver/receiver.go`** - -实现标准 OTLP HTTP 日志接收端点,接收 Alloy 推送的 Job 日志。 - -```go -// 核心结构 -type OTLPLogReceiver struct { - server *http.Server - redisClient *redis.Client - port int - shutdownCh chan struct{} -} - -// 接收端点: POST /v1/logs -// 请求体: protobuf (application/x-protobuf) 或 JSON (application/json) -// 响应: 200 OK / 400 Bad Request / 500 Internal Server Error -``` - -**关键实现细节**: - -1. **OTLP 解析** — 使用 `go.opentelemetry.io/proto/otlp/logs/v1` 解析三层结构: - - ``` - ExportLogsServiceRequest - └── ResourceLogs[] - ├── Resource.Attributes (rcabench_app_id, namespace) - └── ScopeLogs[] - └── LogRecords[] - ├── TimeUnixNano - ├── Body.StringValue (日志行) - └── Attributes (task_id, trace_id, job_id) - ``` - -2. **元数据提取** — 从 Resource Attributes 和 Log Attributes 提取: - - `task_id`(必须,用于路由到正确的 Redis Pub/Sub channel) - - `trace_id`(可选,用于关联追踪) - - `job_id`(可选,job 名称) - - `rcabench_app_id`(已在 Alloy relabel 中设置) - -3. **Redis Pub/Sub 发布** — 按 `task_id` 发布到 channel `joblogs:{task_id}`: - - ```go - client.RedisPublish(ctx, fmt.Sprintf("joblogs:%s", taskID), logEntry) - ``` - -4. **生产级要求**: - - 请求体大小限制(默认 5MB) - - Content-Type 校验(支持 protobuf 和 JSON 两种格式) - - 请求超时控制 - - Prometheus metrics(接收速率、错误率、延迟) - - 优雅关闭(`Shutdown(ctx)`) - - 健康检查端点(`GET /health`) - -**依赖提升**(go.mod indirect → direct): - -- `go.opentelemetry.io/proto/otlp v1.5.0` -- `github.com/gorilla/websocket v1.5.4` -- `google.golang.org/protobuf`(已有) - -### Phase 2:修改 Alloy 配置,新增 OTLP 输出 - -**修改 `manifests/dev/exp-dev-setup.yaml`** - -在现有 pipeline 中增加 OTLP dual-write: - -```river -// ============ 新增: OTLP 日志输出到后端 ============ - -// 桥接: Loki 格式 → OpenTelemetry 格式 -otelcol.receiver.loki "backend" { - output { - logs = [otelcol.exporter.otlphttp.backend.input] - } -} - -// OTLP HTTP 导出到后端接收器 -otelcol.exporter.otlphttp "backend" { - client { - endpoint = "http://rcabench-service.exp.svc.cluster.local:4319" - // 本地开发时用: endpoint = "http://host.k3d.internal:4319" - - // 生产级配置 - retry_on_failure { - enabled = true - initial_interval = "1s" - max_interval = "30s" - max_elapsed_time = "5m" - } - - // 发送队列(缓冲 + 批量) - sending_queue { - enabled = true - num_consumers = 4 - queue_size = 1000 - } - } -} -``` - -**修改 pipeline forward_to**: - -```river -// 现有: -forward_to = [loki.write.default.receiver] - -// 改为 dual-write: -forward_to = [loki.write.default.receiver, otelcol.receiver.loki.backend.receiver] -``` - -**修改 DaemonSet args**: - -```yaml -# 现有: -args: - - --stability.level=generally-available - -# 改为 (otelcol.* 组件需要 public-preview): -args: - - --stability.level=public-preview -``` - -**注意事项**: - -- `otelcol.receiver.loki` 将 Loki labels 自动映射为 OTLP Resource Attributes -- `task_id`、`trace_id`、`job_id` 在 Alloy relabel 阶段已设置为 Structured Metadata,会作为 OTLP Attributes 传递 -- 本地开发环境后端不在 K8s 内,需要用 `host.k3d.internal` 或实际 IP - -### Phase 3:后端新增 Loki 查询客户端 - -**新建 `src/client/loki.go`** - -封装 Loki HTTP API,用于 WebSocket 连接时获取历史日志。 - -```go -type LokiClient struct { - baseURL string - httpClient *http.Client -} - -// QueryJobLogs 查询指定 task_id 的 Job 历史日志 -// LogQL: {app="rcabench"} | task_id=`{taskID}` -func (c *LokiClient) QueryJobLogs(ctx context.Context, taskID string, opts QueryOpts) ([]LogEntry, error) - -// QueryOpts 查询参数 -type QueryOpts struct { - Start time.Time // 默认: task 创建时间 - End time.Time // 默认: now - Limit int // 默认: 5000 - Direction string // "forward" (时间正序) -} -``` - -**Loki API 调用**: - -- `GET /loki/api/v1/query_range` -- LogQL: `{app="rcabench"} | task_id="{task_id}"`(Structured Metadata 过滤) -- 分页: `limit` + `start`/`end` 时间范围 - -**配置** (`config.dev.toml` 新增): - -```toml -[loki] -url = "http://10.10.10.161:3100" -timeout = "10s" -max_entries = 5000 -``` - -### Phase 4:定义日志 DTO 和 WebSocket 消息格式 - -**新建 `src/dto/log.go`** - -```go -// LogEntry 统一日志条目(OTLP 接收和 Loki 查询共用) -type LogEntry struct { - Timestamp time.Time `json:"timestamp"` // 日志时间戳 - Line string `json:"line"` // 日志内容 - TaskID string `json:"task_id"` // 关联的 task ID - JobID string `json:"job_id,omitempty"` // K8s Job 名称 - TraceID string `json:"trace_id,omitempty"` // 追踪 ID - Level string `json:"level,omitempty"` // 日志级别 (info/warn/error) -} - -// WSLogMessage WebSocket 推送的消息格式 -type WSLogMessage struct { - Type string `json:"type"` // "history" | "realtime" | "end" | "error" - Logs []LogEntry `json:"logs,omitempty"` // 日志条目 - Message string `json:"message,omitempty"` // 错误信息或结束原因 - Total int `json:"total,omitempty"` // 历史日志总条数 -} -``` - -### Phase 5:实现 WebSocket Handler - -**新建 `src/handlers/v2/task_logs.go`** - -```go -// GetTaskLogsWS WebSocket 端点 - 实时 Job 日志流 -// @Router /api/v2/tasks/{task_id}/logs/ws [get] -// -// 连接流程: -// 1. JWT 认证 (从 query param ?token=xxx 获取) -// 2. HTTP → WebSocket 升级 -// 3. 查询 Loki 历史日志 → type:"history" -// 4. Redis Pub/Sub 订阅 → type:"realtime" -// 5. 监听 task 完成 → type:"end" → 关闭 -func GetTaskLogsWS(c *gin.Context) -``` - -**生产级要求**: - -1. **认证**: - - WebSocket 不支持自定义 HTTP header - - 从 URL query 参数 `?token=xxx` 获取 JWT - - 验证 token 有效性后再升级连接 - -2. **连接管理**: - - 设置读写超时(WriteWait: 10s, PongWait: 60s, PingPeriod: 54s) - - Ping/Pong 心跳保活 - - 最大消息大小限制 - - 客户端断连时清理 Redis 订阅 - -3. **历史 + 实时日志无缝衔接**: - - 先订阅 Redis Pub/Sub(确保不丢失订阅期间的日志) - - 再查询 Loki 历史日志,发送给客户端 - - 然后开始转发 Redis 实时日志 - - 用时间戳去重(Loki 和 Redis 可能有短暂重叠) - -4. **优雅终止**: - - 监听 task 状态变化(轮询 DB 或订阅 Redis Stream 的 task 完成事件) - - Task 完成后等待 5s(flush 最后的日志)再发送 `type:"end"` - - 支持客户端主动关闭 - -5. **并发安全**: - - WebSocket 写操作需要互斥锁(`sync.Mutex`) - - Redis 订阅和 Loki 查询在独立 goroutine 中 - -### Phase 6:路由注册 - -**修改 `src/router/v2.go`** - -```go -// 在 tasks 路由组中添加: -tasks.GET("/:task_id/logs/ws", v2.GetTaskLogsWS) -``` - -- WebSocket 端点**不使用**标准 JWT middleware(因为 token 在 query param) -- 在 handler 内部手动验证 token - -### Phase 7:应用启动集成 - -**修改 `src/main.go`** - -在 `consumer` 和 `both` 模式中启动 OTLP 接收器: - -```go -// consumer/both 模式 -go logreceiver.Start(ctx, config.GetInt("otlp_receiver.port")) -``` - -### Phase 8:配置更新 - -**修改 `src/config.dev.toml`** - -```toml -[loki] -url = "http://10.10.10.161:3100" -timeout = "10s" -max_entries = 5000 - -[otlp_receiver] -port = 4319 -max_request_size = "5MB" - -[logging.job] -dir = "jobs" -log_retention_days = 30 -pubsub_channel_prefix = "joblogs" -``` - -## Redis Channel 设计 - -``` -joblogs:{task_id} # Pub/Sub channel,实时 Job 日志 - # 每条消息: JSON 序列化的 LogEntry - # 生命周期: task 运行期间活跃 - -trace:{trace_id}:log # Redis Stream(已有),task 状态事件 - # 用于监听 task 完成信号 -``` - -与现有 `StreamLogKey = "trace:%s:log"` Redis Stream 独立,不影响现有 SSE 事件推送。 - -## 验证清单 - -### 开发环境验证 - -1. **OTLP 接收器启动**: - - ```bash - # 构建并启动 - cd src && go build -o /tmp/rcabench ./main.go - ENV_MODE=dev /tmp/rcabench both --port 8082 - # 检查日志: "OTLP log receiver started on :4319" - ``` - -2. **OTLP 接收器功能测试**: - - ```bash - # 发送测试 OTLP 日志(JSON 格式) - curl -X POST http://localhost:4319/v1/logs \ - -H "Content-Type: application/json" \ - -d '{"resourceLogs":[{"resource":{"attributes":[{"key":"task_id","value":{"stringValue":"test-123"}}]},"scopeLogs":[{"logRecords":[{"timeUnixNano":"1708100000000000000","body":{"stringValue":"test log line"}}]}]}]}' - # 响应: 200 OK - ``` - -3. **Redis Pub/Sub 验证**: - - ```bash - # 终端 A: 订阅 - redis-cli subscribe joblogs:test-123 - # 终端 B: 发送上面的 OTLP 测试请求 - # 终端 A 应收到 LogEntry JSON - ``` - -4. **WebSocket 端到端测试**: - - ```bash - # 获取 JWT token - TOKEN=$(curl -s -X POST http://localhost:8082/api/v2/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username":"admin","password":"admin"}' | jq -r '.data.access_token') - - # 连接 WebSocket - websocat "ws://localhost:8082/api/v2/tasks/{task_id}/logs/ws?token=$TOKEN" - ``` - -### K8s 集群验证 - -5. **Alloy 配置更新**: - - ```bash - kubectl apply -f manifests/dev/exp-dev-setup.yaml - # 验证 Alloy pod 重启成功 - kubectl get pods -n exp -l app=alloy - ``` - -6. **端到端 Job 日志流**: - - ```bash - # 创建一个测试 fault injection → 触发 Job - # WebSocket 客户端应实时收到 Job 日志 - ``` - -7. **Loki 历史查询验证**: - ```bash - curl "http://10.10.10.161:3100/loki/api/v1/query_range" \ - --data-urlencode 'query={app="rcabench"} | task_id="xxx"' \ - --data-urlencode 'start=2026-02-17T00:00:00Z' \ - --data-urlencode 'end=2026-02-17T23:59:59Z' \ - --data-urlencode 'limit=100' - ``` - -### 构建和测试 - -8. **Go 构建**:`cd src && go build -o /tmp/rcabench ./main.go` -9. **单元测试**:`cd src && go test ./utils/... -v` -10. **OTLP 接收器单元测试**:`cd src && go test ./service/logreceiver/... -v` - -## 设计决策 - -| 决策项 | 选择 | 原因 | -| --------- | --------------------- | --------------------------------------------------------------- | -| 日志采集 | Alloy OTLP dual-write | 已有 Alloy pipeline,标准化 OTLP 协议,生产级可靠性 | -| 传输协议 | WebSocket | 双向通信,未来可扩展暂停/过滤控制 | -| 实时中转 | Redis Pub/Sub | 多客户端广播,轻量级,与现有 Redis 基础设施复用 | -| 历史日志 | Loki 查询 | 已有完整 Alloy → Loki 管道,LogQL 支持 Structured Metadata 过滤 | -| 日志维度 | 按 task_id | 匹配前端 LogsTab 在任务详情页的展示场景 | -| OTLP 格式 | HTTP (非 gRPC) | 更简单调试,curl 测试友好,防火墙友好 | -| 日志范围 | 仅 K8s Job | 后端自身日志后续迭代添加 | - -## 风险和缓解 - -| 风险 | 影响 | 缓解措施 | -| ------------------------------ | -------------------------------------------- | -------------------------------------------------------------------------------- | -| Alloy `--stability.level` 升级 | `public-preview` 组件可能有 breaking changes | 锁定 Alloy 镜像版本 (v1.13.1),升级前测试 | -| Redis Pub/Sub 无持久化 | 连接前的实时日志丢失 | 先订阅再查 Loki 历史,时间戳去重覆盖间隙 (Loki ~1-5s 延迟) | -| OTLP 接收器宕机 | 实时日志丢失 | Alloy `retry_on_failure` 重试 + `sending_queue` 缓冲;历史日志仍走 Loki 不受影响 | -| WebSocket 连接泄漏 | 资源耗尽 | Ping/Pong 心跳 + 读写超时 + task 完成自动关闭 | -| Loki 查询慢 | WebSocket 连接等待时间长 | 分页查询 + 超时控制 + 先发送部分历史再分批补充 | -| OTLP protobuf 解析复杂 | 开发周期长 | 同时支持 JSON 格式,优先用 JSON 开发调试 | - -## 实现优先级 - -``` -Phase 1 (P0): OTLP 接收器 + JSON 格式支持 → 可独立验证 -Phase 2 (P0): Alloy 配置 dual-write → 打通采集链路 -Phase 3 (P1): Loki 查询客户端 → 历史日志 -Phase 4 (P1): DTO + WebSocket Handler → 前端可用 -Phase 5 (P1): 路由 + 启动集成 + 配置 → 完整功能 -Phase 6 (P2): protobuf 格式支持 → 性能优化 -Phase 7 (P2): Prometheus metrics + 监控仪表盘 → 可观测性 -Phase 8 (P3): 后端自身日志采集 → 扩展范围 -``` - -## 文件清单(预期产出) - -``` -src/service/logreceiver/ -├── receiver.go # OTLP HTTP 接收器(核心) -├── parser.go # OTLP LogRecord 解析 + 元数据提取 -├── receiver_test.go # 单元测试 -└── metrics.go # Prometheus 指标 - -src/client/ -└── loki.go # Loki HTTP 查询客户端 - -src/dto/ -└── log.go # LogEntry, WSLogMessage DTO - -src/handlers/v2/ -└── task_logs.go # WebSocket handler - -src/router/v2.go # 路由注册(修改) -src/main.go # 启动集成(修改) -src/config.dev.toml # 配置新增(修改) - -manifests/dev/ -└── exp-dev-setup.yaml # Alloy 配置 dual-write(修改) -``` diff --git a/docs/report-index.md b/docs/report-index.md new file mode 100644 index 00000000..a0d8186a --- /dev/null +++ b/docs/report-index.md @@ -0,0 +1,170 @@ +# Report Index + +> 更新时间:2026-04-19 +> 目的:把当前可运行架构、保留文档、SDK/鉴权口径、调试方式和非阻塞尾项收口到一个总索引里。 + +## 1. 当前状态 + +- Fx + module + infra 主线已完成 +- `producer / consumer / both` 三种模式已跑通 +- 六服务入口已落地:`api-gateway / iam-service / resource-service / orchestrator-service / runtime-worker-service / system-service` +- 旧兼容层已退出主线运行态 +- SDK 路由已统一收口到 `src/router/sdk.go` +- runtime 上传接口已并入 `src/router/sdk.go`,并只保留 `RequireServiceTokenAuth()` +- `portal / admin / sdk / runtime` 四类 audience 已完成一轮最终对齐补扫 + +## 2. 保留文档 + +- `docs/todo.md` + - 最终执行状态、SDK 路由核对、验收命令、主线完成/非阻塞剩余项 +- `docs/package-rename-todo.md` + - `src/interface/grpc/*` 与包名统一记录 +- `docs/api-key-auth-execution-todo.md` + - API key / Key ID / Key Secret 执行记录 +- `docs/python-runtime-wrapper-design.md` + - Python runtime wrapper 职责边界设计 +- `docs/python-runtime-wrapper-todo.md` + - runtime wrapper 执行记录 +- `docs/swagger-audience-unmarked-report.md` + - Swagger audience 当前对齐状态、例外项与剩余 1 条空标记路由 + +## 3. 服务边界 + +### 3.1 六服务职责 + +- `api-gateway` + - 对外唯一 HTTP/OpenAPI 入口;做 audience、鉴权、聚合与边缘协议适配 +- `iam-service` + - `auth / user / rbac / team / api key` +- `resource-service` + - `project / label / container / dataset / evaluation / chaos-system` +- `orchestrator-service` + - `execution / injection / task / trace / notification / group` +- `runtime-worker-service` + - Redis 异步执行链、K8s / Helm / BuildKit / Chaos 运行态 +- `system-service` + - `config / audit / health / monitor / metrics` + +### 3.2 基本约束 + +- 允许:`cmd -> app -> interface/module/infra/internalclient` +- 允许:`interface -> module/internalclient` +- 允许:`module -> infra/model/本模块 repository` +- 禁止:`gateway -> repository` +- 禁止:`interface -> repository` 直接拼业务 +- 禁止:`module A -> module B repository` +- 禁止:非 owner 服务新增直接写库逻辑 + +## 4. SDK / 鉴权口径 + +### 4.1 SDK 路由口径 + +- 所有 `@x-api-type {"sdk":"true"}` 的运行态入口统一由 `src/router/sdk.go` 承接 +- `runtime` 视为 SDK 路由中的一个专门子集,但鉴权语义单独保留 +- runtime 上传接口: + - `POST /api/v2/executions/{execution_id}/detector_results` + - `POST /api/v2/executions/{execution_id}/granularity_results` +- 上述 runtime 路由当前仅要求: + - `RequireServiceTokenAuth()` +- 不再叠加 `JWTAuth()` + +### 4.2 API key + +- 入口:`POST /api/v2/auth/api-key/token` +- 请求头: + - `X-Key-Id` + - `X-Timestamp` + - `X-Nonce` + - `X-Signature` +- canonical string: + +```text +METHOD +PATH +TIMESTAMP +NONCE +SHA256(BODY) +``` + +- 业务 API 统一使用 `Authorization: Bearer ` +- `aegisctl` 与 Python SDK 已统一到这套签名换 token 流程 + +### 4.3 Python SDK / Runtime Client + +- `RCABenchClient` + - 公共/业务 API client;通过 `Key ID / Key Secret` 从环境变量换 token +- `RCABenchRuntimeClient` + - runtime service-token-only client + - 只保持 thin client,不承载 wrapper 调度语义 + +### 4.4 SDK generation / Apifox + +- `portal` + - 当前只生成 TypeScript SDK +- `admin` + - 当前只生成 TypeScript SDK +- `sdk` + - 当前只生成 Python SDK +- `runtime` + - 当前作为 `sdk` 语义下的运行态子集保留在文档产物里,不单独作为 Apifox 上传目标 +- `swagger init` 现在支持可选上传到 Apifox,但只支持三类目标: + - `sdk` + - `portal` + - `admin` +- SDK 生成主入口改为显式 option 风格: + - `sdk typescript --target portal|admin --env local|release --version ` + - `sdk python --target sdk --env local|release --version ` +- TypeScript OpenAPI Generator 模板目录已压平为: + - `.openapi-generator/typescript/config.json` + - `.openapi-generator/typescript/templates/*` +- `scripts/command` 里的 Apifox / SDK / 测试安装 URL 统一改为优先从 `scripts/command/settings.toml` 读取 +- `scripts/start.sh` 里的外部安装地址与测试代理也已改成顶部 env override 变量 +- 不再需要单独的 `--upload-apifox` +- 只有显式传入 `--apifox-target ...` 时才会上传 +- 若要一次上传全部,使用: + - `--apifox-target all` + +## 5. 启动与调试 + +### 5.1 什么时候用哪种模式 + +- `producer` + - 调 HTTP、router、handler、Swagger、Portal/Admin API +- `consumer` + - 调 worker / controller / receiver / runtime 执行链 +- `both` + - 调本地 submit -> queue -> worker -> query 闭环 +- 六服务模式 + - 调 internal gRPC、owner 边界、remote-first 路径 + +说明:`both` 不是“同时启动六服务”,而是单体 HTTP + worker 组合模式。 + +### 5.2 六服务本地入口 + +| Service | Command | Default Port | +| --- | --- | --- | +| `api-gateway` | `go run ./src/cmd/api-gateway -conf ./src/config.dev.toml -port 8082` | `8082` | +| `iam-service` | `go run ./src/cmd/iam-service -conf ./src/config.dev.toml` | `9091` | +| `orchestrator-service` | `go run ./src/cmd/orchestrator-service -conf ./src/config.dev.toml` | `9092` | +| `resource-service` | `go run ./src/cmd/resource-service -conf ./src/config.dev.toml` | `9093` | +| `runtime-worker-service` | `go run ./src/cmd/runtime-worker-service -conf ./src/config.dev.toml` | `9094` | +| `system-service` | `go run ./src/cmd/system-service -conf ./src/config.dev.toml` | `9095` | + +## 6. 最终结论 + +### 6.1 主线完成 + +- 单体 Fx 化完成 +- 六服务边界完成 +- 旧兼容层主线清理完成 +- SDK / audience / API key 主线完成 +- SDK 路由统一收口完成 +- 基础验收与真实 K8s 集群入口完成 + +### 6.2 非阻塞剩余项 + +- 人工确认 Fx 日志输出是否要再裁剪 +- 继续压 dedicated service 的少量 local fallback +- 继续深清跨 owner DB 直查 +- 补发布参数、values、HPA、Ingress 等环境治理 +- 补更多真实依赖集成回归 diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 00000000..b41e1595 --- /dev/null +++ b/docs/todo.md @@ -0,0 +1,150 @@ +# Backend Refactor TODO + +> 更新时间:2026-04-19 +> 口径:只保留当前主线状态、验收命令和非阻塞尾项,不再保留逐轮施工日志。 + +## 1. 当前判断 + +- [x] Fx + module + infra 主线完成 +- [x] `producer / consumer / both` 三种模式可启动 +- [x] 六服务入口已落地并可运行 +- [x] 旧兼容层已退出主线运行态 +- [x] SDK audience / API key 鉴权主线完成 +- [x] SDK 路由已统一收口到 `src/router/sdk.go` +- [x] runtime 上传接口并入 `src/router/sdk.go`,并只保留 `RequireServiceTokenAuth()` + +结论:当前可按“主线完成”判断,剩余工作主要是非阻塞治理和真实环境补强。 + +## 2. 主线完成清单 + +### 2.1 启动与基础设施 + +- [x] `main.go` 只负责 mode 选择与 Fx 启动 +- [x] DB / Redis / Etcd / Tracing / Loki / K8s / Harbor / Helm / BuildKit 已收口到 `src/infra/*` +- [x] HTTP server / worker / controller / receiver 均已纳入 lifecycle +- [x] `src/app` 已按边界拆成基础 options 与服务 options +- [x] `src/interface/grpc/*` 已迁到 `src/interface/grpc/{iam,resource,orchestrator,runtime,system}` + +### 2.2 模块边界 + +- [x] 业务主模块已完成 `module -> service -> repository` 收口 +- [x] handler 不再直接依赖全局 DB / 旧 producer / 旧 repository wrapper +- [x] repository 主体回收到各模块 `repository.go` +- [x] 外部系统访问已通过 gateway/store 收口 +- [x] middleware 已从旧 producer 依赖切到模块服务 / 独立接口 + +### 2.3 旧兼容层清理 + +- [x] `src/service/producer` 已退出生产代码 +- [x] `src/handlers/system` 已退出运行态主线 +- [x] `database.DB` 已退回 `src/infra/db` 集中管理 +- [x] `GetGateway()` / `redisinfra.GetGateway()` / `CurrentK8s*` 这类全局 fallback 已退出主线 +- [x] `src/interface/http/router.go`、`src/app/compat_options.go`、`src/router/runtime.go` 这类单层组织文件已继续压缩/删除 + +### 2.4 路由 / 文档 / SDK / 鉴权 + +- [x] Public / SDK / Portal / Admin 路由已拆分 +- [x] 所有 `@x-api-type {"sdk":"true"}` 运行态入口已统一收口到 `src/router/sdk.go` +- [x] runtime 结果上传接口已作为 `sdk + runtime` 路由并入 `src/router/sdk.go` +- [x] runtime 结果上传接口鉴权已改为仅 `RequireServiceTokenAuth()` +- [x] `portal / admin / sdk / runtime` 四类 audience 路由与 Swagger 标记已完成对齐补扫 +- [x] Swagger audience 以 `x-api-type` 为准 +- [x] Python SDK 只消费 `sdk.json` +- [x] TypeScript SDK 分别消费 `portal.json` / `admin.json` +- [x] TypeScript OpenAPI Generator 模板目录已压平成 `.openapi-generator/typescript/*` +- [x] `scripts/command` 中 Apifox / SDK / 测试安装 URL 已优先从 `scripts/command/settings.toml` 读取 +- [x] API key 主线已统一到 `Key ID / Key Secret` + 签名换 token +- [x] `aegisctl` 与 Python SDK 已切到同一套签名口径 + +### 2.5 微服务主线 + +- [x] `api-gateway` 对外 HTTP 入口已形成 +- [x] `iam-service` 承接 auth / user / rbac / team / api key +- [x] `resource-service` 承接 project / label / container / dataset / evaluation +- [x] `orchestrator-service` 承接 execution / injection / task / trace / notification / group 控制面 +- [x] `runtime-worker-service` 保留 Redis 异步执行链,承接运行态消费、K8s/Helm/BuildKit/Chaos +- [x] `system-service` 承接 config / audit / health / monitor / metrics + +## 3. SDK 路由核对结论 + +已核对 `src/module/*/handler.go` 中所有 `@x-api-type {"sdk":"true"}` 注释,当前运行态路由均由 `src/router/sdk.go` 承接: + +- [x] `POST /api/v2/auth/api-key/token` +- [x] `GET /api/v2/sdk/evaluations` +- [x] `GET /api/v2/sdk/evaluations/experiments` +- [x] `GET /api/v2/sdk/evaluations/{id}` +- [x] `GET /api/v2/sdk/datasets` +- [x] `GET /api/v2/datasets/{dataset_id}/versions/{version_id}/download` +- [x] `PATCH /api/v2/datasets/{dataset_id}/version/{version_id}/injections` +- [x] `GET /api/v2/projects/{project_id}/injections` +- [x] `GET /api/v2/projects/{project_id}/injections/analysis/no-issues` +- [x] `GET /api/v2/projects/{project_id}/injections/analysis/with-issues` +- [x] `POST /api/v2/projects/{project_id}/injections/inject` +- [x] `POST /api/v2/projects/{project_id}/injections/build` +- [x] `GET /api/v2/projects/{project_id}/executions` +- [x] `POST /api/v2/projects/{project_id}/executions/execute` +- [x] `POST /api/v2/evaluations/datapacks` +- [x] `POST /api/v2/evaluations/datasets` +- [x] `GET /api/v2/evaluations` +- [x] `GET /api/v2/evaluations/{id}` +- [x] `GET /api/v2/executions/{id}` +- [x] `PATCH /api/v2/executions/{id}/labels` +- [x] `GET /api/v2/injections/metadata` +- [x] `GET /api/v2/injections/{id}` +- [x] `POST /api/v2/injections/{id}/clone` +- [x] `GET /api/v2/injections/{id}/download` +- [x] `GET /api/v2/injections/{id}/files` +- [x] `GET /api/v2/injections/{id}/files/download` +- [x] `GET /api/v2/injections/{id}/files/query` +- [x] `PATCH /api/v2/injections/{id}/labels` +- [x] `GET /api/v2/metrics/algorithms` +- [x] `GET /api/v2/metrics/executions` +- [x] `GET /api/v2/metrics/injections` +- [x] `POST /api/v2/executions/{execution_id}/detector_results` +- [x] `POST /api/v2/executions/{execution_id}/granularity_results` + +补充说明: + +- `src/module/docs/swagger_models.go` 里的 `sdk` 标记只用于 Swagger model 聚合,不对应独立运行态路由。 +- `GET /api/v2/executions/labels` 当前不是 `sdk:true`,所以仍保留在 Portal 侧,不在本次 SDK 收口范围内。 + +## 4. 验收命令 + +- [x] 默认回归 + - `cd src && go test ./...` +- [x] Producer Fx 图与 HTTP 冒烟 + - `cd src && go test ./app -run 'TestProducerOptionsValidate|TestProducerOptionsStartStopSmoke|TestProducerOptionsHTTPIntegrationSmoke'` +- [x] Consumer / Both 生命周期冒烟 + - `cd src && go test ./app -run 'TestConsumerOptions|TestBothOptions'` +- [x] 路由 / 文档主路径 + - `cd src && go test ./router ./docs ./interface/http` +- [x] 真实 K8s 集群验收 + - `cd src && RUN_K8S_INTEGRATION=1 go test ./infra/k8s -run TestK8sGatewayJobLifecycleIntegration` + +## 5. 主线完成 / 非阻塞剩余项 + +### 5.1 主线完成 + +- [x] 单体 Fx 启动主线完成 +- [x] 六服务边界主线完成 +- [x] 旧兼容层主线完成清扫 +- [x] SDK / audience / API key 主线完成 +- [x] SDK 路由统一收口完成 +- [x] 真实 K8s 集群验收入口完成 + +### 5.2 非阻塞剩余项 + +- [ ] 人工确认 Fx 启动日志是否需要进一步裁剪 +- [ ] 少量 dedicated service 的 local fallback 还可以继续压窄 +- [ ] 少量跨 owner 直查仍可继续按 owner 深清 +- [ ] 发布层可继续补 values/HPA/Ingress/镜像策略等环境治理 +- [ ] 更贴近真实外部依赖的集成回归仍可继续补强 + +## 6. 参考文档 + +- `docs/report-index.md` +- `docs/package-rename-todo.md` +- `docs/api-key-auth-execution-todo.md` +- `docs/python-runtime-wrapper-design.md` +- `docs/python-runtime-wrapper-todo.md` +- `docs/swagger-audience-unmarked-report.md` diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index 6c6d3be4..a04b9965 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -6,7 +6,7 @@ data: config.prod.toml: | name = "{{ .Values.configmap.name }}" version = "{{ .Values.configmap.version }}" - port = {{ .Values.configmap.port }} + port = {{ .Values.microservices.apiGateway.httpPort }} workspace = "{{ .Values.configmap.workspace }}" [system] @@ -36,7 +36,7 @@ data: [k8s] namespace = "{{ .Values.configmap.k8s.namespace }}" [k8s.service] - internal_url = "http://{{ .Release.Name }}-exp:8080" + internal_url = "http://{{ .Release.Name }}-api-gateway:{{ .Values.microservices.apiGateway.httpPort }}" [k8s.init_container] {{- range $key, $value := .Values.configmap.k8s.init_container }} {{ $key }} = {{ $value | quote }} @@ -66,7 +66,7 @@ data: claim_name = {{ $value.claim_name | quote }} {{- end }} {{- end }} - + [jfs] container_path = "{{ .Values.configmap.jfs.container_path }}" dataset_path = "{{ .Values.configmap.jfs.dataset_path }}" @@ -77,16 +77,46 @@ data: address = "{{ .Release.Name }}-buildkit:1234" {{- end }} + [clients.iam] + target = "{{ .Release.Name }}-iam-service:{{ .Values.microservices.iamService.grpcPort }}" + + [clients.orchestrator] + target = "{{ .Release.Name }}-orchestrator-service:{{ .Values.microservices.orchestratorService.grpcPort }}" + + [clients.resource] + target = "{{ .Release.Name }}-resource-service:{{ .Values.microservices.resourceService.grpcPort }}" + + [clients.runtime] + target = "{{ .Release.Name }}-runtime-worker-service:{{ .Values.microservices.runtimeWorkerService.grpcPort }}" + + [clients.system] + target = "{{ .Release.Name }}-system-service:{{ .Values.microservices.systemService.grpcPort }}" + + [iam.grpc] + addr = ":{{ .Values.microservices.iamService.grpcPort }}" + + [orchestrator.grpc] + addr = ":{{ .Values.microservices.orchestratorService.grpcPort }}" + + [resource.grpc] + addr = ":{{ .Values.microservices.resourceService.grpcPort }}" + + [runtime_worker.grpc] + addr = ":{{ .Values.microservices.runtimeWorkerService.grpcPort }}" + + [system.grpc] + addr = ":{{ .Values.microservices.systemService.grpcPort }}" + {{- if .Values.loki.enabled }} [loki] address = "http://{{ .Release.Name }}-loki:3100" timeout = "{{ .Values.configmap.loki.timeout }}" max_entries = {{ .Values.configmap.loki.max_entries }} + {{- end }} - [otlp.receiver] + [otlp_receiver] port = {{ .Values.configmap.otlp.port }} max_request_size = {{ .Values.configmap.otlp.max_request_size }} - {{- end }} --- apiVersion: v1 kind: ConfigMap @@ -134,4 +164,4 @@ metadata: name: {{ .Release.Name }}-system-helm-configs data: system_helm_configs.json: | -{{ include "helm.systemHelmConfigs" . | indent 4 }} \ No newline at end of file +{{ include "helm.systemHelmConfigs" . | indent 4 }} diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index a281fd01..83f636a0 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -1,54 +1,22 @@ -# exp Application apiVersion: apps/v1 kind: Deployment metadata: - name: {{ .Release.Name }}-producer + name: {{ .Release.Name }}-api-gateway spec: - replicas: 1 + replicas: {{ .Values.microservices.apiGateway.replicaCount }} selector: matchLabels: - app: {{ .Release.Name }}-producer + app: {{ .Release.Name }}-api-gateway template: metadata: annotations: rollout-timestamp: {{ now | quote }} labels: - app: {{ .Release.Name }}-producer + app: {{ .Release.Name }}-api-gateway spec: serviceAccountName: {{ .Release.Name }}-sa initContainers: - - name: wait-for-dependencies - image: {{ include "helm.image" (dict "imageConfig" .Values.images.busybox "global" .Values.global) }} - imagePullPolicy: "{{ .Values.images.busybox.pullPolicy }}" - command: - - sh - - -c - - | - # Parallel dependency check with timeout - TIMEOUT=120 - START=$(date +%s) - - check_jaeger() { while ! nc -z {{ .Release.Name }}-jaeger 4318; do sleep 1; done; } - check_redis() { while ! nc -z {{ .Release.Name }}-redis 6379; do sleep 1; done; } - check_mysql() { while ! nc -z {{ .Release.Name }}-mysql 3306; do sleep 1; done; } - check_etcd() { while ! nc -z {{ .Release.Name }}-etcd-headless 2379; do sleep 1; done; } - - # Start background checks - check_jaeger & PID1=$! - check_redis & PID2=$! - check_mysql & PID3=$! - check_etcd & PID4=$! - - # Wait with timeout - while kill -0 $PID1 2>/dev/null || kill -0 $PID2 2>/dev/null || kill -0 $PID3 2>/dev/null || kill -0 $PID4 2>/dev/null; do - [ $(($(date +%s) - START)) -ge $TIMEOUT ] && echo "Timeout waiting for dependencies" && exit 1 - sleep 5 - done - - # Verify all succeeded - wait $PID1 && wait $PID2 && wait $PID3 && wait $PID4 || exit 1 - echo "Dependencies ready in $(($(date +%s) - START))s" - - name: init-etcd-data + - name: init-etcd-producer-config image: pair-diag-cn-guangzhou.cr.volces.com/pair/etcdctl:latest imagePullPolicy: IfNotPresent command: @@ -59,58 +27,53 @@ spec: CONFIG_PREFIX="/rcabench/config/producer" CONFIG_YAML_PATH="/initial-config/etcd.yaml" FORCE_INIT="{{ .Values.initialConfig.force }}" - - echo "=== Etcd Initialization Started: $(date) ===" - - # Check if already initialized + INIT_VALUE=$(etcdctl --endpoints=http://{{ .Release.Name }}-etcd-headless:2379 get "$INIT_KEY" --print-value-only 2>/dev/null || echo "") if [ "$INIT_VALUE" = "true" ] && [ "$FORCE_INIT" != "true" ]; then - echo "Already initialized (found $INIT_KEY=true). Set initialConfig.force=true to reinitialize." + echo "producer config already initialized" exit 0 fi - - if [ "$FORCE_INIT" = "true" ]; then - echo "Force initialization enabled, proceeding..." - else - echo "No initialization marker found, proceeding with initialization..." - fi - - # Load initial config from YAML - echo "Loading config from $CONFIG_YAML_PATH..." + if [ -f "$CONFIG_YAML_PATH" ]; then while IFS=': ' read -r key_name value || [ -n "$key_name" ]; do - # Skip empty lines and comments [ -z "$key_name" ] && continue echo "$key_name" | grep -q '^#' && continue - - # Remove leading/trailing whitespace and quotes key_name=$(echo "$key_name" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') value=$(echo "$value" | sed "s/^[[:space:]]*//; s/[[:space:]]*$//; s/^[\"']//; s/[\"']$//") - - if [ -n "$key_name" ]; then - key="$CONFIG_PREFIX/$key_name" - echo "Setting: $key = $value" - etcdctl --endpoints=http://{{ .Release.Name }}-etcd-headless:2379 put "$key" "$value" || true - fi + [ -n "$key_name" ] || continue + etcdctl --endpoints=http://{{ .Release.Name }}-etcd-headless:2379 put "$CONFIG_PREFIX/$key_name" "$value" || true done < "$CONFIG_YAML_PATH" etcdctl --endpoints=http://{{ .Release.Name }}-etcd-headless:2379 put "$INIT_KEY" "true" || true - echo "Config loaded successfully" - else - echo "Warning: $CONFIG_YAML_PATH not found" fi - - echo "=== Etcd Initialization Done: $(date) ===" volumeMounts: - name: etcd-initial-config mountPath: /initial-config readOnly: true containers: - - name: exp + - name: api-gateway image: {{ include "helm.image" (dict "imageConfig" .Values.images.rcabench "global" .Values.global) }} imagePullPolicy: "{{ .Values.images.rcabench.pullPolicy }}" - command: ["/app/entrypoint.sh", "producer", "8080"] + args: + - api-gateway + - --conf + - /etc/rcabench/config.prod.toml + - --port + - {{ .Values.microservices.apiGateway.httpPort | quote }} ports: - - containerPort: 8080 + - containerPort: {{ .Values.microservices.apiGateway.httpPort }} + name: http + readinessProbe: + httpGet: + path: /system/health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /system/health + port: http + initialDelaySeconds: 10 + periodSeconds: 15 env: - name: GOPRIVATE value: "github.com/OperationsPAI/chaos-experiment" @@ -146,57 +109,166 @@ spec: persistentVolumeClaim: claimName: {{ .Release.Name }}-juicefs-dataset --- -# exp Application apiVersion: apps/v1 kind: Deployment metadata: - name: {{ .Release.Name }}-consumer + name: {{ .Release.Name }}-iam-service spec: - replicas: 1 + replicas: {{ .Values.microservices.iamService.replicaCount }} + selector: + matchLabels: + app: {{ .Release.Name }}-iam-service + template: + metadata: + annotations: + rollout-timestamp: {{ now | quote }} + labels: + app: {{ .Release.Name }}-iam-service + spec: + serviceAccountName: {{ .Release.Name }}-sa + containers: + - name: iam-service + image: {{ include "helm.image" (dict "imageConfig" .Values.images.rcabench "global" .Values.global) }} + imagePullPolicy: "{{ .Values.images.rcabench.pullPolicy }}" + args: + - iam-service + - --conf + - /etc/rcabench/config.prod.toml + ports: + - containerPort: {{ .Values.microservices.iamService.grpcPort }} + name: grpc + readinessProbe: + grpc: + port: {{ .Values.microservices.iamService.grpcPort }} + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + grpc: + port: {{ .Values.microservices.iamService.grpcPort }} + initialDelaySeconds: 10 + periodSeconds: 15 + volumeMounts: + - name: config + mountPath: /etc/rcabench/config.prod.toml + subPath: config.prod.toml + volumes: + - name: config + configMap: + name: {{ .Release.Name }}-rcabench-config +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-orchestrator-service +spec: + replicas: {{ .Values.microservices.orchestratorService.replicaCount }} + selector: + matchLabels: + app: {{ .Release.Name }}-orchestrator-service + template: + metadata: + annotations: + rollout-timestamp: {{ now | quote }} + labels: + app: {{ .Release.Name }}-orchestrator-service + spec: + serviceAccountName: {{ .Release.Name }}-sa + containers: + - name: orchestrator-service + image: {{ include "helm.image" (dict "imageConfig" .Values.images.rcabench "global" .Values.global) }} + imagePullPolicy: "{{ .Values.images.rcabench.pullPolicy }}" + args: + - orchestrator-service + - --conf + - /etc/rcabench/config.prod.toml + ports: + - containerPort: {{ .Values.microservices.orchestratorService.grpcPort }} + name: grpc + readinessProbe: + grpc: + port: {{ .Values.microservices.orchestratorService.grpcPort }} + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + grpc: + port: {{ .Values.microservices.orchestratorService.grpcPort }} + initialDelaySeconds: 10 + periodSeconds: 15 + volumeMounts: + - name: config + mountPath: /etc/rcabench/config.prod.toml + subPath: config.prod.toml + volumes: + - name: config + configMap: + name: {{ .Release.Name }}-rcabench-config +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-resource-service +spec: + replicas: {{ .Values.microservices.resourceService.replicaCount }} selector: matchLabels: - app: {{ .Release.Name }}-consumer + app: {{ .Release.Name }}-resource-service template: metadata: annotations: rollout-timestamp: {{ now | quote }} labels: - app: {{ .Release.Name }}-consumer + app: {{ .Release.Name }}-resource-service + spec: + serviceAccountName: {{ .Release.Name }}-sa + containers: + - name: resource-service + image: {{ include "helm.image" (dict "imageConfig" .Values.images.rcabench "global" .Values.global) }} + imagePullPolicy: "{{ .Values.images.rcabench.pullPolicy }}" + args: + - resource-service + - --conf + - /etc/rcabench/config.prod.toml + ports: + - containerPort: {{ .Values.microservices.resourceService.grpcPort }} + name: grpc + readinessProbe: + grpc: + port: {{ .Values.microservices.resourceService.grpcPort }} + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + grpc: + port: {{ .Values.microservices.resourceService.grpcPort }} + initialDelaySeconds: 10 + periodSeconds: 15 + volumeMounts: + - name: config + mountPath: /etc/rcabench/config.prod.toml + subPath: config.prod.toml + volumes: + - name: config + configMap: + name: {{ .Release.Name }}-rcabench-config +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-runtime-worker-service +spec: + replicas: {{ .Values.microservices.runtimeWorkerService.replicaCount }} + selector: + matchLabels: + app: {{ .Release.Name }}-runtime-worker-service + template: + metadata: + annotations: + rollout-timestamp: {{ now | quote }} + labels: + app: {{ .Release.Name }}-runtime-worker-service spec: serviceAccountName: {{ .Release.Name }}-sa initContainers: - - name: wait-for-dependencies - image: {{ include "helm.image" (dict "imageConfig" .Values.images.busybox "global" .Values.global) }} - imagePullPolicy: "{{ .Values.images.busybox.pullPolicy }}" - command: - - sh - - -c - - | - # Parallel dependency check with timeout - TIMEOUT=120 - START=$(date +%s) - - check_jaeger() { while ! nc -z {{ .Release.Name }}-jaeger 4318; do sleep 1; done; } - check_redis() { while ! nc -z {{ .Release.Name }}-redis 6379; do sleep 1; done; } - check_mysql() { while ! nc -z {{ .Release.Name }}-mysql 3306; do sleep 1; done; } - check_etcd() { while ! nc -z {{ .Release.Name }}-etcd-headless 2379; do sleep 1; done; } - - # Start background checks - check_jaeger & PID1=$! - check_redis & PID2=$! - check_mysql & PID3=$! - check_etcd & PID4=$! - - # Wait with timeout - while kill -0 $PID1 2>/dev/null || kill -0 $PID2 2>/dev/null || kill -0 $PID3 2>/dev/null || kill -0 $PID4 2>/dev/null; do - [ $(($(date +%s) - START)) -ge $TIMEOUT ] && echo "Timeout waiting for dependencies" && exit 1 - sleep 5 - done - - # Verify all succeeded - wait $PID1 && wait $PID2 && wait $PID3 && wait $PID4 || exit 1 - echo "Dependencies ready in $(($(date +%s) - START))s" - - name: init-etcd-data + - name: init-etcd-consumer-config image: pair-diag-cn-guangzhou.cr.volces.com/pair/etcdctl:latest imagePullPolicy: IfNotPresent command: @@ -207,56 +279,51 @@ spec: CONFIG_PREFIX="/rcabench/config/consumer" CONFIG_YAML_PATH="/initial-config/etcd.yaml" FORCE_INIT="{{ .Values.initialConfig.force }}" - - echo "=== Etcd Initialization Started: $(date) ===" - - # Check if already initialized + INIT_VALUE=$(etcdctl --endpoints=http://{{ .Release.Name }}-etcd-headless:2379 get "$INIT_KEY" --print-value-only 2>/dev/null || echo "") if [ "$INIT_VALUE" = "true" ] && [ "$FORCE_INIT" != "true" ]; then - echo "Already initialized (found $INIT_KEY=true). Set initialConfig.force=true to reinitialize." + echo "consumer config already initialized" exit 0 fi - - if [ "$FORCE_INIT" = "true" ]; then - echo "Force initialization enabled, proceeding..." - else - echo "No initialization marker found, proceeding with initialization..." - fi - - # Load initial config from YAML - echo "Loading config from $CONFIG_YAML_PATH..." + if [ -f "$CONFIG_YAML_PATH" ]; then while IFS=': ' read -r key_name value || [ -n "$key_name" ]; do - # Skip empty lines and comments [ -z "$key_name" ] && continue echo "$key_name" | grep -q '^#' && continue - - # Remove leading/trailing whitespace and quotes key_name=$(echo "$key_name" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') value=$(echo "$value" | sed "s/^[[:space:]]*//; s/[[:space:]]*$//; s/^[\"']//; s/[\"']$//") - - if [ -n "$key_name" ]; then - key="$CONFIG_PREFIX/$key_name" - echo "Setting: $key = $value" - etcdctl --endpoints=http://{{ .Release.Name }}-etcd-headless:2379 put "$key" "$value" || true - fi + [ -n "$key_name" ] || continue + etcdctl --endpoints=http://{{ .Release.Name }}-etcd-headless:2379 put "$CONFIG_PREFIX/$key_name" "$value" || true done < "$CONFIG_YAML_PATH" etcdctl --endpoints=http://{{ .Release.Name }}-etcd-headless:2379 put "$INIT_KEY" "true" || true - echo "Config loaded successfully" - else - echo "Warning: $CONFIG_YAML_PATH not found" fi - - echo "=== Etcd Initialization Done: $(date) ===" volumeMounts: - name: etcd-initial-config mountPath: /initial-config readOnly: true containers: - - name: exp + - name: runtime-worker-service image: {{ include "helm.image" (dict "imageConfig" .Values.images.rcabench "global" .Values.global) }} imagePullPolicy: "{{ .Values.images.rcabench.pullPolicy }}" - command: ["/app/entrypoint.sh", "consumer"] + args: + - runtime-worker-service + - --conf + - /etc/rcabench/config.prod.toml + ports: + - containerPort: {{ .Values.microservices.runtimeWorkerService.grpcPort }} + name: grpc + - containerPort: {{ .Values.configmap.otlp.port }} + name: otlp + readinessProbe: + grpc: + port: {{ .Values.microservices.runtimeWorkerService.grpcPort }} + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + grpc: + port: {{ .Values.microservices.runtimeWorkerService.grpcPort }} + initialDelaySeconds: 10 + periodSeconds: 15 env: - name: GOPRIVATE value: "github.com/OperationsPAI/chaos-experiment" @@ -315,6 +382,53 @@ spec: - name: experiment-storage persistentVolumeClaim: claimName: {{ .Release.Name }}-juicefs-experiment-storage +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-system-service +spec: + replicas: {{ .Values.microservices.systemService.replicaCount }} + selector: + matchLabels: + app: {{ .Release.Name }}-system-service + template: + metadata: + annotations: + rollout-timestamp: {{ now | quote }} + labels: + app: {{ .Release.Name }}-system-service + spec: + serviceAccountName: {{ .Release.Name }}-sa + containers: + - name: system-service + image: {{ include "helm.image" (dict "imageConfig" .Values.images.rcabench "global" .Values.global) }} + imagePullPolicy: "{{ .Values.images.rcabench.pullPolicy }}" + args: + - system-service + - --conf + - /etc/rcabench/config.prod.toml + ports: + - containerPort: {{ .Values.microservices.systemService.grpcPort }} + name: grpc + readinessProbe: + grpc: + port: {{ .Values.microservices.systemService.grpcPort }} + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + grpc: + port: {{ .Values.microservices.systemService.grpcPort }} + initialDelaySeconds: 10 + periodSeconds: 15 + volumeMounts: + - name: config + mountPath: /etc/rcabench/config.prod.toml + subPath: config.prod.toml + volumes: + - name: config + configMap: + name: {{ .Release.Name }}-rcabench-config {{- if .Values.buildkit.enabled }} --- apiVersion: v1 @@ -375,7 +489,7 @@ spec: mountPath: /etc/rcabench/config.prod.toml subPath: config.prod.toml - name: containers-data - mountPath: "{{ .Values.configmap.container.storage_path }}" + mountPath: "{{ .Values.configmap.jfs.container_path }}" - name: buildkit-socket mountPath: /run/buildkit - name: buildkit-config @@ -406,4 +520,4 @@ spec: - key: harbor.lab.pj.crt path: harbor.lab.pj.crt {{- end }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/helm/templates/service.yaml b/helm/templates/service.yaml index c393dc87..7444ede3 100644 --- a/helm/templates/service.yaml +++ b/helm/templates/service.yaml @@ -26,7 +26,7 @@ spec: targetPort: 14268 nodePort: 31468 - name: query - port: 16686 + port: 16686 targetPort: 16686 nodePort: 31686 {{- else }} @@ -52,7 +52,7 @@ spec: port: 14268 targetPort: 14268 - name: query - port: 16686 + port: 16686 targetPort: 16686 {{- end }} @@ -68,8 +68,8 @@ spec: selector: app: {{ .Release.Name }}-redis ports: - - port: 6379 - nodePort: 32279 + - port: 6379 + nodePort: 32279 {{- else }} apiVersion: v1 kind: Service @@ -80,7 +80,7 @@ spec: selector: app: {{ .Release.Name }}-redis ports: - - port: 6379 + - port: 6379 {{- end }} --- @@ -95,9 +95,9 @@ spec: selector: app: {{ .Release.Name }}-mysql ports: - - port: 3306 - targetPort: 3306 - nodePort: 32206 + - port: 3306 + targetPort: 3306 + nodePort: 32206 {{- else }} apiVersion: v1 kind: Service @@ -108,8 +108,8 @@ spec: selector: app: {{ .Release.Name }}-mysql ports: - - port: 3306 - targetPort: 3306 + - port: 3306 + targetPort: 3306 {{- end }} --- @@ -123,12 +123,12 @@ spec: selector: app: {{ .Release.Name }}-etcd ports: - - name: client - port: 2379 - targetPort: 2379 - - name: peer - port: 2380 - targetPort: 2380 + - name: client + port: 2379 + targetPort: 2379 + - name: peer + port: 2380 + targetPort: 2380 --- # etcd External Service @@ -142,9 +142,9 @@ spec: selector: app: {{ .Release.Name }}-etcd ports: - - port: 2379 - targetPort: 2379 - nodePort: 31379 + - port: 2379 + targetPort: 2379 + nodePort: 31379 {{- else }} apiVersion: v1 kind: Service @@ -155,47 +155,104 @@ spec: selector: app: {{ .Release.Name }}-etcd ports: - - port: 2379 - targetPort: 2379 + - port: 2379 + targetPort: 2379 {{- end }} --- -# exp Service -{{- if eq .Values.configmap.system.env_mode "staging" }} +# API Gateway Service apiVersion: v1 kind: Service metadata: - name: {{ .Release.Name }}-exp + name: {{ .Release.Name }}-api-gateway spec: + {{- if eq .Values.configmap.system.env_mode "staging" }} type: NodePort + {{- else }} + type: {{ .Values.microservices.apiGateway.service.type }} + {{- end }} selector: - app: {{ .Release.Name }}-producer + app: {{ .Release.Name }}-api-gateway ports: - - name: grpc - port: {{ .Values.configmap.otlp.port }} - targetPort: {{ .Values.configmap.otlp.port }} - nodePort: 32319 - - name: http - port: {{ .Values.configmap.port}} - targetPort: {{ .Values.configmap.port}} - nodePort: 32080 -{{- else }} + - name: http + port: {{ .Values.microservices.apiGateway.httpPort }} + targetPort: {{ .Values.microservices.apiGateway.httpPort }} + {{- if eq .Values.configmap.system.env_mode "staging" }} + nodePort: {{ .Values.microservices.apiGateway.service.nodePort }} + {{- end }} + +--- apiVersion: v1 kind: Service metadata: - name: {{ .Release.Name }}-exp + name: {{ .Release.Name }}-iam-service spec: type: ClusterIP selector: - app: {{ .Release.Name }}-producer + app: {{ .Release.Name }}-iam-service ports: - - name: grpc - port: {{ .Values.configmap.otlp.port }} - targetPort: {{ .Values.configmap.otlp.port }} - - name: http - port: {{ .Values.configmap.port}} - targetPort: {{ .Values.configmap.port}} -{{- end }} + - name: grpc + port: {{ .Values.microservices.iamService.grpcPort }} + targetPort: {{ .Values.microservices.iamService.grpcPort }} + +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-orchestrator-service +spec: + type: ClusterIP + selector: + app: {{ .Release.Name }}-orchestrator-service + ports: + - name: grpc + port: {{ .Values.microservices.orchestratorService.grpcPort }} + targetPort: {{ .Values.microservices.orchestratorService.grpcPort }} + +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-resource-service +spec: + type: ClusterIP + selector: + app: {{ .Release.Name }}-resource-service + ports: + - name: grpc + port: {{ .Values.microservices.resourceService.grpcPort }} + targetPort: {{ .Values.microservices.resourceService.grpcPort }} + +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-runtime-worker-service +spec: + type: ClusterIP + selector: + app: {{ .Release.Name }}-runtime-worker-service + ports: + - name: grpc + port: {{ .Values.microservices.runtimeWorkerService.grpcPort }} + targetPort: {{ .Values.microservices.runtimeWorkerService.grpcPort }} + - name: otlp + port: {{ .Values.configmap.otlp.port }} + targetPort: {{ .Values.configmap.otlp.port }} + +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }}-system-service +spec: + type: ClusterIP + selector: + app: {{ .Release.Name }}-system-service + ports: + - name: grpc + port: {{ .Values.microservices.systemService.grpcPort }} + targetPort: {{ .Values.microservices.systemService.grpcPort }} --- apiVersion: v1 @@ -259,36 +316,3 @@ spec: targetPort: 9090 {{- end }} {{- end }} - -{{- if .Values.grafana.enabled }} ---- -# Grafana Service -{{- if eq .Values.configmap.system.env_mode "staging" }} -apiVersion: v1 -kind: Service -metadata: - name: {{ .Release.Name }}-grafana -spec: - type: NodePort - selector: - app: {{ .Release.Name }}-grafana - ports: - - name: http - port: 3000 - targetPort: 3000 - nodePort: 32300 -{{- else }} -apiVersion: v1 -kind: Service -metadata: - name: {{ .Release.Name }}-grafana -spec: - type: ClusterIP - selector: - app: {{ .Release.Name }}-grafana - ports: - - name: http - port: 3000 - targetPort: 3000 -{{- end }} -{{- end }} \ No newline at end of file diff --git a/helm/values.yaml b/helm/values.yaml index c0485555..b6355f4e 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -147,7 +147,7 @@ initialization: configmap: name: "rcabench" version: "1.0.0" - port: 8080 + port: 8082 workspace: "/app" system: env_mode: "prod" @@ -193,6 +193,29 @@ configmap: port: 4319 max_request_size: 5242880 +microservices: + apiGateway: + replicaCount: 1 + service: + type: ClusterIP + nodePort: 32082 + httpPort: 8082 + iamService: + replicaCount: 1 + grpcPort: 9091 + orchestratorService: + replicaCount: 1 + grpcPort: 9092 + resourceService: + replicaCount: 1 + grpcPort: 9093 + runtimeWorkerService: + replicaCount: 1 + grpcPort: 9094 + systemService: + replicaCount: 1 + grpcPort: 9095 + persistence: # Global storage type setting - applies to containers, logs, and juicefs sections # Options: "juicefs" | "volcengine" | "external" diff --git a/justfile b/justfile index ae4eab08..dcbc7ed0 100644 --- a/justfile +++ b/justfile @@ -61,7 +61,7 @@ check-prerequisites: printf "{{green}}✅ All dependency checks passed{{reset}}\n\n" # 🔗 Start port forwarding to access application -forward-ports env="prod": +port-forward env="prod": just run-command port start -e {{env}} -n {{ns}} # 🛠️ Setup development environment @@ -99,8 +99,8 @@ setup-test-env: check-prerequisites # Pedestal Function # ============================================================================= -# 🔍 Install pedestals in namespaces (usage: just install-pedestals ) -install-pedestals pedestal_name pedestal_count: +# 🔍 Install pedestals in namespaces (usage: just pedestal-install ) +pedestal-install pedestal_name pedestal_count: just run-command pedestal install -e {{env_mode}} -n {{pedestal_name}} -c {{pedestal_count}} -f # ============================================================================= @@ -108,7 +108,7 @@ install-pedestals pedestal_name pedestal_count: # ============================================================================= # Deploy OpenEBS -install-openebs: +openebs-install: #!/usr/bin/env bash set -euo pipefail printf "{{blue}}Deploying OpenEBS...{{reset}}\n" @@ -119,7 +119,7 @@ install-openebs: printf "{{green}}✅ OpenEBS installed successfully{{reset}}\n\n" # 🔧 Deploy RCABench application in prod environment -install-rcabench: +rcabench-install: #!/usr/bin/env bash set -euo pipefail printf "{{blue}}🔧 Deploying RCABench application...{{reset}}\n" @@ -132,23 +132,23 @@ install-rcabench: --atomic --timeout 10m printf "{{green}}✅ RCABench installed successfully{{reset}}\n\n" printf "{{blue}}🔗 Starting automatic port forwarding...{{reset}}\n" - just forward-ports + just port-forward # 🛠️ Setup local development environment with basic services local-deploy: just run-command rcabench local-deploy -f - just init-etcd + just etcd-init # 🚀 Build and deploy application (using skaffold) run: check-prerequisites ENV_MODE=staging devbox run skaffold run # Initialize etcd -init-etcd: +etcd-init: just run-command etcd init -e {{env_mode}} -f -update-version version: - just run-command rcabench update-version -v {{version}} +version-update version: + just run-command rcabench version-update -v {{version}} # ============================================================================= # Backup @@ -167,8 +167,8 @@ test version: SDK_VERSION={{version}} ENV_MODE=test devbox run skaffold run # Run regression tests -regression-test: - chmod +x ./scripts/regression-test.sh && ./scripts/regression-test.sh +test-regression: + chmod +x ./scripts/test-regression.sh && ./scripts/test-regression.sh # ============================================================================= # Development Tools @@ -213,7 +213,7 @@ delete-chaos ns_prefix ns_count: # ============================================================================= # 🔄 Sync Docker images from DockerHub to prod repository -sync-images bv="latest" fv="latest": +images-sync bv="latest" fv="latest": #!/usr/bin/env bash set -euo pipefail source {{root}}/.secret @@ -250,18 +250,32 @@ sync-images bv="latest" fv="latest": # ============================================================================= # 📝 Initialize Swagger documentation -swag-init version: - just run-command swagger init -v {{version}} +swagger-init v: + just run-command swagger init -v {{v}} --apifox-target all -# ⚙️ Generate TypeScript Client from Swagger documentation -generate-typescript-client version: - just swag-init {{version}} - just run-command swagger generate-client -l typescript -v {{version}} +# ⚙️ Generate Portal TypeScript SDK from Swagger documentation +generate-portal v: + just run-command sdk typescript --target portal --env local --version {{v}} + +# ⚙️ Generate Admin TypeScript SDK from Swagger documentation +generate-admin v: + just run-command sdk typescript --target admin --env local --version {{v}} # ⚙️ Generate Python SDK from Swagger documentation -generate-python-sdk version: - just swag-init {{version}} - just run-command swagger generate-sdk -l python -v {{version}} +generate-python-sdk v: + just run-command sdk python --target sdk --env local --version {{v}} + +# 🚀 Generate release-ready Portal TypeScript SDK +release-portal v: + just run-command sdk typescript --target portal --env release --version {{v}} + +# 🚀 Generate release-ready Admin TypeScript SDK +release-admin v: + just run-command sdk typescript --target admin --env release --version {{v}} + +# 🚀 Generate release-ready Python SDK +release-python-sdk v: + just run-command sdk python --target sdk --env release --version {{v}} # ============================================================================= # Utilities @@ -289,7 +303,7 @@ release version: #!/usr/bin/env bash set -euo pipefail printf "{{blue}}🚀 Releasing version {{version}}...{{reset}}\n" - just update-version {{version}} + just version-update {{version}} just changelog git add {{root}}/CHANGELOG.md {{root}}/helm/Chart.yaml {{root}}/helm/values.yaml \ {{root}}/src/config.dev.toml {{root}}/src/main.go @@ -297,4 +311,4 @@ release version: git push -u origin main git tag -a "v{{version}}" -m "Release version {{version}}" git push origin "v{{version}}" - printf "{{green}}✅ Version {{version}} released successfully{{reset}}\n" \ No newline at end of file + printf "{{green}}✅ Version {{version}} released successfully{{reset}}\n" diff --git a/manifests/microservices/README.md b/manifests/microservices/README.md new file mode 100644 index 00000000..c2004721 --- /dev/null +++ b/manifests/microservices/README.md @@ -0,0 +1,20 @@ +# Microservice Kubernetes Skeleton + +这目录承接当前六服务拆分后的第一版 Kubernetes skeleton。 + +当前文件: + +- `aegislab-microservices.yaml` + +用途: + +- 给 `api-gateway / iam-service / resource-service / orchestrator-service / runtime-worker-service / system-service` 提供第一版 Deployment/Service 骨架 +- 明确端口、启动命令、probe 约定、配置挂载方式 + +注意: + +- 这是一份 skeleton,不是最终生产部署方案 +- 当前仍假设: + - MySQL / Redis / Etcd / Jaeger / BuildKit 等基础依赖已由其他清单或平台层提供 + - `ConfigMap/aegislab-config` 已准备好并包含 `config.toml` +- 这份清单优先编码“边界和启动方式”,而不是覆盖全部生产级资源策略 diff --git a/manifests/microservices/aegislab-microservices.yaml b/manifests/microservices/aegislab-microservices.yaml new file mode 100644 index 00000000..29e9666c --- /dev/null +++ b/manifests/microservices/aegislab-microservices.yaml @@ -0,0 +1,330 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: aegislab +--- +apiVersion: v1 +kind: Service +metadata: + name: api-gateway + namespace: aegislab +spec: + selector: + app: api-gateway + ports: + - name: http + port: 8082 + targetPort: 8082 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api-gateway + namespace: aegislab +spec: + replicas: 1 + selector: + matchLabels: + app: api-gateway + template: + metadata: + labels: + app: api-gateway + spec: + containers: + - name: api-gateway + image: opspai/rcabench:latest + args: ["api-gateway", "--conf", "/etc/rcabench/config.toml", "--port", "8082"] + ports: + - containerPort: 8082 + name: http + readinessProbe: + httpGet: + path: /system/health + port: 8082 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /system/health + port: 8082 + initialDelaySeconds: 10 + periodSeconds: 15 + volumeMounts: + - name: config + mountPath: /etc/rcabench/config.toml + subPath: config.toml + volumes: + - name: config + configMap: + name: aegislab-config +--- +apiVersion: v1 +kind: Service +metadata: + name: iam-service + namespace: aegislab +spec: + selector: + app: iam-service + ports: + - name: grpc + port: 9091 + targetPort: 9091 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: iam-service + namespace: aegislab +spec: + replicas: 1 + selector: + matchLabels: + app: iam-service + template: + metadata: + labels: + app: iam-service + spec: + containers: + - name: iam-service + image: opspai/rcabench:latest + args: ["iam-service", "--conf", "/etc/rcabench/config.toml"] + ports: + - containerPort: 9091 + name: grpc + readinessProbe: + grpc: + port: 9091 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + grpc: + port: 9091 + initialDelaySeconds: 10 + periodSeconds: 15 + volumeMounts: + - name: config + mountPath: /etc/rcabench/config.toml + subPath: config.toml + volumes: + - name: config + configMap: + name: aegislab-config +--- +apiVersion: v1 +kind: Service +metadata: + name: orchestrator-service + namespace: aegislab +spec: + selector: + app: orchestrator-service + ports: + - name: grpc + port: 9092 + targetPort: 9092 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: orchestrator-service + namespace: aegislab +spec: + replicas: 1 + selector: + matchLabels: + app: orchestrator-service + template: + metadata: + labels: + app: orchestrator-service + spec: + containers: + - name: orchestrator-service + image: opspai/rcabench:latest + args: ["orchestrator-service", "--conf", "/etc/rcabench/config.toml"] + ports: + - containerPort: 9092 + name: grpc + readinessProbe: + grpc: + port: 9092 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + grpc: + port: 9092 + initialDelaySeconds: 10 + periodSeconds: 15 + volumeMounts: + - name: config + mountPath: /etc/rcabench/config.toml + subPath: config.toml + volumes: + - name: config + configMap: + name: aegislab-config +--- +apiVersion: v1 +kind: Service +metadata: + name: resource-service + namespace: aegislab +spec: + selector: + app: resource-service + ports: + - name: grpc + port: 9093 + targetPort: 9093 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: resource-service + namespace: aegislab +spec: + replicas: 1 + selector: + matchLabels: + app: resource-service + template: + metadata: + labels: + app: resource-service + spec: + containers: + - name: resource-service + image: opspai/rcabench:latest + args: ["resource-service", "--conf", "/etc/rcabench/config.toml"] + ports: + - containerPort: 9093 + name: grpc + readinessProbe: + grpc: + port: 9093 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + grpc: + port: 9093 + initialDelaySeconds: 10 + periodSeconds: 15 + volumeMounts: + - name: config + mountPath: /etc/rcabench/config.toml + subPath: config.toml + volumes: + - name: config + configMap: + name: aegislab-config +--- +apiVersion: v1 +kind: Service +metadata: + name: runtime-worker-service + namespace: aegislab +spec: + selector: + app: runtime-worker-service + ports: + - name: grpc + port: 9094 + targetPort: 9094 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: runtime-worker-service + namespace: aegislab +spec: + replicas: 1 + selector: + matchLabels: + app: runtime-worker-service + template: + metadata: + labels: + app: runtime-worker-service + spec: + containers: + - name: runtime-worker-service + image: opspai/rcabench:latest + args: ["runtime-worker-service", "--conf", "/etc/rcabench/config.toml"] + ports: + - containerPort: 9094 + name: grpc + readinessProbe: + grpc: + port: 9094 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + grpc: + port: 9094 + initialDelaySeconds: 10 + periodSeconds: 15 + volumeMounts: + - name: config + mountPath: /etc/rcabench/config.toml + subPath: config.toml + volumes: + - name: config + configMap: + name: aegislab-config +--- +apiVersion: v1 +kind: Service +metadata: + name: system-service + namespace: aegislab +spec: + selector: + app: system-service + ports: + - name: grpc + port: 9095 + targetPort: 9095 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: system-service + namespace: aegislab +spec: + replicas: 1 + selector: + matchLabels: + app: system-service + template: + metadata: + labels: + app: system-service + spec: + containers: + - name: system-service + image: opspai/rcabench:latest + args: ["system-service", "--conf", "/etc/rcabench/config.toml"] + ports: + - containerPort: 9095 + name: grpc + readinessProbe: + grpc: + port: 9095 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + grpc: + port: 9095 + initialDelaySeconds: 10 + periodSeconds: 15 + volumeMounts: + - name: config + mountPath: /etc/rcabench/config.toml + subPath: config.toml + volumes: + - name: config + configMap: + name: aegislab-config diff --git a/project-index.yaml b/project-index.yaml index 70e82437..136e29c7 100644 --- a/project-index.yaml +++ b/project-index.yaml @@ -1614,7 +1614,7 @@ requirements: priority: P1 status: implemented confidence: inferred - source: "sdk/python/, scripts/command/src/swagger.py" + source: "sdk/python/, scripts/command/src/swagger/python.py, scripts/command/src/cli/sdk.py" code: - path: sdk/python/src/rcabench/client @@ -1635,7 +1635,7 @@ requirements: depends_on: [REQ-602] conflicts: [] - notes: "Generated via 'make generate-python-sdk'" + notes: "Generated via 'just generate-python-sdk '; consumes src/docs/converted/sdk.json" - id: REQ-601 title: TypeScript SDK (Auto-generated) @@ -1646,7 +1646,7 @@ requirements: priority: P1 status: implemented confidence: inferred - source: "CLAUDE.md" + source: "scripts/command/src/swagger/typescript.py, scripts/command/src/cli/sdk.py" code: [] @@ -1667,23 +1667,24 @@ requirements: depends_on: [REQ-602] conflicts: [] - notes: "Generated via 'make generate-typescript-sdk SDK_VERSION=x.x.x'; sdk/typescript/ directory" + notes: "Generated via 'just generate-portal ' / 'just generate-admin '; output in sdk/typescript/{portal,admin}; generator config lives in .openapi-generator/typescript/*" - id: REQ-602 title: Swagger/OpenAPI Documentation description: > API documentation via Swagger annotations on all handler functions. - APIs marked with @x-api-type {"sdk":"true"} are included in generated SDKs. + Generated audience specs are extracted from OpenAPI3 x-api-type extensions + (sdk / portal / admin) and then fed into the language-specific generators. Generated via swag init with dependency parsing. Backend-only tooling. priority: P1 status: implemented confidence: confirmed - source: "src/handlers/v2/*.go" + source: "src/docs/openapi3/openapi.json, scripts/command/src/swagger/init.py" code: - - path: src/handlers/v2/auth.go - description: "Example of Swagger annotations with @x-api-type SDK marking" + - path: src/handlers/docs.go + description: "Swagger annotation example carrying x-api-type audience metadata" frontend: [] has_mock: false @@ -1700,7 +1701,7 @@ requirements: depends_on: [] conflicts: [] - notes: "scripts/command/src/swagger.py filters APIs by x-api-type.sdk field" + notes: "scripts/command/src/swagger/init.py extracts sdk / portal / admin audience specs from OpenAPI3 x-api-type metadata and writes converted artifacts under src/docs/converted/" # =========================================================================== # REQ-7xx: Deployment & Infrastructure (Backend) diff --git a/scripts/command/settings.toml b/scripts/command/settings.toml index 3c065b90..7b085ff2 100644 --- a/scripts/command/settings.toml +++ b/scripts/command/settings.toml @@ -8,6 +8,37 @@ release_name = "rcabench" python_sdk_dir = "sdk/python" time_format = "%Y%m%d_%H%M%S" +[default.openapi] +generator_volume_root = "/local" + +[default.apifox] +api_base_url = "https://api.apifox.com/v1" +api_version = "2024-03-28" +locale = "zh-CN" + +[default.sdk.python] +git_host = "github.com" +git_user_id = "OperationsPAI" +git_repo_id = "AegisLab" + +[default.sdk.typescript.portal] +npm_name = "@OperationsPAI/portal" +npm_description = "TypeScript Portal SDK for RCABench API" + +[default.sdk.typescript.admin] +npm_name = "@OperationsPAI/admin" +npm_description = "TypeScript Admin SDK for RCABench API" + +[default.command_urls] +cert_manager_manifest_url = "https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml" +mysql_apt_config_deb_url = "https://dev.mysql.com/get/mysql-apt-config_0.8.29-1_all.deb" + +[default.command_urls.helm_repo_urls] +chaos_mesh = "https://charts.chaos-mesh.org" +cilium = "https://helm.cilium.io/" +open_telemetry = "https://open-telemetry.github.io/opentelemetry-helm-charts" +clickstack = "https://hyperdxio.github.io/helm-charts" + [default.database.mysql] host = "localhost" port = "3306" diff --git a/scripts/command/src/backup/mysql.py b/scripts/command/src/backup/mysql.py index 24788544..f2d0ebf1 100644 --- a/scripts/command/src/backup/mysql.py +++ b/scripts/command/src/backup/mysql.py @@ -448,7 +448,7 @@ def install_tools() -> None: run_command( [ "wget", - "https://dev.mysql.com/get/mysql-apt-config_0.8.29-1_all.deb", + settings.command_urls.mysql_apt_config_deb_url, "-O", "/tmp/mysql-apt-config.deb", ], diff --git a/scripts/command/src/cli/main.py b/scripts/command/src/cli/main.py index 68584444..055118f4 100644 --- a/scripts/command/src/cli/main.py +++ b/scripts/command/src/cli/main.py @@ -17,6 +17,7 @@ def main(): pedestal, port_manager, rcabench_, + sdk, swagger, test, ) @@ -32,6 +33,7 @@ def main(): port_manager.app, name="port", help="Kubernetes port forwarding manager." ) app.add_typer(rcabench_.app, name="rcabench", help="RCABench utilities.") + app.add_typer(sdk.app, name="sdk", help="SDK generation utilities.") app.add_typer(swagger.app, name="swagger", help="Swagger/OpenAPI utilities.") app.add_typer(test.app, name="test", help="Test environment utilities.") diff --git a/scripts/command/src/cli/rcabench_.py b/scripts/command/src/cli/rcabench_.py index 63aadb31..dc41f293 100644 --- a/scripts/command/src/cli/rcabench_.py +++ b/scripts/command/src/cli/rcabench_.py @@ -51,11 +51,11 @@ def rcabench_local_deploy( "\n[bold yellow]You can start the application manually later: [/bold yellow]" ) console.print( - f"[gray]cd {PROJECT_ROOT / 'src'} && go run main.go both --port 8082 [/gray]" + f"[gray]cd {PROJECT_ROOT / 'src'} && go run . both --port 8082 [/gray]" ) -@app.command(name="update-version") +@app.command(name="version-update") def rcabench_update_version( version: str = typer.Option( ..., @@ -64,5 +64,5 @@ def rcabench_update_version( help="The new version to set in project files (e.g., 1.2.3).", ), ): - """Updates the version information in project files.""" + """Update project version markers in source and Helm files.""" update_version(version) diff --git a/scripts/command/src/cli/sdk.py b/scripts/command/src/cli/sdk.py new file mode 100644 index 00000000..44d7777f --- /dev/null +++ b/scripts/command/src/cli/sdk.py @@ -0,0 +1,93 @@ +from enum import Enum + +import typer + +from src.common.common import console, settings +from src.swagger import init +from src.swagger.common import RunMode +from src.swagger.python import PythonSDK +from src.swagger.typescript import TypeScriptSDK + +app = typer.Typer(help="Target-specific SDK generation utilities.") + + +class GenerationEnv(str, Enum): + LOCAL = "local" + RELEASE = "release" + + +class TypeScriptTarget(str, Enum): + PORTAL = "portal" + ADMIN = "admin" + + +class PythonTarget(str, Enum): + SDK = "sdk" + + +@app.command(name="typescript") +def generate_typescript_sdk( + target: TypeScriptTarget = typer.Option( + ..., + "--target", + "-t", + help="SDK target: portal or admin.", + ), + env: GenerationEnv = typer.Option( + GenerationEnv.LOCAL, + "--env", + "-e", + help="Generation environment: local or release.", + ), + version: str = typer.Option( + "0.0.0", + "--version", + "-v", + help="SDK package version.", + ), +): + """Generate one TypeScript SDK package.""" + + settings.reload() + init(version) + TypeScriptSDK(version, target=RunMode(target.value)).generate() + + if env == GenerationEnv.RELEASE: + console.print( + "[dim]Release-ready TypeScript package generated. Publish with your registry step when needed.[/dim]" + ) + + +@app.command(name="python") +def generate_python_sdk( + target: PythonTarget = typer.Option( + ..., + "--target", + "-t", + help="SDK target: sdk.", + ), + env: GenerationEnv = typer.Option( + GenerationEnv.LOCAL, + "--env", + "-e", + help="Generation environment: local or release.", + ), + version: str = typer.Option( + "0.0.0", + "--version", + "-v", + help="SDK package version.", + ), +): + """Generate the Python SDK package.""" + + del target + + settings.reload() + init(version) + PythonSDK(version).generate() + + if env == GenerationEnv.RELEASE: + console.print( + "[dim]Release-ready Python package generated. Publish with your registry step when needed.[/dim]" + ) diff --git a/scripts/command/src/cli/swagger.py b/scripts/command/src/cli/swagger.py index d9bfd392..be459b19 100644 --- a/scripts/command/src/cli/swagger.py +++ b/scripts/command/src/cli/swagger.py @@ -1,64 +1,22 @@ import typer -from src.common.common import LanguageType, console, settings -from src.swagger import Generator, init +from src.common.common import settings +from src.swagger import init +from src.swagger.apifox import ApifoxTarget -app = typer.Typer() +app = typer.Typer(help="Swagger/OpenAPI generation utilities.") @app.command(name="init") def swagger_init( version: str = typer.Option(..., "--version", "-v", help="API version."), -): - """Initializes Swagger documentation setup.""" - init(version) - - -@app.command() -def generate_client( - language: LanguageType = typer.Option( - LanguageType.TYPESCRIPT, - "--language", - "-l", - help="SDK language.", - ), - version: str = typer.Option( - "1.0.0", - "--version", - "-v", - help="API version.", + apifox_targets: list[ApifoxTarget] | None = typer.Option( + None, + "--apifox-target", + "-t", + help="Optional Apifox upload targets: sdk, portal, admin, or all. Omit to skip upload.", ), ): - """Generates Swagger client documentation.""" - + """Generate normalized OpenAPI artifacts from Go Swagger annotations.""" settings.reload() - - if language != LanguageType.TYPESCRIPT: - console.print( - f"[bold red]❌ Client generation for {language} is not supported yet.[/bold red]" - ) - raise typer.Exit(code=1) - - Generator.get_client_generator(language, version).generate() - - -@app.command() -def generate_sdk( - language: LanguageType = typer.Option( - LanguageType.PYTHON, - "--language", - "-l", - help="SDK language.", - ), - version: str = typer.Option( - "1.0.0", - "--version", - "-v", - help="API version.", - ), -): - """Generates SDK Swagger documentation.""" - - settings.reload() - - Generator.get_sdk_generator(language, version).generate() + init(version, apifox_targets=apifox_targets) diff --git a/scripts/command/src/formatter/python.py b/scripts/command/src/formatter/python.py index e5923750..1cdff6a3 100644 --- a/scripts/command/src/formatter/python.py +++ b/scripts/command/src/formatter/python.py @@ -1,5 +1,6 @@ import os import re +import shutil from collections import Counter from rich.table import Table @@ -39,9 +40,26 @@ def __init__(self, scope: ScopeType = ScopeType.STAGED, sdk_dir: str | None = No super().__init__(scope) self.sdk_dir = sdk_dir or settings.python_sdk_dir self.has_errors = False + self.ruff_binary = self._resolve_ruff_binary() self.extra_args = ["--config", os.path.join(self.sdk_dir, "pyproject.toml")] self.files_to_format = self._get_files() + def _resolve_ruff_binary(self) -> str | None: + """Resolve ruff from PATH first, then from the local command venv.""" + binary = shutil.which("ruff") + if binary: + return binary + + candidates = [ + PROJECT_ROOT / "scripts" / "command" / ".venv" / "bin" / "ruff", + PROJECT_ROOT / ".venv" / "bin" / "ruff", + ] + for candidate in candidates: + if candidate.is_file(): + return candidate.as_posix() + + return None + def _get_files(self) -> list[str]: """ Get files to format based on the configured scope. @@ -140,7 +158,7 @@ def _categorize_files(self) -> dict[str, list[str]]: def _run_ruff_check(self, category: str, files: list[str]) -> bool: """Run ruff check --fix on files.""" - cmd = ["ruff", "check", "--fix", "--unsafe-fixes"] + cmd = [self.ruff_binary or "ruff", "check", "--fix", "--unsafe-fixes"] cmd.extend(files) if category == ScopeType.SDK.value: cmd.extend(self.extra_args) @@ -164,7 +182,7 @@ def _run_ruff_check(self, category: str, files: list[str]) -> bool: def _check_remaining_errors(self, category: str, files: list[str]) -> str | None: """Check for remaining errors after fix.""" - cmd = ["ruff", "check"] + cmd = [self.ruff_binary or "ruff", "check"] cmd.extend(files) if category == ScopeType.SDK.value: cmd.extend(self.extra_args) @@ -238,7 +256,7 @@ def _display_error_statistics(self, output: str) -> None: def _run_ruff_format(self, category: str, files: list[str]) -> bool: """Run ruff format on files.""" - cmd = ["ruff", "format"] + files + cmd = [self.ruff_binary or "ruff", "format"] + files cmd.extend(files) if category == ScopeType.SDK.value: cmd.extend(self.extra_args) @@ -267,6 +285,11 @@ def run(self) -> int: if not self.files_to_format: console.print("[bold yellow]No Python files to format.[/bold yellow]") return 0 + if self.ruff_binary is None: + console.print( + "[bold yellow]⚠️ Ruff not found; skipping Python formatting.[/bold yellow]" + ) + return 0 console.print("[bold blue]🎨 Formatting Python files with ruff...[/bold blue]") diff --git a/scripts/command/src/swagger/__init__.py b/scripts/command/src/swagger/__init__.py index 0cd81fc6..b9a20d27 100644 --- a/scripts/command/src/swagger/__init__.py +++ b/scripts/command/src/swagger/__init__.py @@ -1,11 +1,4 @@ -from src.common.common import LanguageType -from src.swagger.common import Generator +from src.swagger.apifox import ApifoxTarget from src.swagger.init import init -from src.swagger.python import PythonSDK -from src.swagger.typescript import TypeScriptClient -__all__ = ["init", "Generator"] - -Generator.register_client(LanguageType.TYPESCRIPT, TypeScriptClient) - -Generator.register_sdk(LanguageType.PYTHON, PythonSDK) +__all__ = ["ApifoxTarget", "init"] diff --git a/scripts/command/src/swagger/apifox.py b/scripts/command/src/swagger/apifox.py new file mode 100644 index 00000000..3a8d3694 --- /dev/null +++ b/scripts/command/src/swagger/apifox.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from enum import Enum +from pathlib import Path + +import typer +from rich.panel import Panel + +from src.common.common import console, settings + +__all__ = [ + "ApifoxTarget", + "upload_targets_to_apifox", +] + + +class ApifoxTarget(str, Enum): + SDK = "sdk" + PORTAL = "portal" + ADMIN = "admin" + ALL = "all" + + +TARGET_OPENAPI_FILES: dict[ApifoxTarget, str] = { + ApifoxTarget.SDK: "sdk.json", + ApifoxTarget.PORTAL: "portal.json", + ApifoxTarget.ADMIN: "admin.json", +} + + +def upload_targets_to_apifox( + converted_dir: Path, + targets: list[ApifoxTarget] | None = None, +) -> None: + """Upload one or more generated OpenAPI documents to Apifox.""" + normalized_targets = _normalize_targets(targets or [ApifoxTarget.ALL]) + _ensure_common_config() + + for target in normalized_targets: + openapi_path = converted_dir / TARGET_OPENAPI_FILES[target] + endpoint_folder_id = _required_env( + f"APIFOX_{target.upper()}_ENDPOINT_FOLDER_ID" + ) + schema_folder_id = _required_env(f"APIFOX_{target.upper()}_SCHEMA_FOLDER_ID") + _upload_openapi( + openapi_path=openapi_path, + label=target.value, + endpoint_folder_id=int(endpoint_folder_id), + schema_folder_id=int(schema_folder_id), + ) + + +def _normalize_targets(targets: list[ApifoxTarget]) -> list[ApifoxTarget]: + """Expand and de-duplicate Apifox upload targets.""" + normalized: list[ApifoxTarget] = [] + for target in targets: + if target == ApifoxTarget.ALL: + normalized.extend( + [ + ApifoxTarget.SDK, + ApifoxTarget.PORTAL, + ApifoxTarget.ADMIN, + ] + ) + continue + normalized.append(target) + + deduped: list[ApifoxTarget] = [] + seen: set[ApifoxTarget] = set() + for target in normalized: + if target in seen: + continue + seen.add(target) + deduped.append(target) + return deduped + + +def _ensure_common_config() -> None: + """Ensure project-level Apifox credentials exist before uploading.""" + missing = [ + name + for name in ("APIFOX_PROJECT_ID", "APIFOX_ACCESS_TOKEN") + if not os.getenv(name) + ] + if missing: + console.print( + "[bold red]Missing required Apifox config:[/bold red] " + ", ".join(missing) + ) + raise typer.Exit(2) + + +def _required_env(name: str) -> str: + """Return a required env var or exit with a clear message.""" + value = os.getenv(name) + if value: + return value + console.print(f"[bold red]Missing required Apifox config:[/bold red] {name}") + raise typer.Exit(2) + + +def _upload_openapi( + *, + openapi_path: Path, + label: str, + endpoint_folder_id: int, + schema_folder_id: int, +) -> None: + """Upload one OpenAPI document to Apifox.""" + if not openapi_path.exists(): + console.print(f"[bold red]OpenAPI file not found:[/bold red] {openapi_path}") + raise typer.Exit(2) + + apifox_settings = settings.apifox + project_id = _required_env("APIFOX_PROJECT_ID") + access_token = _required_env("APIFOX_ACCESS_TOKEN") + payload = { + "input": openapi_path.read_text(encoding="utf-8"), + "options": { + "targetEndpointFolderId": endpoint_folder_id, + "targetSchemaFolderId": schema_folder_id, + "endpointOverwriteBehavior": "OVERWRITE_EXISTING", + "schemaOverwriteBehavior": "OVERWRITE_EXISTING", + "updateFolderOfChangedEndpoint": True, + "prependBasePath": True, + }, + } + + request = urllib.request.Request( + ( + f"{str(apifox_settings.api_base_url).rstrip('/')}/projects/{project_id}/import-openapi" + f"?locale={apifox_settings.locale}" + ), + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + method="POST", + headers={ + "X-Apifox-Api-Version": apifox_settings.api_version, + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + ) + + console.print( + Panel( + f"file: {openapi_path}\n" + f"endpoint folder: {endpoint_folder_id}\n" + f"schema folder: {schema_folder_id}", + title=f"Uploading {label} OpenAPI to Apifox", + ) + ) + try: + with urllib.request.urlopen(request) as response: + body = response.read().decode("utf-8") + console.print( + f"[green]Apifox upload succeeded for {label} (HTTP {response.status}).[/green]" + ) + _print_response(body) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + console.print( + f"[bold red]Apifox upload failed for {label} (HTTP {exc.code}).[/bold red]" + ) + _print_response(body) + raise typer.Exit(1) from exc + + +def _print_response(response_body: str) -> None: + """Pretty-print Apifox responses when possible.""" + try: + console.print_json(response_body) + except Exception: + console.print(response_body) diff --git a/scripts/command/src/swagger/common.py b/scripts/command/src/swagger/common.py index ebd1cfb9..b9010ad7 100644 --- a/scripts/command/src/swagger/common.py +++ b/scripts/command/src/swagger/common.py @@ -1,7 +1,6 @@ -from abc import ABC from enum import Enum -from src.common.common import PROJECT_ROOT, LanguageType +from src.common.common import PROJECT_ROOT SWAGGER_ROOT = PROJECT_ROOT / "src" / "docs" OPENAPI2_DIR = SWAGGER_ROOT / "openapi2" @@ -10,57 +9,7 @@ class RunMode(str, Enum): - CLIENT = "client" SDK = "sdk" - - -class Generator(ABC): - """Base generator class with factory pattern.""" - - _client_registry: dict[LanguageType, type["Generator"]] = {} - _sdk_registry: dict[LanguageType, type["Generator"]] = {} - - @classmethod - def register_client( - cls, name: LanguageType, generator_class: type["Generator"] - ) -> None: - """Register a client generator class with a name.""" - cls._client_registry[name] = generator_class - - @classmethod - def register_sdk( - cls, name: LanguageType, generator_class: type["Generator"] - ) -> None: - """Register a sdk generator class with a name.""" - cls._sdk_registry[name] = generator_class - - @staticmethod - def get_client_generator(generator_type: LanguageType, version: str) -> "Generator": - """Factory method to get a client generator instance based on type.""" - generator_class = Generator._client_registry.get(generator_type) - if not generator_class: - available = ", ".join(Generator._client_registry.keys()) - raise ValueError( - f"Unknown client generator type: {generator_type}. Available: {available}" - ) - - return generator_class(version) - - @staticmethod - def get_sdk_generator(generator_type: LanguageType, version: str) -> "Generator": - """Factory method to get a sdk generator instance based on type.""" - generator_class = Generator._sdk_registry.get(generator_type) - if not generator_class: - available = ", ".join(Generator._sdk_registry.keys()) - raise ValueError( - f"Unknown sdk generator type: {generator_type}. Available: {available}" - ) - - return generator_class(version) - - def __init__(self, version: str) -> None: - self.version = version - - def generate(self) -> None: - """Generate the client or SDK.""" - raise NotImplementedError + RUNTIME = "runtime" + PORTAL = "portal" + ADMIN = "admin" diff --git a/scripts/command/src/swagger/init.py b/scripts/command/src/swagger/init.py index 735a6d73..258ae01b 100644 --- a/scripts/command/src/swagger/init.py +++ b/scripts/command/src/swagger/init.py @@ -5,10 +5,9 @@ from pathlib import Path from typing import Any -from python_on_whales import docker - from src.common.command import run_command -from src.common.common import PROJECT_ROOT, console, settings +from src.common.common import console +from src.swagger.apifox import ApifoxTarget, upload_targets_to_apifox from src.swagger.common import SWAGGER_ROOT, RunMode from src.util import get_longest_common_substring @@ -19,6 +18,317 @@ __all__ = ["init"] +def audience_flag_enabled(x_api_type: Any, audience: str) -> bool: + """Return whether an x-api-type audience flag is enabled.""" + if not isinstance(x_api_type, dict): + return False + + value = x_api_type.get(audience) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() == "true" + return False + + +def normalize_openapi_ref(ref: str) -> str: + """Convert Swagger 2 refs to OpenAPI 3 component refs.""" + return ( + ref.replace("#/definitions/", "#/components/schemas/") + .replace("#/parameters/", "#/components/parameters/") + .replace("#/responses/", "#/components/responses/") + ) + + +def convert_schema_object(schema: Any) -> Any: + """Recursively convert a Swagger 2 schema object into OpenAPI 3 format.""" + if isinstance(schema, dict): + if schema.get("type") == "file": + converted_file_schema = dict(schema) + converted_file_schema["type"] = "string" + converted_file_schema["format"] = "binary" + return converted_file_schema + + converted: dict[str, Any] = {} + for key, value in schema.items(): + if key == "$ref" and isinstance(value, str): + converted[key] = normalize_openapi_ref(value) + continue + + if key in { + "schema", + "items", + "additionalProperties", + "not", + "propertyNames", + "contains", + }: + converted[key] = convert_schema_object(value) + continue + + if key in {"allOf", "anyOf", "oneOf"} and isinstance(value, list): + converted[key] = [convert_schema_object(item) for item in value] + continue + + if key == "properties" and isinstance(value, dict): + converted[key] = { + name: convert_schema_object(prop) for name, prop in value.items() + } + continue + + converted[key] = convert_schema_object(value) + + return converted + + if isinstance(schema, list): + return [convert_schema_object(item) for item in schema] + + return schema + + +def convert_swagger_parameter(parameter: dict[str, Any]) -> dict[str, Any]: + """Convert a non-body Swagger 2 parameter to OpenAPI 3.""" + converted = copy.deepcopy(parameter) + schema: dict[str, Any] = {} + + for field in ( + "type", + "format", + "items", + "enum", + "default", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "minLength", + "maxLength", + "pattern", + "multipleOf", + "minItems", + "maxItems", + "uniqueItems", + ): + if field in converted: + schema[field] = convert_schema_object(converted.pop(field)) + + if "collectionFormat" in converted: + collection_format = converted.pop("collectionFormat") + if collection_format == "multi": + converted["style"] = "form" + converted["explode"] = True + elif collection_format == "csv": + converted["style"] = "form" + converted["explode"] = False + + if schema: + converted["schema"] = schema + + return converted + + +def make_request_body_content( + schema: dict[str, Any], media_types: list[str] +) -> dict[str, Any]: + """Build an OpenAPI 3 requestBody content map.""" + return {media_type: {"schema": schema} for media_type in media_types} + + +def convert_swagger_operation( + operation: dict[str, Any], + global_consumes: list[str], + global_produces: list[str], +) -> dict[str, Any]: + """Convert a Swagger 2 operation to OpenAPI 3.""" + converted = copy.deepcopy(operation) + consumes = ( + converted.pop("consumes", None) or global_consumes or ["application/json"] + ) + produces = ( + converted.pop("produces", None) or global_produces or ["application/json"] + ) + + parameters = converted.pop("parameters", []) + request_body: dict[str, Any] | None = None + form_properties: dict[str, Any] = {} + form_required: list[str] = [] + converted_parameters: list[dict[str, Any]] = [] + + for parameter in parameters: + if not isinstance(parameter, dict): + continue + + if "$ref" in parameter: + parameter_ref = dict(parameter) + parameter_ref["$ref"] = normalize_openapi_ref(parameter_ref["$ref"]) + converted_parameters.append(parameter_ref) + continue + + location = parameter.get("in") + if location == "body": + request_schema = convert_schema_object(parameter.get("schema", {})) + request_body = { + "required": parameter.get("required", False), + "content": make_request_body_content(request_schema, consumes), + } + if parameter.get("description"): + request_body["description"] = parameter["description"] + continue + + if location == "formData": + property_schema: dict[str, Any] = {} + parameter_type = parameter.get("type") + if parameter_type == "file": + property_schema = {"type": "string", "format": "binary"} + else: + property_schema = { + "type": parameter_type, + } + if "format" in parameter: + property_schema["format"] = parameter["format"] + if "enum" in parameter: + property_schema["enum"] = parameter["enum"] + if "items" in parameter: + property_schema["items"] = convert_schema_object(parameter["items"]) + if "default" in parameter: + property_schema["default"] = parameter["default"] + + if parameter.get("description"): + property_schema["description"] = parameter["description"] + + form_properties[parameter["name"]] = property_schema + if parameter.get("required"): + form_required.append(parameter["name"]) + continue + + converted_parameters.append(convert_swagger_parameter(parameter)) + + if form_properties: + request_body = { + "required": bool(form_required), + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": form_properties, + } + } + }, + } + if form_required: + request_body["content"]["multipart/form-data"]["schema"]["required"] = ( + form_required + ) + + if converted_parameters: + converted["parameters"] = converted_parameters + if request_body is not None: + converted["requestBody"] = request_body + + converted_responses: dict[str, Any] = {} + for code, response in converted.get("responses", {}).items(): + if not isinstance(response, dict): + converted_responses[code] = response + continue + + response_copy = copy.deepcopy(response) + response_schema = response_copy.pop("schema", None) + if response_schema is not None: + response_copy["content"] = { + media_type: {"schema": convert_schema_object(response_schema)} + for media_type in produces + } + converted_responses[code] = convert_schema_object(response_copy) + + converted["responses"] = converted_responses + return convert_schema_object(converted) + + +def convert_swagger2_to_openapi3(swagger_data: dict[str, Any]) -> dict[str, Any]: + """Convert the generated Swagger 2 document into a full OpenAPI 3 document.""" + openapi_data = copy.deepcopy(swagger_data) + openapi_data["openapi"] = "3.0.3" + openapi_data.pop("swagger", None) + + global_consumes = openapi_data.pop("consumes", None) or [] + global_produces = openapi_data.pop("produces", None) or [] + + components: dict[str, Any] = {} + definitions = openapi_data.pop("definitions", {}) + if definitions: + components["schemas"] = { + name: convert_schema_object(schema) for name, schema in definitions.items() + } + + parameters = openapi_data.pop("parameters", {}) + if parameters: + components["parameters"] = { + name: convert_swagger_parameter(parameter) + for name, parameter in parameters.items() + } + + responses = openapi_data.pop("responses", {}) + if responses: + components["responses"] = { + name: convert_schema_object(response) + for name, response in responses.items() + } + + security_definitions = openapi_data.pop("securityDefinitions", {}) + if security_definitions: + components["securitySchemes"] = { + name: convert_schema_object(scheme) + for name, scheme in security_definitions.items() + } + + if components: + openapi_data["components"] = components + + host = openapi_data.pop("host", "") + base_path = openapi_data.pop("basePath", "") or "" + schemes = openapi_data.pop("schemes", None) or [] + if host: + if host.startswith("http://") or host.startswith("https://"): + server_url = host.rstrip("/") + else: + scheme = schemes[0] if schemes else "http" + server_url = f"{scheme}://{host}".rstrip("/") + if base_path: + server_url = f"{server_url}{base_path}" + openapi_data["servers"] = [{"url": server_url}] + + converted_paths: dict[str, Any] = {} + for path, path_item in openapi_data.get("paths", {}).items(): + if not isinstance(path_item, dict): + converted_paths[path] = path_item + continue + + converted_path_item: dict[str, Any] = {} + path_level_parameters = path_item.get("parameters", []) + if path_level_parameters: + converted_path_item["parameters"] = [ + convert_swagger_parameter(parameter) + if isinstance(parameter, dict) and "$ref" not in parameter + else {"$ref": normalize_openapi_ref(parameter["$ref"])} + for parameter in path_level_parameters + ] + + for method, operation in path_item.items(): + if method == "parameters": + continue + if not isinstance(operation, dict): + converted_path_item[method] = operation + continue + converted_path_item[method] = convert_swagger_operation( + operation, global_consumes, global_produces + ) + + converted_paths[path] = converted_path_item + + openapi_data["paths"] = converted_paths + return convert_schema_object(openapi_data) + + class SDKPostProcesser: """Process Swagger JSON to add SSE extensions and update model names.""" @@ -329,13 +639,15 @@ def convert_inline_enums_to_refs(self) -> None: return schema_path = "#/components/schemas/" + available_schemas = set(self.data.get("components", {}).get("schemas", {})) converted_count = 0 + skipped_count = 0 def process_parameters( params: list[dict[str, Any]], path: str, method: str ) -> None: """Process parameters and convert inline enums to refs.""" - nonlocal converted_count + nonlocal converted_count, skipped_count for param in params: if not isinstance(param, dict): @@ -371,13 +683,18 @@ def process_parameters( wildcard_key = f"*|{param_name}" target_schema = self.PARAMETER_SCHEMA_MAPPING.get(wildcard_key) - if target_schema: + if target_schema and target_schema in available_schemas: # Replace inline enum with $ref param["schema"] = {"$ref": f"{schema_path}{target_schema}"} converted_count += 1 console.print( f"[gray] -> Converted {method.upper()} {path} parameter '{param_name}' to use schema '{target_schema}'[/gray]" ) + elif target_schema: + skipped_count += 1 + console.print( + f"[gray] -> Kept inline enum for {method.upper()} {path} parameter '{param_name}' because schema '{target_schema}' is not present in components[/gray]" + ) # Process all paths and their operations for path, operations in self.data["paths"].items(): @@ -396,26 +713,37 @@ def process_parameters( console.print( f"[bold green]✅ Converted {converted_count} inline enum parameters to schema references[/bold green]" ) + if skipped_count > 0: + console.print( + f"[bold yellow]⚠ Skipped {skipped_count} inline enum parameter ref conversions because the target schema was not present[/bold yellow]" + ) def output(self, output_file: Path, category: RunMode) -> None: - output_data = self.data - if category == RunMode.SDK: - output_data = self._filter_sdk_apis() - if output_data is None: - console.print("[bold red]Processing function returned None[/bold red]") - sys.exit(1) + output_data = self._filter_apis_by_audience(category) + if output_data is None: + console.print("[bold red]Processing function returned None[/bold red]") + sys.exit(1) with open(output_file, "w", encoding="utf-8") as f: json.dump(output_data, f, indent=2) - def _filter_sdk_apis(self) -> dict[str, Any] | None: + def _filter_apis_by_audience(self, category: RunMode) -> dict[str, Any] | None: """ - Filter Swagger JSON to only keep APIs marked with x-api-type: {"sdk": "true"}. - Remove all other APIs and their unused model definitions. + Filter Swagger JSON according to the x-api-type audience flags. """ + audience_keys_by_mode = { + RunMode.SDK: {"sdk"}, + RunMode.RUNTIME: {"runtime"}, + RunMode.PORTAL: {"portal"}, + RunMode.ADMIN: {"admin"}, + } + audience_keys = audience_keys_by_mode.get(category) + if not audience_keys: + return copy.deepcopy(self.data) + new_data = copy.deepcopy(self.data) - # Step 1: Filter paths - keep only APIs with x-api-type.sdk = "true" + # Step 1: Filter paths - keep only operations tagged for the target audience. original_paths = new_data["paths"] filtered_paths = {} removed_count = 0 @@ -425,8 +753,7 @@ def _filter_sdk_apis(self) -> dict[str, Any] | None: filtered_operations = {} for method, spec in operations.items(): x_api_type = spec.get("x-api-type", {}) - # Check if sdk is explicitly "true" (string) - if x_api_type.get("sdk") == "true": + if any(audience_flag_enabled(x_api_type, key) for key in audience_keys): filtered_operations[method] = spec kept_count += 1 console.print(f"[gray] ✓ Kept: {method.upper()} {path}[/gray]") @@ -499,10 +826,18 @@ def collect_refs(obj: dict[str, Any] | list[dict[str, Any]]) -> None: f"[gray]\n Models: {len(filtered_schemas)} kept, {removed_models} removed[/gray]" ) + console.print( + f"[gray]\n {category.value} operations: {kept_count} kept, {removed_count} removed[/gray]" + ) + return new_data -def init(version: str) -> None: +def init( + version: str, + *, + apifox_targets: list[ApifoxTarget] | None = None, +) -> None: """ Initialize Swagger documentation by generating OpenAPI 2.0 and converting to OpenAPI 3.0. """ @@ -524,43 +859,44 @@ def init(version: str) -> None: ] ) - # 2. Generate OpenAPI3 using OpenAPI Generator - volume_path = Path("/local") - relative_swagger = SWAGGER_ROOT.relative_to(PROJECT_ROOT) - container_input_path = volume_path / relative_swagger / "openapi2" / "swagger.json" - container_output_path = volume_path / relative_swagger / "openapi3" - - try: - docker.run( - settings.generator_image, - command=[ - "generate", - "-i", - container_input_path.as_posix(), - "-g", - "openapi", - "-o", - container_output_path.as_posix(), - ], - volumes=[(PROJECT_ROOT, volume_path)], - remove=True, - ) - except Exception as e: - console.print(f"[bold_red]❌ Error during OpenAPI3 generation: {e}[/bold_red]") + # 2. Convert Swagger 2.0 into a full OpenAPI 3 document locally. + swagger2_file = OPENAPI2_DIR / "swagger.json" + if not swagger2_file.exists(): + console.print(f"[bold red]{swagger2_file} not found[/bold red]") sys.exit(1) + if OPENAPI3_DIR.exists(): + shutil.rmtree(OPENAPI3_DIR) + OPENAPI3_DIR.mkdir(parents=True) + + with open(swagger2_file, encoding="utf-8") as f: + swagger2_data = json.load(f) + + openapi3_data = convert_swagger2_to_openapi3(swagger2_data) + with open(OPENAPI3_DIR / "openapi.json", "w", encoding="utf-8") as f: + json.dump(openapi3_data, f, indent=2) + # 3. Post-process Swagger JSON - console.print("[bold blue]📦 Post-processing swagger initiaization...[/bold blue]") + console.print( + "[bold blue]📦 Post-processing generated OpenAPI artifacts...[/bold blue]" + ) if not CONVERTED_DIR.exists(): CONVERTED_DIR.mkdir(parents=True) + else: + stale_typescript_file = CONVERTED_DIR / "typescript.json" + stale_typescript_file.unlink(missing_ok=True) post_input_file = OPENAPI3_DIR / "openapi.json" - client_file = CONVERTED_DIR / "client.json" sdk_file = CONVERTED_DIR / "sdk.json" + runtime_file = CONVERTED_DIR / "runtime.json" + portal_file = CONVERTED_DIR / "portal.json" + admin_file = CONVERTED_DIR / "admin.json" - shutil.copyfile(post_input_file, dst=client_file) shutil.copyfile(post_input_file, dst=sdk_file) + shutil.copyfile(post_input_file, dst=runtime_file) + shutil.copyfile(post_input_file, dst=portal_file) + shutil.copyfile(post_input_file, dst=admin_file) processor = SDKPostProcesser(post_input_file) processor.update_version(version) @@ -569,8 +905,16 @@ def init(version: str) -> None: processor.deduplicate_enum_values() # Remove duplicate enum values processor.convert_inline_enums_to_refs() - processor.output(client_file, RunMode.CLIENT) processor.output(sdk_file, RunMode.SDK) + processor.output(runtime_file, RunMode.RUNTIME) + processor.output(portal_file, RunMode.PORTAL) + processor.output(admin_file, RunMode.ADMIN) + + if apifox_targets: + console.print( + "[bold blue]☁ Uploading generated OpenAPI documents to Apifox...[/bold blue]" + ) + upload_targets_to_apifox(CONVERTED_DIR, apifox_targets) console.print( "[bold green]✅ Swagger documentation generation completed successfully![/bold green]" diff --git a/scripts/command/src/swagger/python.py b/scripts/command/src/swagger/python.py index 388d63c4..bfa3e529 100644 --- a/scripts/command/src/swagger/python.py +++ b/scripts/command/src/swagger/python.py @@ -8,10 +8,10 @@ from src.common.common import PROJECT_ROOT, ScopeType, console, settings from src.formatter import PythonFormatter -from src.swagger.common import SWAGGER_ROOT, Generator +from src.swagger.common import SWAGGER_ROOT -class PythonSDK(Generator): +class PythonSDK: """Class to generate Python SDK from Swagger JSON using OpenAPI Generator.""" PYTHON_SDK_DIR = PROJECT_ROOT / "sdk" / "python" @@ -21,6 +21,10 @@ class PythonSDK(Generator): def __init__(self, version: str) -> None: self.version = version + @property + def package_settings(self): + return settings.sdk.python + def _update_version(self) -> None: """ Update version information in various project files. @@ -69,7 +73,7 @@ def generate(self) -> None: self.PYTHON_SDK_GEN_DIR.mkdir(parents=True) - volume_path = Path("/local") + volume_path = Path(settings.openapi.generator_volume_root) relative_swagger = SWAGGER_ROOT.relative_to(PROJECT_ROOT) relative_sdk_gen = self.PYTHON_SDK_GEN_DIR.relative_to(PROJECT_ROOT) relative_generator_config = self.PYTHON_GENERATOR_CONFIG_DIR.relative_to( @@ -89,6 +93,7 @@ def generate(self) -> None: current_user = os.getuid() current_group = os.getgid() + package_settings = self.package_settings try: docker.run( settings.generator_image, @@ -105,11 +110,11 @@ def generate(self) -> None: "-t", container_templates_path.as_posix(), "--git-host", - "github.com", + package_settings.git_host, "--git-repo-id", - "AegisLab", + package_settings.git_repo_id, "--git-user-id", - "OperationsPAI", + package_settings.git_user_id, ], volumes=[(PROJECT_ROOT, volume_path)], user=f"{current_user}:{current_group}", @@ -168,7 +173,9 @@ def generate(self) -> None: console.print( "[bold blue]Step 3: Formatting post-processed Python SDK...[/bold blue]" ) - formatter = PythonFormatter(scope=ScopeType.SDK) + formatter = PythonFormatter( + scope=ScopeType.SDK, sdk_dir=self.PYTHON_SDK_DIR.as_posix() + ) formatter.run() # 5. Update version information in project files diff --git a/scripts/command/src/swagger/typescript.py b/scripts/command/src/swagger/typescript.py index 67d80db3..20396f22 100644 --- a/scripts/command/src/swagger/typescript.py +++ b/scripts/command/src/swagger/typescript.py @@ -8,29 +8,78 @@ from src.common.command import run_command from src.common.common import PROJECT_ROOT, console, settings -from src.swagger.common import SWAGGER_ROOT, Generator, RunMode +from src.swagger.common import SWAGGER_ROOT, RunMode -class TypeScriptClient(Generator): - """TypeScript client generator using OpenAPI Generator.""" +class TypeScriptSDK: + """TypeScript generator for separate portal/admin audience specs.""" - MODE = RunMode.CLIENT - CLIENT_DIR = PROJECT_ROOT / "client" / "typescript" - CLIENT_GEN_DIR = PROJECT_ROOT / "client" / "typescript-gen" - GENERATOR_CONFIG_DIR = PROJECT_ROOT / ".openapi-generator" / "typescript" / "client" + SDK_ROOT_DIR = PROJECT_ROOT / "sdk" / "typescript" + SDK_GEN_ROOT_DIR = PROJECT_ROOT / "sdk" / "typescript-gen" + GENERATOR_CONFIG_DIR = PROJECT_ROOT / ".openapi-generator" / "typescript" - def __init__(self, version: str) -> None: + def __init__(self, version: str, target: RunMode | None = None) -> None: self.version = version + self.target = target def generate(self) -> None: - _generate_typescript_helper( - self.MODE, - self.version, - self.CLIENT_DIR, - self.CLIENT_GEN_DIR, - self.GENERATOR_CONFIG_DIR, + _cleanup_stale_sdk_root_files(self.SDK_ROOT_DIR) + typescript_settings = settings.sdk.typescript + + audience_packages = { + RunMode.PORTAL: { + "dst_dir": self.SDK_ROOT_DIR / "portal", + "gen_dir": self.SDK_GEN_ROOT_DIR / "portal", + "config_overrides": { + "npmName": typescript_settings.portal.npm_name, + "npmDescription": typescript_settings.portal.npm_description, + }, + }, + RunMode.ADMIN: { + "dst_dir": self.SDK_ROOT_DIR / "admin", + "gen_dir": self.SDK_GEN_ROOT_DIR / "admin", + "config_overrides": { + "npmName": typescript_settings.admin.npm_name, + "npmDescription": typescript_settings.admin.npm_description, + }, + }, + } + + target_modes = ( + [self.target] + if self.target is not None + else [RunMode.PORTAL, RunMode.ADMIN] ) + for mode in target_modes: + spec = audience_packages[mode] + _generate_typescript_helper( + mode, + self.version, + spec["dst_dir"], + spec["gen_dir"], + self.GENERATOR_CONFIG_DIR, + config_overrides=spec["config_overrides"], + ) + + +def _cleanup_stale_sdk_root_files(root_dir: Path) -> None: + """Remove stale flat sdk/typescript files while keeping portal/admin packages.""" + if not root_dir.exists() or not root_dir.is_dir(): + return + + # Skip when the root only acts as a parent directory for portal/admin packages. + if not (root_dir / "package.json").exists(): + return + + for child in root_dir.iterdir(): + if child.name in {"portal", "admin"}: + continue + if child.is_dir(): + shutil.rmtree(child) + continue + child.unlink(missing_ok=True) + def _generate_typescript_helper( mode: RunMode, @@ -38,18 +87,22 @@ def _generate_typescript_helper( dst_dir: Path, gen_dir: Path, generator_config_dir: Path, + config_overrides: dict[str, str] | None = None, ) -> None: """ - Helper function to generate TypeScript client or SDK. + Helper function to generate one TypeScript SDK package. 1. Updates the generator config with the specified version. - 2. Generates the client/SDK using OpenAPI Generator in a Docker container. - 3. Post-processes the generated client/SDK. + 2. Generates the SDK using OpenAPI Generator in a Docker container. + 3. Post-processes the generated SDK. 4. Cleans up temporary directories. """ - if mode not in {RunMode.CLIENT, RunMode.SDK}: - raise ValueError(f"Invalid mode: {mode}. Must be 'client' or 'sdk'.") + if mode not in {RunMode.PORTAL, RunMode.ADMIN}: + raise ValueError(f"Invalid mode: {mode}. Must be 'portal' or 'admin'.") - msg = "Client" if mode == RunMode.CLIENT else "SDK" + if mode == RunMode.PORTAL: + msg = "Portal SDK" + else: + msg = "Admin SDK" # 1. Update generator config with the specified version generator_config = generator_config_dir / "config.json" @@ -57,6 +110,8 @@ def _generate_typescript_helper( config_data = json.load(f) config_data["npmVersion"] = version + if config_overrides: + config_data.update(config_overrides) tmp_generator_config = generator_config_dir / "config_tmp.json" with open(tmp_generator_config, "w") as f: @@ -72,7 +127,7 @@ def _generate_typescript_helper( gen_dir.mkdir(parents=True) - volume_path = Path("/local") + volume_path = Path(settings.openapi.generator_volume_root) relative_swagger = SWAGGER_ROOT.relative_to(PROJECT_ROOT) relative_gen = gen_dir.relative_to(PROJECT_ROOT) relative_generator_config = generator_config_dir.relative_to(PROJECT_ROOT) @@ -118,11 +173,11 @@ def _generate_typescript_helper( tmp_generator_config.unlink(missing_ok=True) console.print( - f"[bold green]✅ Original TypeScript {msg} generated successfully![/bold green]" + f"[bold green]✅ Generated TypeScript {msg} successfully![/bold green]" ) console.print() - # 3. Post-process generated client/SDK + # 3. Post-process generated SDK console.print(f"[bold blue]Step 2: Post-processing generated {msg}...[/bold blue]") # Clean up existing @@ -141,7 +196,7 @@ def _generate_typescript_helper( if gen_dir.exists(): shutil.rmtree(gen_dir) - # 5. Build the TypeScript client/SDK + # 5. Build the TypeScript SDK console.print(f"[bold blue]Step 3: Building TypeScript {msg}...[/bold blue]") # Check if pnpm is available, fallback to npm diff --git a/scripts/command/src/test.py b/scripts/command/src/test.py index fa3803fa..d7e3738f 100644 --- a/scripts/command/src/test.py +++ b/scripts/command/src/test.py @@ -1,6 +1,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed -from src.common.common import ENV, PROJECT_ROOT, console +from src.common.common import ENV, PROJECT_ROOT, console, settings from src.common.helm_cli import HelmCLI, HelmRelease from src.common.kubernetes_manager import ( KubernetesManager, @@ -77,9 +77,7 @@ def _install_helm_releases(env: ENV, k8s_manager: KubernetesManager, is_ci: bool # Install cert-manager (prerequisite for otel-kube-stack) console.print("[bold blue]📦 Installing cert-manager...[/bold blue]") - kubectl_apply( - "https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml" - ) + kubectl_apply(settings.command_urls.cert_manager_manifest_url) if not k8s_manager.watch_deployments_ready( ["cert-manager"], namespace="cert-manager", timeout_seconds=300 @@ -203,6 +201,7 @@ def teardown_env( def _get_helm_releases() -> list[HelmRelease]: """Define all Helm releases for test development.""" + helm_repo_urls = settings.command_urls.helm_repo_urls return [ # Chaos Mesh HelmRelease( @@ -210,7 +209,7 @@ def _get_helm_releases() -> list[HelmRelease]: chart="chaos-mesh/chaos-mesh", namespace="chaos-mesh", repo_name="chaos-mesh", - repo_url="https://charts.chaos-mesh.org", + repo_url=helm_repo_urls.chaos_mesh, version="2.8.0", create_namespace=True, ), @@ -220,7 +219,7 @@ def _get_helm_releases() -> list[HelmRelease]: chart="cilium/cilium", namespace="kube-system", repo_name="cilium", - repo_url="https://helm.cilium.io/", + repo_url=helm_repo_urls.cilium, version="1.18.4", ), # OpenTelemetry Kube Stack @@ -229,7 +228,7 @@ def _get_helm_releases() -> list[HelmRelease]: chart="open-telemetry/opentelemetry-kube-stack", namespace="monitoring", repo_name="open-telemetry", - repo_url="https://open-telemetry.github.io/opentelemetry-helm-charts", + repo_url=helm_repo_urls.open_telemetry, values_file=LOCAL_DEV_DIR / "otel-kube-stack.yaml", create_namespace=True, ), @@ -239,7 +238,7 @@ def _get_helm_releases() -> list[HelmRelease]: chart="clickstack/clickstack", namespace="monitoring", repo_name="clickstack", - repo_url="https://hyperdxio.github.io/helm-charts", + repo_url=helm_repo_urls.clickstack, values_file=LOCAL_DEV_DIR / "click-stack.yaml", ), # OpenTelemetry Demo diff --git a/scripts/command/uv.lock b/scripts/command/uv.lock index 4d45647e..9dc1d89a 100644 --- a/scripts/command/uv.lock +++ b/scripts/command/uv.lock @@ -1148,7 +1148,7 @@ wheels = [ [[package]] name = "rcabench" -version = "1.2.0" +version = "1.2.1" source = { editable = "../../sdk/python" } dependencies = [ { name = "lazy-imports" }, diff --git a/scripts/start.sh b/scripts/start.sh index 4ae79fe7..de193a69 100644 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -4,6 +4,16 @@ set -e # Exit on error # Get ENV_MODE parameter (default: test) ENV_MODE=${1:-test} +CERT_MANAGER_MANIFEST_URL=${CERT_MANAGER_MANIFEST_URL:-"https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml"} +CHAOS_MESH_REPO_URL=${CHAOS_MESH_REPO_URL:-"https://charts.chaos-mesh.org"} +CLICKSTACK_REPO_URL=${CLICKSTACK_REPO_URL:-"https://hyperdxio.github.io/helm-charts"} +OPEN_TELEMETRY_REPO_URL=${OPEN_TELEMETRY_REPO_URL:-"https://open-telemetry.github.io/opentelemetry-helm-charts"} +OTEL_DEMO_REPO_URL=${OTEL_DEMO_REPO_URL:-"https://operationspai.github.io/opentelemetry-demo"} +JUICEFS_REPO_URL=${JUICEFS_REPO_URL:-"https://juicedata.github.io/charts"} +TEST_HTTP_PROXY=${TEST_HTTP_PROXY:-"http://crash:crash@172.18.0.1:7890"} +TEST_HTTPS_PROXY=${TEST_HTTPS_PROXY:-"http://crash:crash@172.18.0.1:7890"} +TEST_NO_PROXY=${TEST_NO_PROXY:-"localhost,127.0.0.1,10.96.0.0/12,172.18.0.0/16,cluster.local,svc"} + echo "Running in $ENV_MODE mode" echo "" @@ -52,7 +62,7 @@ if [ "$ENV_MODE" = "prod" ]; then # Install chaos-mesh echo "Installing Chaos Mesh..." - helm repo add chaos-mesh https://charts.chaos-mesh.org --force-update + helm repo add chaos-mesh "$CHAOS_MESH_REPO_URL" --force-update retry_helm_install 3 helm install chaos-mesh chaos-mesh/chaos-mesh \ --namespace chaos-mesh \ --create-namespace \ @@ -70,7 +80,7 @@ if [ "$ENV_MODE" = "prod" ]; then # Install cert-manager echo "Installing cert-manager..." - kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml + kubectl apply -f "$CERT_MANAGER_MANIFEST_URL" echo "Waiting for cert-manager to be ready..." kubectl wait --for=condition=available --timeout=5m deployment/cert-manager -n cert-manager kubectl wait --for=condition=available --timeout=5m deployment/cert-manager-webhook -n cert-manager @@ -79,7 +89,7 @@ if [ "$ENV_MODE" = "prod" ]; then # Install ClickHouse only (no JuiceFS in prod) echo "Installing ClickHouse stack..." - helm repo add clickstack https://hyperdxio.github.io/helm-charts --force-update + helm repo add clickstack "$CLICKSTACK_REPO_URL" --force-update retry_helm_install 3 helm install clickstack clickstack/clickstack \ --namespace monitoring \ --create-namespace \ @@ -92,7 +102,7 @@ if [ "$ENV_MODE" = "prod" ]; then # Install otel-kube-stack echo "Installing OpenTelemetry Kube Stack..." - helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts --force-update + helm repo add open-telemetry "$OPEN_TELEMETRY_REPO_URL" --force-update retry_helm_install 3 helm install opentelemetry-kube-stack open-telemetry/opentelemetry-kube-stack \ --namespace monitoring \ --create-namespace \ @@ -104,7 +114,7 @@ if [ "$ENV_MODE" = "prod" ]; then # Install otel-demo echo "Installing OpenTelemetry Demo application..." - helm repo add opentelemetry-demo https://operationspai.github.io/opentelemetry-demo --force-update + helm repo add opentelemetry-demo "$OTEL_DEMO_REPO_URL" --force-update retry_helm_install 3 helm install otel-demo0 opentelemetry-demo/opentelemetry-demo \ --namespace otel-demo0 \ --create-namespace \ @@ -123,9 +133,9 @@ else # Create Kind cluster echo "Creating Kind cluster..." - HTTP_PROXY=http://crash:crash@172.18.0.1:7890 \ - HTTPS_PROXY=http://crash:crash@172.18.0.1:7890 \ - NO_PROXY=localhost,127.0.0.1,10.96.0.0/12,172.18.0.0/16,cluster.local,svc \ + HTTP_PROXY="$TEST_HTTP_PROXY" \ + HTTPS_PROXY="$TEST_HTTPS_PROXY" \ + NO_PROXY="$TEST_NO_PROXY" \ kind create cluster --config=manifests/test/kind-config.yaml --name test kubectx kind-test echo "✅ Kind cluster created successfully" @@ -133,7 +143,7 @@ else # Install chaos-mesh echo "Installing Chaos Mesh..." - helm repo add chaos-mesh https://charts.chaos-mesh.org --force-update + helm repo add chaos-mesh "$CHAOS_MESH_REPO_URL" --force-update retry_helm_install 3 helm install chaos-mesh chaos-mesh/chaos-mesh \ --namespace chaos-mesh \ --create-namespace \ @@ -151,7 +161,7 @@ else # Install cert-manager echo "Installing cert-manager..." - kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml + kubectl apply -f "$CERT_MANAGER_MANIFEST_URL" echo "Waiting for cert-manager to be ready..." kubectl wait --for=condition=available --timeout=5m deployment/cert-manager -n cert-manager kubectl wait --for=condition=available --timeout=5m deployment/cert-manager-webhook -n cert-manager @@ -162,7 +172,7 @@ else echo "Installing ClickHouse and JuiceFS CSI Driver in parallel..." ( echo " Installing ClickHouse stack..." - helm repo add clickstack https://hyperdxio.github.io/helm-charts --force-update + helm repo add clickstack "$CLICKSTACK_REPO_URL" --force-update retry_helm_install 3 helm install clickstack clickstack/clickstack \ --namespace monitoring \ --create-namespace \ @@ -175,7 +185,7 @@ else ( echo " Installing JuiceFS CSI Driver..." - helm repo add juicefs https://juicedata.github.io/charts --force-update + helm repo add juicefs "$JUICEFS_REPO_URL" --force-update retry_helm_install 3 helm install juicefs-csi-driver juicefs/juicefs-csi-driver \ --namespace kube-system \ -f manifests/cn_mirror/juicefs-csi-driver.yaml \ @@ -193,7 +203,7 @@ else # Install otel-kube-stack echo "Installing OpenTelemetry Kube Stack..." - helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts --force-update + helm repo add open-telemetry "$OPEN_TELEMETRY_REPO_URL" --force-update retry_helm_install 3 helm install opentelemetry-kube-stack open-telemetry/opentelemetry-kube-stack \ --namespace monitoring \ --create-namespace \ @@ -205,7 +215,7 @@ else # Install otel-demo echo "Installing OpenTelemetry Demo application..." - helm repo add opentelemetry-demo https://operationspai.github.io/opentelemetry-demo --force-update + helm repo add opentelemetry-demo "$OTEL_DEMO_REPO_URL" --force-update retry_helm_install 3 helm install otel-demo0 opentelemetry-demo/opentelemetry-demo \ --namespace otel-demo0 \ --create-namespace \ @@ -218,4 +228,4 @@ fi echo "=============================================" echo "✅ Cluster setup completed successfully!" -echo "=============================================" \ No newline at end of file +echo "=============================================" diff --git a/scripts/test-push.sh b/scripts/test-push.sh index 00dfb99f..29884050 100644 --- a/scripts/test-push.sh +++ b/scripts/test-push.sh @@ -41,4 +41,4 @@ cd "$PROJECT_ROOT" rm -rf sdk/python echo "✅ Cleaned up test server environment" -exit $TEST_RESULT \ No newline at end of file +exit $TEST_RESULT diff --git a/scripts/regression-test.sh b/scripts/test-regression.sh similarity index 98% rename from scripts/regression-test.sh rename to scripts/test-regression.sh index cff5e985..3048842b 100644 --- a/scripts/regression-test.sh +++ b/scripts/test-regression.sh @@ -38,4 +38,4 @@ cd "$PROJECT_ROOT" rm -rf sdk/python/src/rcabench/openapi echo "✅ Cleaned up test server environment" -exit $TEST_RESULT \ No newline at end of file +exit $TEST_RESULT diff --git a/sdk/python/README.md b/sdk/python/README.md index 9ad72885..b859f5c4 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -1,86 +1,93 @@ -# RCABench SDK +# RCABench Python SDK -A Python SDK for interacting with RCABench services. +The SDK exposes two handwritten entry clients on top of the generated OpenAPI package: -## Installation +- `RCABenchClient`: public/business API client authenticated by `Key ID` + `Key Secret` +- `RCABenchRuntimeClient`: runtime-only client authenticated by service token + +Generated OpenAPI code lives under `src/rcabench/openapi`. Handwritten auth/session logic lives under `src/rcabench/client`. -### From PyPI +## Installation ```bash pip install rcabench ``` -### From Source +For local development: ```bash -# Clone the repository -git clone https://github.com/your-username/rcabench.git -cd rcabench/sdk/python - -# Install the package +cd sdk/python pip install -e . ``` -## Building the Package +## Authentication Model -To build the package for distribution: +Secrets are never passed directly in code. The SDK reads credentials from environment variables only. + +### Public Client + +Required environment variables: ```bash -# Install build dependencies -pip install build +export RCABENCH_BASE_URL="http://localhost:8082" +export RCABENCH_KEY_ID="pk_xxx" +export RCABENCH_KEY_SECRET="sk_xxx" +``` -# Build the package -python -m build +`RCABenchClient` exchanges the key pair for a bearer token through the API-key token endpoint, then reuses the authenticated OpenAPI client. -# This will create distribution files in the dist/ directory +### Runtime Client + +Required environment variables: + +```bash +export RCABENCH_BASE_URL="http://localhost:8082" +export RCABENCH_SERVICE_TOKEN="runtime_token_xxx" ``` +`RCABenchRuntimeClient` is intended for managed runtime/wrapper usage. It injects the service token into the generated OpenAPI client directly. + ## Usage +### Public API Client + ```python -from rcabench import RCABenchSDK - -# Initialize the SDK -sdk = RCABenchSDK("http://localhost:8082") - -# Get available algorithms -algorithms = sdk.algorithm.list() -print(algorithms) - -# Submit an injection task -injection_payload = [{ - "duration": 1, - "faultType": 5, - "injectNamespace": "ts", - "injectPod": "ts-preserve-service", - "spec": {"CPULoad": 1, "CPUWorker": 3}, - "benchmark": "clickhouse", -}] -response = sdk.injection.execute(injection_payload) -print(response) - -# Run an algorithm -algorithm_payload = [{ - "benchmark": "clickhouse", - "algorithm": "e-diagnose", - "dataset": "dataset-name", -}] -response = sdk.algorithm.execute(algorithm_payload) -print(response) +from rcabench import RCABenchClient +from rcabench.openapi.api.datasets_api import DatasetsApi + +client = RCABenchClient() +api = DatasetsApi(client.get_client()) + +datasets = api.list_sdk_dataset_samples(page=1, size=10) +print(datasets) +``` + +You may still override `base_url` in code when needed: + +```python +client = RCABenchClient(base_url="http://localhost:8082") ``` -## API Reference +### Runtime API Client + +```python +from rcabench import RCABenchRuntimeClient + +runtime_client = RCABenchRuntimeClient() +api_client = runtime_client.get_client() + +print(api_client.configuration.host) +``` -The SDK provides the following main components: +`RCABenchRuntimeClient` stays as a thin authenticated connector only. Runtime upload/report timing and orchestration semantics belong in the external managed wrapper layer, not in this SDK client. -- `RCABenchSDK`: The main entry point for the SDK - - `algorithm`: For interacting with algorithm endpoints - - `evaluation`: For interacting with evaluation endpoints - - `injection`: For interacting with injection endpoints +## Development -For detailed API documentation, please refer to the code docstrings. +Run type checking only on handwritten SDK code: -## Requirements +```bash +cd sdk/python +uv run --with pyright pyright src/rcabench/client +``` -- Python 3.8 or higher -- `requests` and `aiohttp` libraries +The generated package under `src/rcabench/openapi` is excluded from Pyright. diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index f95a02ff..e1614864 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rcabench" -version = "1.2.0" +version = "1.2.1" description = "RCABench - A comprehensive root cause analysis benchmarking platform for microservices" authors = [ { name = "Lincyaw", email = "814750204@qq.com" }, @@ -61,4 +61,4 @@ exclude = [ # "logs/", "vendor/", "output/", -] +] \ No newline at end of file diff --git a/sdk/python/src/rcabench/__init__.py b/sdk/python/src/rcabench/__init__.py index c68196d1..429f2c71 100644 --- a/sdk/python/src/rcabench/__init__.py +++ b/sdk/python/src/rcabench/__init__.py @@ -1 +1,5 @@ -__version__ = "1.2.0" +__version__ = "1.2.1" + +from rcabench.client import RCABenchClient, RCABenchRuntimeClient + +__all__ = ["RCABenchClient", "RCABenchRuntimeClient", "__version__"] diff --git a/sdk/python/src/rcabench/client/__init__.py b/sdk/python/src/rcabench/client/__init__.py index 57ef9ed3..0c05f51e 100644 --- a/sdk/python/src/rcabench/client/__init__.py +++ b/sdk/python/src/rcabench/client/__init__.py @@ -1,3 +1,4 @@ from rcabench.client.http_client import RCABenchClient +from rcabench.client.runtime_client import RCABenchRuntimeClient -__all__ = ["RCABenchClient"] +__all__ = ["RCABenchClient", "RCABenchRuntimeClient"] diff --git a/sdk/python/src/rcabench/client/base.py b/sdk/python/src/rcabench/client/base.py new file mode 100644 index 00000000..a06c0eb2 --- /dev/null +++ b/sdk/python/src/rcabench/client/base.py @@ -0,0 +1,78 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import ClassVar + +from pydantic import StrictStr + +from rcabench.openapi.api_client import ApiClient +from rcabench.openapi.configuration import Configuration + + +@dataclass(kw_only=True) +class SessionData: + access_token: StrictStr | None = None + api_client: ApiClient | None = None + + +CacheKey = tuple[str, str, str | None] + + +class BaseRCABenchClient(ABC): + """ + Shared authenticated client lifecycle for hand-written RCABench clients. + + Subclasses own: + - auth input resolution + - instance/session cache keys + - _authenticate implementation + """ + + _instances: ClassVar[dict[CacheKey, "BaseRCABenchClient"]] = {} + _sessions: ClassVar[dict[CacheKey, SessionData]] = {} + base_url: str + instance_key: CacheKey + _initialized: bool + + def __enter__(self) -> ApiClient: + if self.instance_key not in self.__class__._sessions or not self._is_session_valid(): + self._authenticate() + return self._get_authenticated_client() + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + pass + + def _is_session_valid(self) -> bool: + session_data = self.__class__._sessions.get(self.instance_key) + if not session_data: + return False + return session_data.access_token is not None + + @abstractmethod + def _authenticate(self) -> None: + raise NotImplementedError + + def _get_authenticated_client(self) -> ApiClient: + if self.instance_key not in self.__class__._sessions or not self._is_session_valid(): + self._authenticate() + + session_data = self.__class__._sessions[self.instance_key] + bearer_token = session_data.access_token + assert bearer_token is not None, "Access token is missing in session data" + + if not session_data.api_client: + auth_config = Configuration( + host=self.base_url, + api_key={"BearerAuth": bearer_token}, + api_key_prefix={"BearerAuth": "Bearer"}, + ) + session_data.api_client = ApiClient(auth_config) + + return session_data.api_client + + def get_client(self) -> ApiClient: + return self._get_authenticated_client() + + @classmethod + def clear_sessions(cls) -> None: + cls._sessions.clear() + cls._instances.clear() diff --git a/sdk/python/src/rcabench/client/http_client.py b/sdk/python/src/rcabench/client/http_client.py index 6ec6c8a3..db7a82f7 100644 --- a/sdk/python/src/rcabench/client/http_client.py +++ b/sdk/python/src/rcabench/client/http_client.py @@ -1,58 +1,42 @@ import os -from dataclasses import dataclass - -from pydantic import StrictStr +import secrets +import time +from hashlib import sha256 +from hmac import new as hmac_new +from typing import ClassVar +from rcabench.client.base import BaseRCABenchClient, CacheKey, SessionData from rcabench.openapi.api.authentication_api import AuthenticationApi from rcabench.openapi.api_client import ApiClient from rcabench.openapi.configuration import Configuration -from rcabench.openapi.models.login_req import LoginReq - -@dataclass(kw_only=True) -class SessionData: - access_token: StrictStr | None = None - api_client: ApiClient | None = None - -class RCABenchClient: +class RCABenchClient(BaseRCABenchClient): """ - RCABench client supporting both username/password and token-based authentication. - - - Token-based auth (for K8s jobs): - client = RCABenchClient(base_url="...", token="...") - or via environment variable RCABENCH_TOKEN + RCABench public client supporting API-key authentication. - - Username/password auth (for interactive use): - client = RCABenchClient(base_url="...", username="...", password="...") - or via environment variables RCABENCH_USERNAME, RCABENCH_PASSWORD + Auth credentials are loaded from environment variables only: + - RCABENCH_BASE_URL or `base_url=...` + - RCABENCH_KEY_ID + - RCABENCH_KEY_SECRET """ - _instances: dict[tuple[str, str, str | None], "RCABenchClient"] = {} - _sessions: dict[tuple[str, str, str | None], SessionData] = {} + _instances: ClassVar[dict[CacheKey, BaseRCABenchClient]] = {} + _sessions: ClassVar[dict[CacheKey, SessionData]] = {} + _token_exchange_path = "/api/v2/auth/api-key/token" def __new__( cls, base_url: str | None = None, - username: str | None = None, - password: str | None = None, - token: str | None = None, ): - # Parse actual configuration values actual_base_url = base_url or os.getenv("RCABENCH_BASE_URL") - actual_token = token or os.getenv("RCABENCH_TOKEN") - actual_username = username or os.getenv("RCABENCH_USERNAME") - actual_password = password or os.getenv("RCABENCH_PASSWORD") + actual_key_id = os.getenv("RCABENCH_KEY_ID") + actual_key_secret = os.getenv("RCABENCH_KEY_SECRET") assert actual_base_url is not None, "base_url or RCABENCH_BASE_URL is not set" - - # Token auth takes precedence over username/password - if actual_token: - instance_key = (actual_base_url, actual_token, None) - else: - assert actual_username is not None, "username or RCABENCH_USERNAME is not set (or use token/RCABENCH_TOKEN)" - assert actual_password is not None, "password or RCABENCH_PASSWORD is not set (or use token/RCABENCH_TOKEN)" - instance_key = (actual_base_url, actual_username, actual_password) + assert actual_key_id is not None, "RCABENCH_KEY_ID is not set" + assert actual_key_secret is not None, "RCABENCH_KEY_SECRET is not set" + instance_key = (actual_base_url, actual_key_id, actual_key_secret) if instance_key not in cls._instances: instance = super().__new__(cls) @@ -64,105 +48,67 @@ def __new__( def __init__( self, base_url: str | None = None, - username: str | None = None, - password: str | None = None, - token: str | None = None, ): - # Avoid duplicate initialization of the same instance if hasattr(self, "_initialized") and self._initialized: return - self.base_url = base_url or os.getenv("RCABENCH_BASE_URL") - self.token = token or os.getenv("RCABENCH_TOKEN") - self.username = username or os.getenv("RCABENCH_USERNAME") - self.password = password or os.getenv("RCABENCH_PASSWORD") - - assert self.base_url is not None, "base_url or RCABENCH_BASE_URL is not set" + actual_base_url = base_url or os.getenv("RCABENCH_BASE_URL") + actual_key_id = os.getenv("RCABENCH_KEY_ID") + actual_key_secret = os.getenv("RCABENCH_KEY_SECRET") - # Token auth takes precedence - if self.token: - self.instance_key = (self.base_url, self.token, None) - else: - assert self.username is not None, "username or RCABENCH_USERNAME is not set (or use token/RCABENCH_TOKEN)" - assert self.password is not None, "password or RCABENCH_PASSWORD is not set (or use token/RCABENCH_TOKEN)" - self.instance_key = (self.base_url, self.username, self.password) + assert actual_base_url is not None, "base_url or RCABENCH_BASE_URL is not set" + assert actual_key_id is not None, "RCABENCH_KEY_ID is not set" + assert actual_key_secret is not None, "RCABENCH_KEY_SECRET is not set" + self.base_url = actual_base_url + self.key_id = actual_key_id + self.key_secret = actual_key_secret + self.instance_key = (self.base_url, self.key_id, self.key_secret) self._initialized = True - def __enter__(self): - # Check if there is already a valid session - if self.instance_key not in self._sessions or not self._is_session_valid(): - self._authenticate() - return self._get_authenticated_client() - - def __exit__(self, exc_type, exc_val, exc_tb): - # Do not close session, maintain singleton state - pass - - def _is_session_valid(self) -> bool: - """Check if the current session is valid""" - session_data = self._sessions.get(self.instance_key) - if not session_data: - return False - - # More complex session validity checks can be added here, such as checking if token is expired - # Currently simply check if access_token exists - return session_data.access_token is not None - def _authenticate(self) -> None: - """Authenticate using either token or username/password""" - if self.token: - # Direct token authentication (for K8s jobs using service tokens) - self._sessions[self.instance_key] = SessionData( - access_token=self.token, - api_client=None, - ) - else: - # Username/password login - self._login() + self._exchange_api_key_token() - def _login(self) -> None: - """Login using username and password""" + def _exchange_api_key_token(self) -> None: config = Configuration(host=self.base_url) with ApiClient(config) as api_client: auth_api = AuthenticationApi(api_client) assert self.base_url is not None - assert self.username is not None - assert self.password is not None - login_request = LoginReq(username=self.username, password=self.password) - response = auth_api.login(request=login_request) + assert self.key_id is not None + assert self.key_secret is not None + timestamp = str(int(time.time())) + nonce = secrets.token_hex(16) + signature = self._sign_api_key_request( + key_secret=self.key_secret, + method="POST", + path=self._token_exchange_path, + timestamp=timestamp, + nonce=nonce, + ) + response = auth_api.exchange_api_key_token( + x_key_id=self.key_id, + x_timestamp=timestamp, + x_nonce=nonce, + x_signature=signature, + ) assert response.data is not None - - # Store session information in class-level cache - self._sessions[self.instance_key] = SessionData( + self.__class__._sessions[self.instance_key] = SessionData( access_token=response.data.token, - api_client=None, # Will be created on demand - ) - - def _get_authenticated_client(self) -> ApiClient: - if self.instance_key not in self._sessions or not self._is_session_valid(): - self._authenticate() - - session_data = self._sessions[self.instance_key] - - # If api_client has not been created or needs to be updated, create a new one - bearer_token = session_data.access_token - assert bearer_token is not None, "Access token is missing in session data" - - if not session_data.api_client: - auth_config = Configuration( - host=self.base_url, - api_key={"BearerAuth": bearer_token}, - api_key_prefix={"BearerAuth": "Bearer"}, + api_client=None, ) - session_data.api_client = ApiClient(auth_config) - - return session_data.api_client - - def get_client(self) -> ApiClient: - return self._get_authenticated_client() - @classmethod - def clear_sessions(cls): - cls._sessions.clear() - cls._instances.clear() + @staticmethod + def _sign_api_key_request( + key_secret: str, + method: str, + path: str, + timestamp: str, + nonce: str, + ) -> str: + body_hash = sha256(b"").hexdigest() + canonical = "\n".join([method.upper(), path, timestamp, nonce, body_hash]) + return hmac_new( + key_secret.encode("utf-8"), + canonical.encode("utf-8"), + sha256, + ).hexdigest() diff --git a/sdk/python/src/rcabench/client/runtime_client.py b/sdk/python/src/rcabench/client/runtime_client.py new file mode 100644 index 00000000..bb7d69fa --- /dev/null +++ b/sdk/python/src/rcabench/client/runtime_client.py @@ -0,0 +1,60 @@ +import os +from typing import ClassVar + +from rcabench.client.base import BaseRCABenchClient, CacheKey, SessionData + + +class RCABenchRuntimeClient(BaseRCABenchClient): + """ + Runtime-only client for managed workloads. + + Auth credentials are loaded from environment variables only: + - RCABENCH_BASE_URL or `base_url=...` + - RCABENCH_SERVICE_TOKEN + """ + + _instances: ClassVar[dict[CacheKey, BaseRCABenchClient]] = {} + _sessions: ClassVar[dict[CacheKey, SessionData]] = {} + + def __new__( + cls, + base_url: str | None = None, + ): + actual_base_url = base_url or os.getenv("RCABENCH_BASE_URL") + actual_service_token = os.getenv("RCABENCH_SERVICE_TOKEN") + + assert actual_base_url is not None, "base_url or RCABENCH_BASE_URL is not set" + assert actual_service_token is not None, "RCABENCH_SERVICE_TOKEN is not set" + + instance_key = (actual_base_url, actual_service_token, None) + + if instance_key not in cls._instances: + instance = super().__new__(cls) + cls._instances[instance_key] = instance + instance._initialized = False + + return cls._instances[instance_key] + + def __init__( + self, + base_url: str | None = None, + ): + if hasattr(self, "_initialized") and self._initialized: + return + + actual_base_url = base_url or os.getenv("RCABENCH_BASE_URL") + actual_service_token = os.getenv("RCABENCH_SERVICE_TOKEN") + + assert actual_base_url is not None, "base_url or RCABENCH_BASE_URL is not set" + assert actual_service_token is not None, "RCABENCH_SERVICE_TOKEN is not set" + + self.base_url = actual_base_url + self.service_token = actual_service_token + self.instance_key = (self.base_url, self.service_token, None) + self._initialized = True + + def _authenticate(self) -> None: + self.__class__._sessions[self.instance_key] = SessionData( + access_token=self.service_token, + api_client=None, + ) diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index 93576a6d..e2313272 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -519,7 +519,7 @@ wheels = [ [[package]] name = "rcabench" -version = "1.1.55" +version = "1.2.1" source = { editable = "." } dependencies = [ { name = "lazy-imports" }, diff --git a/skaffold.yaml b/skaffold.yaml index 2b96c96c..1beada63 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -78,5 +78,5 @@ profiles: hooks: after: - host: - command: ["just", "regression-test"] - os: [darwin, linux] \ No newline at end of file + command: ["just", "test-regression"] + os: [darwin, linux] diff --git a/src/app/app.go b/src/app/app.go new file mode 100644 index 00000000..216a12f4 --- /dev/null +++ b/src/app/app.go @@ -0,0 +1,62 @@ +package app + +import ( + buildkit "aegis/infra/buildkit" + config "aegis/infra/config" + db "aegis/infra/db" + etcd "aegis/infra/etcd" + harbor "aegis/infra/harbor" + helm "aegis/infra/helm" + logger "aegis/infra/logger" + loki "aegis/infra/loki" + redis "aegis/infra/redis" + tracing "aegis/infra/tracing" + + "go.uber.org/fx" +) + +func BaseOptions(confPath string) fx.Option { + return fx.Options( + fx.Supply(config.Params{Path: confPath}), + config.Module, + logger.Module, + ) +} + +func ObserveOptions() fx.Option { + return fx.Options( + loki.Module, + tracing.Module, + ) +} + +func DataOptions() fx.Option { + return fx.Options( + db.Module, + redis.Module, + ) +} + +func CoordinationOptions() fx.Option { + return fx.Options( + etcd.Module, + ) +} + +func BuildInfraOptions() fx.Option { + return fx.Options( + harbor.Module, + helm.Module, + buildkit.Module, + ) +} + +func CommonOptions(confPath string) fx.Option { + return fx.Options( + BaseOptions(confPath), + ObserveOptions(), + DataOptions(), + CoordinationOptions(), + BuildInfraOptions(), + ) +} diff --git a/src/app/both.go b/src/app/both.go new file mode 100644 index 00000000..7b04cde0 --- /dev/null +++ b/src/app/both.go @@ -0,0 +1,11 @@ +package app + +import "go.uber.org/fx" + +func BothOptions(confPath string, port string) fx.Option { + return fx.Options( + CommonOptions(confPath), + RuntimeWorkerStackOptions(), + ProducerHTTPOptions(port), + ) +} diff --git a/src/app/consumer.go b/src/app/consumer.go new file mode 100644 index 00000000..d848eb3d --- /dev/null +++ b/src/app/consumer.go @@ -0,0 +1,11 @@ +package app + +import "go.uber.org/fx" + +func ConsumerOptions(confPath string) fx.Option { + return fx.Options( + CommonOptions(confPath), + RuntimeWorkerStackOptions(), + ExecutionInjectionOwnerModules(), + ) +} diff --git a/src/app/gateway/auth_services.go b/src/app/gateway/auth_services.go new file mode 100644 index 00000000..f19332f8 --- /dev/null +++ b/src/app/gateway/auth_services.go @@ -0,0 +1,137 @@ +package gateway + +import ( + "context" + + auth "aegis/module/auth" + "aegis/utils" +) + +type authIAMClient interface { + Enabled() bool + Login(context.Context, *auth.LoginReq) (*auth.LoginResp, error) + Register(context.Context, *auth.RegisterReq) (*auth.UserInfo, error) + RefreshToken(context.Context, *auth.TokenRefreshReq) (*auth.TokenRefreshResp, error) + Logout(context.Context, *utils.Claims) error + ChangePassword(context.Context, *auth.ChangePasswordReq, int) error + GetProfile(context.Context, int) (*auth.UserProfileResp, error) + CreateAPIKey(context.Context, int, *auth.CreateAPIKeyReq) (*auth.APIKeyWithSecretResp, error) + ListAPIKeys(context.Context, int, *auth.ListAPIKeyReq) (*auth.ListAPIKeyResp, error) + GetAPIKey(context.Context, int, int) (*auth.APIKeyInfo, error) + DeleteAPIKey(context.Context, int, int) error + DisableAPIKey(context.Context, int, int) error + EnableAPIKey(context.Context, int, int) error + RevokeAPIKey(context.Context, int, int) error + RotateAPIKey(context.Context, int, int) (*auth.APIKeyWithSecretResp, error) + ExchangeAPIKeyToken(context.Context, *auth.APIKeyTokenReq, string, string) (*auth.APIKeyTokenResp, error) +} + +type remoteAwareAuthService struct { + auth.HandlerService + iam authIAMClient +} + +func (s remoteAwareAuthService) Login(ctx context.Context, req *auth.LoginReq) (*auth.LoginResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.Login(ctx, req) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareAuthService) Register(ctx context.Context, req *auth.RegisterReq) (*auth.UserInfo, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.Register(ctx, req) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareAuthService) RefreshToken(ctx context.Context, req *auth.TokenRefreshReq) (*auth.TokenRefreshResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.RefreshToken(ctx, req) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareAuthService) Logout(ctx context.Context, claims *utils.Claims) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.Logout(ctx, claims) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareAuthService) ChangePassword(ctx context.Context, req *auth.ChangePasswordReq, userID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.ChangePassword(ctx, req, userID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareAuthService) GetProfile(ctx context.Context, userID int) (*auth.UserProfileResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.GetProfile(ctx, userID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareAuthService) CreateAPIKey(ctx context.Context, userID int, req *auth.CreateAPIKeyReq) (*auth.APIKeyWithSecretResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.CreateAPIKey(ctx, userID, req) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareAuthService) ListAPIKeys(ctx context.Context, userID int, req *auth.ListAPIKeyReq) (*auth.ListAPIKeyResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.ListAPIKeys(ctx, userID, req) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareAuthService) GetAPIKey(ctx context.Context, userID, accessKeyID int) (*auth.APIKeyInfo, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.GetAPIKey(ctx, userID, accessKeyID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareAuthService) DeleteAPIKey(ctx context.Context, userID, accessKeyID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.DeleteAPIKey(ctx, userID, accessKeyID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareAuthService) DisableAPIKey(ctx context.Context, userID, accessKeyID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.DisableAPIKey(ctx, userID, accessKeyID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareAuthService) EnableAPIKey(ctx context.Context, userID, accessKeyID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.EnableAPIKey(ctx, userID, accessKeyID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareAuthService) RevokeAPIKey(ctx context.Context, userID, accessKeyID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.RevokeAPIKey(ctx, userID, accessKeyID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareAuthService) RotateAPIKey(ctx context.Context, userID, accessKeyID int) (*auth.APIKeyWithSecretResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.RotateAPIKey(ctx, userID, accessKeyID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareAuthService) ExchangeAPIKeyToken(ctx context.Context, req *auth.APIKeyTokenReq, method, path string) (*auth.APIKeyTokenResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.ExchangeAPIKeyToken(ctx, req, method, path) + } + return nil, missingRemoteDependency("iam-service") +} diff --git a/src/app/gateway/metric_services.go b/src/app/gateway/metric_services.go new file mode 100644 index 00000000..caf6d5f1 --- /dev/null +++ b/src/app/gateway/metric_services.go @@ -0,0 +1,118 @@ +package gateway + +import ( + "context" + "slices" + + "aegis/consts" + "aegis/dto" + container "aegis/module/container" + metric "aegis/module/metric" +) + +type metricOrchestratorClient interface { + Enabled() bool + GetInjectionMetrics(context.Context, *metric.GetMetricsReq) (*metric.InjectionMetrics, error) + GetExecutionMetrics(context.Context, *metric.GetMetricsReq) (*metric.ExecutionMetrics, error) +} + +type metricResourceClient interface { + Enabled() bool + ListContainers(context.Context, *container.ListContainerReq) (*dto.ListResp[container.ContainerResp], error) +} + +type remoteAwareMetricService struct { + metric.HandlerService + orchestrator metricOrchestratorClient + resource metricResourceClient +} + +func (s remoteAwareMetricService) GetInjectionMetrics(ctx context.Context, req *metric.GetMetricsReq) (*metric.InjectionMetrics, error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + return s.orchestrator.GetInjectionMetrics(ctx, req) + } + return nil, missingRemoteDependency("orchestrator-service") +} + +func (s remoteAwareMetricService) GetExecutionMetrics(ctx context.Context, req *metric.GetMetricsReq) (*metric.ExecutionMetrics, error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + return s.orchestrator.GetExecutionMetrics(ctx, req) + } + return nil, missingRemoteDependency("orchestrator-service") +} + +func (s remoteAwareMetricService) GetAlgorithmMetrics(ctx context.Context, req *metric.GetMetricsReq) (*metric.AlgorithmMetrics, error) { + if s.orchestrator == nil || !s.orchestrator.Enabled() { + return nil, missingRemoteDependency("orchestrator-service") + } + if s.resource == nil || !s.resource.Enabled() { + return nil, missingRemoteDependency("resource-service") + } + + algorithms, err := s.listAlgorithmContainers(ctx, req) + if err != nil { + return nil, err + } + + metrics := &metric.AlgorithmMetrics{ + Algorithms: make([]metric.AlgorithmMetricItem, 0, len(algorithms)), + } + for _, algorithm := range algorithms { + algorithmID := algorithm.ID + executionMetrics, err := s.orchestrator.GetExecutionMetrics(ctx, &metric.GetMetricsReq{ + StartTime: req.StartTime, + EndTime: req.EndTime, + AlgorithmID: &algorithmID, + }) + if err != nil || executionMetrics == nil || executionMetrics.TotalCount == 0 { + continue + } + metrics.Algorithms = append(metrics.Algorithms, metric.AlgorithmMetricItem{ + AlgorithmID: algorithm.ID, + AlgorithmName: algorithm.Name, + ExecutionCount: executionMetrics.TotalCount, + SuccessCount: executionMetrics.SuccessCount, + FailedCount: executionMetrics.FailedCount, + SuccessRate: executionMetrics.SuccessRate, + AvgDuration: executionMetrics.AvgDuration, + }) + } + return metrics, nil +} + +func (s remoteAwareMetricService) listAlgorithmContainers(ctx context.Context, req *metric.GetMetricsReq) ([]container.ContainerResp, error) { + containerType := consts.ContainerTypeAlgorithm + status := consts.CommonEnabled + page := 1 + items := make([]container.ContainerResp, 0) + + for { + resp, err := s.resource.ListContainers(ctx, &container.ListContainerReq{ + PaginationReq: dto.PaginationReq{ + Page: page, + Size: consts.PageSizeXLarge, + }, + Type: &containerType, + Status: &status, + }) + if err != nil { + return nil, err + } + items = append(items, resp.Items...) + if resp.Pagination == nil || page >= resp.Pagination.TotalPages || len(resp.Items) == 0 { + break + } + page++ + } + + if req.AlgorithmID == nil { + return items, nil + } + index := slices.IndexFunc(items, func(item container.ContainerResp) bool { + return item.ID == *req.AlgorithmID + }) + if index < 0 { + return []container.ContainerResp{}, nil + } + return []container.ContainerResp{items[index]}, nil +} diff --git a/src/app/gateway/metric_services_test.go b/src/app/gateway/metric_services_test.go new file mode 100644 index 00000000..61417468 --- /dev/null +++ b/src/app/gateway/metric_services_test.go @@ -0,0 +1,120 @@ +package gateway + +import ( + "context" + "testing" + "time" + + "aegis/consts" + "aegis/dto" + container "aegis/module/container" + metric "aegis/module/metric" +) + +type orchestratorMetricClientStub struct { + injectionReqs []*metric.GetMetricsReq + executionReqs []*metric.GetMetricsReq + injection *metric.InjectionMetrics + execution map[int]metric.ExecutionMetrics + enabled bool +} + +func (s *orchestratorMetricClientStub) Enabled() bool { + return s.enabled +} + +func (s *orchestratorMetricClientStub) GetInjectionMetrics(_ context.Context, req *metric.GetMetricsReq) (*metric.InjectionMetrics, error) { + s.injectionReqs = append(s.injectionReqs, req) + return s.injection, nil +} + +func (s *orchestratorMetricClientStub) GetExecutionMetrics(_ context.Context, req *metric.GetMetricsReq) (*metric.ExecutionMetrics, error) { + s.executionReqs = append(s.executionReqs, req) + if req != nil && req.AlgorithmID != nil { + if metric, ok := s.execution[*req.AlgorithmID]; ok { + result := metric + return &result, nil + } + } + return &metric.ExecutionMetrics{}, nil +} + +type resourceMetricClientStub struct { + responses []*dto.ListResp[container.ContainerResp] + enabled bool + calls int +} + +func (s *resourceMetricClientStub) Enabled() bool { + return s.enabled +} + +func (s *resourceMetricClientStub) ListContainers(_ context.Context, _ *container.ListContainerReq) (*dto.ListResp[container.ContainerResp], error) { + idx := s.calls + s.calls++ + if idx >= len(s.responses) { + return &dto.ListResp[container.ContainerResp]{}, nil + } + return s.responses[idx], nil +} + +func TestRemoteAwareMetricServiceGetInjectionMetricsRemoteOnly(t *testing.T) { + service := remoteAwareMetricService{} + _, err := service.GetInjectionMetrics(context.Background(), &metric.GetMetricsReq{}) + if err == nil { + t.Fatal("GetInjectionMetrics() error = nil, want missing dependency") + } +} + +func TestRemoteAwareMetricServiceGetAlgorithmMetricsBuildsFromRemoteSources(t *testing.T) { + start := time.Now().Add(-time.Hour) + end := time.Now() + orchestrator := &orchestratorMetricClientStub{ + enabled: true, + execution: map[int]metric.ExecutionMetrics{ + 1: {TotalCount: 3, SuccessCount: 2, FailedCount: 1, SuccessRate: 66.7, AvgDuration: 12.5}, + 2: {TotalCount: 0}, + 3: {TotalCount: 5, SuccessCount: 5, FailedCount: 0, SuccessRate: 100, AvgDuration: 8}, + }, + } + resource := &resourceMetricClientStub{ + enabled: true, + responses: []*dto.ListResp[container.ContainerResp]{ + { + Items: []container.ContainerResp{ + {ID: 1, Name: "algo-a", Type: consts.GetContainerTypeName(consts.ContainerTypeAlgorithm)}, + {ID: 2, Name: "algo-b", Type: consts.GetContainerTypeName(consts.ContainerTypeAlgorithm)}, + }, + Pagination: &dto.PaginationInfo{Page: 1, Size: 100, Total: 3, TotalPages: 2}, + }, + { + Items: []container.ContainerResp{ + {ID: 3, Name: "algo-c", Type: consts.GetContainerTypeName(consts.ContainerTypeAlgorithm)}, + }, + Pagination: &dto.PaginationInfo{Page: 2, Size: 100, Total: 3, TotalPages: 2}, + }, + }, + } + + service := remoteAwareMetricService{ + orchestrator: orchestrator, + resource: resource, + } + + resp, err := service.GetAlgorithmMetrics(context.Background(), &metric.GetMetricsReq{ + StartTime: &start, + EndTime: &end, + }) + if err != nil { + t.Fatalf("GetAlgorithmMetrics() error = %v", err) + } + if len(resp.Algorithms) != 2 { + t.Fatalf("GetAlgorithmMetrics() algorithm count = %d, want 2", len(resp.Algorithms)) + } + if resp.Algorithms[0].AlgorithmName != "algo-a" || resp.Algorithms[1].AlgorithmName != "algo-c" { + t.Fatalf("GetAlgorithmMetrics() unexpected algorithms: %+v", resp.Algorithms) + } + if len(orchestrator.executionReqs) != 3 { + t.Fatalf("GetAlgorithmMetrics() execution calls = %d, want 3", len(orchestrator.executionReqs)) + } +} diff --git a/src/app/gateway/middleware_service.go b/src/app/gateway/middleware_service.go new file mode 100644 index 00000000..b8632cff --- /dev/null +++ b/src/app/gateway/middleware_service.go @@ -0,0 +1,80 @@ +package gateway + +import ( + "context" + + "aegis/consts" + "aegis/dto" + "aegis/internalclient/iamclient" + "aegis/middleware" + "aegis/utils" +) + +type remoteAwareMiddlewareService struct { + base middleware.Service + iam *iamclient.Client +} + +func (s remoteAwareMiddlewareService) VerifyToken(ctx context.Context, token string) (*utils.Claims, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.VerifyToken(ctx, token) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareMiddlewareService) VerifyServiceToken(ctx context.Context, token string) (*utils.ServiceClaims, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.VerifyServiceToken(ctx, token) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareMiddlewareService) CheckUserPermission(ctx context.Context, params *dto.CheckPermissionParams) (bool, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.CheckUserPermission(ctx, params) + } + return false, missingRemoteDependency("iam-service") +} + +func (s remoteAwareMiddlewareService) IsUserTeamAdmin(ctx context.Context, userID, teamID int) (bool, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.IsUserTeamAdmin(ctx, userID, teamID) + } + return false, missingRemoteDependency("iam-service") +} + +func (s remoteAwareMiddlewareService) IsUserInTeam(ctx context.Context, userID, teamID int) (bool, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.IsUserInTeam(ctx, userID, teamID) + } + return false, missingRemoteDependency("iam-service") +} + +func (s remoteAwareMiddlewareService) IsTeamPublic(ctx context.Context, teamID int) (bool, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.IsTeamPublic(ctx, teamID) + } + return false, missingRemoteDependency("iam-service") +} + +func (s remoteAwareMiddlewareService) IsUserProjectAdmin(ctx context.Context, userID, projectID int) (bool, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.IsUserProjectAdmin(ctx, userID, projectID) + } + return false, missingRemoteDependency("iam-service") +} + +func (s remoteAwareMiddlewareService) IsUserInProject(ctx context.Context, userID, projectID int) (bool, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.IsUserInProject(ctx, userID, projectID) + } + return false, missingRemoteDependency("iam-service") +} + +func (s remoteAwareMiddlewareService) LogFailedAction(ipAddress, userAgent, action, errorMsg string, duration, userID int, resourceName consts.ResourceName) error { + return s.base.LogFailedAction(ipAddress, userAgent, action, errorMsg, duration, userID, resourceName) +} + +func (s remoteAwareMiddlewareService) LogUserAction(ipAddress, userAgent, action, details string, duration, userID int, resourceName consts.ResourceName) error { + return s.base.LogUserAction(ipAddress, userAgent, action, details, duration, userID, resourceName) +} diff --git a/src/app/gateway/options.go b/src/app/gateway/options.go new file mode 100644 index 00000000..3c2d3940 --- /dev/null +++ b/src/app/gateway/options.go @@ -0,0 +1,179 @@ +package gateway + +import ( + "aegis/app" + chaos "aegis/infra/chaos" + k8s "aegis/infra/k8s" + "aegis/internalclient/iamclient" + "aegis/internalclient/orchestratorclient" + "aegis/internalclient/resourceclient" + "aegis/internalclient/systemclient" + "aegis/middleware" + auth "aegis/module/auth" + chaossystem "aegis/module/chaossystem" + container "aegis/module/container" + dataset "aegis/module/dataset" + evaluation "aegis/module/evaluation" + execution "aegis/module/execution" + group "aegis/module/group" + injection "aegis/module/injection" + label "aegis/module/label" + metric "aegis/module/metric" + notification "aegis/module/notification" + project "aegis/module/project" + rbac "aegis/module/rbac" + system "aegis/module/system" + systemmetric "aegis/module/systemmetric" + task "aegis/module/task" + team "aegis/module/team" + trace "aegis/module/trace" + user "aegis/module/user" + + "go.uber.org/fx" +) + +// Options builds the dedicated api-gateway runtime. +func Options(confPath, port string) fx.Option { + return fx.Options( + app.BaseOptions(confPath), + app.ObserveOptions(), + app.DataOptions(), + app.CoordinationOptions(), + app.BuildInfraOptions(), + chaos.Module, + k8s.Module, + app.ProducerHTTPOptions(port), + app.RequireConfiguredTargets( + "api-gateway", + app.RequiredConfigTarget{Name: "iam-service", PrimaryKey: "clients.iam.target", LegacyKey: "iam.grpc.target"}, + app.RequiredConfigTarget{Name: "orchestrator-service", PrimaryKey: "clients.orchestrator.target", LegacyKey: "orchestrator.grpc.target"}, + app.RequiredConfigTarget{Name: "resource-service", PrimaryKey: "clients.resource.target", LegacyKey: "resource.grpc.target"}, + app.RequiredConfigTarget{Name: "system-service", PrimaryKey: "clients.system.target", LegacyKey: "system.grpc.target"}, + ), + iamclient.Module, + orchestratorclient.Module, + resourceclient.Module, + systemclient.Module, + fx.Decorate(func(local auth.HandlerService, remote *iamclient.Client) auth.HandlerService { + return remoteAwareAuthService{ + HandlerService: local, + iam: remote, + } + }), + fx.Decorate(func(local middleware.Service, remote *iamclient.Client) middleware.Service { + return remoteAwareMiddlewareService{ + base: local, + iam: remote, + } + }), + fx.Decorate(func(local user.HandlerService, remote *iamclient.Client) user.HandlerService { + return remoteAwareUserService{ + HandlerService: local, + iam: remote, + } + }), + fx.Decorate(func(local rbac.HandlerService, remote *iamclient.Client) rbac.HandlerService { + return remoteAwareRBACService{ + HandlerService: local, + iam: remote, + } + }), + fx.Decorate(func(local team.HandlerService, remote *iamclient.Client) team.HandlerService { + return remoteAwareTeamService{ + HandlerService: local, + iam: remote, + } + }), + fx.Decorate(func(local execution.HandlerService, remote *orchestratorclient.Client) execution.HandlerService { + return remoteAwareExecutionService{ + HandlerService: local, + orchestrator: remote, + } + }), + fx.Decorate(func(local injection.HandlerService, remote *orchestratorclient.Client) injection.HandlerService { + return remoteAwareInjectionService{ + HandlerService: local, + orchestrator: remote, + } + }), + fx.Decorate(func(local task.HandlerService, remote *orchestratorclient.Client) task.HandlerService { + return remoteAwareTaskService{ + HandlerService: local, + orchestrator: remote, + } + }), + fx.Decorate(func(local trace.HandlerService, remote *orchestratorclient.Client) trace.HandlerService { + return remoteAwareTraceService{ + HandlerService: local, + orchestrator: remote, + } + }), + fx.Decorate(func(local group.HandlerService, remote *orchestratorclient.Client) group.HandlerService { + return remoteAwareGroupService{ + HandlerService: local, + orchestrator: remote, + } + }), + fx.Decorate(func(local notification.HandlerService, remote *orchestratorclient.Client) notification.HandlerService { + return remoteAwareNotificationService{ + HandlerService: local, + orchestrator: remote, + } + }), + fx.Decorate(func(local project.HandlerService, remote *resourceclient.Client) project.HandlerService { + return remoteAwareProjectService{ + HandlerService: local, + resource: remote, + } + }), + fx.Decorate(func(local container.HandlerService, remote *resourceclient.Client) container.HandlerService { + return remoteAwareContainerService{ + HandlerService: local, + resource: remote, + } + }), + fx.Decorate(func(local dataset.HandlerService, remote *resourceclient.Client) dataset.HandlerService { + return remoteAwareDatasetService{ + HandlerService: local, + resource: remote, + } + }), + fx.Decorate(func(local evaluation.HandlerService, remote *resourceclient.Client) evaluation.HandlerService { + return remoteAwareEvaluationService{ + HandlerService: local, + resource: remote, + } + }), + fx.Decorate(func(local label.HandlerService, remote *resourceclient.Client) label.HandlerService { + return remoteAwareLabelService{ + HandlerService: local, + resource: remote, + } + }), + fx.Decorate(func(local chaossystem.HandlerService, remote *resourceclient.Client) chaossystem.HandlerService { + return remoteAwareChaosSystemService{ + HandlerService: local, + resource: remote, + } + }), + fx.Decorate(func(local metric.HandlerService, orchestrator *orchestratorclient.Client, resource *resourceclient.Client) metric.HandlerService { + return remoteAwareMetricService{ + HandlerService: local, + orchestrator: orchestrator, + resource: resource, + } + }), + fx.Decorate(func(local system.HandlerService, remote *systemclient.Client) system.HandlerService { + return remoteAwareSystemService{ + HandlerService: local, + system: remote, + } + }), + fx.Decorate(func(local systemmetric.HandlerService, remote *systemclient.Client) systemmetric.HandlerService { + return remoteAwareSystemMetricService{ + HandlerService: local, + system: remote, + } + }), + ) +} diff --git a/src/app/gateway/orchestrator_services.go b/src/app/gateway/orchestrator_services.go new file mode 100644 index 00000000..ec2ac977 --- /dev/null +++ b/src/app/gateway/orchestrator_services.go @@ -0,0 +1,339 @@ +package gateway + +import ( + "context" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/internalclient/orchestratorclient" + "aegis/model" + execution "aegis/module/execution" + group "aegis/module/group" + injection "aegis/module/injection" + notification "aegis/module/notification" + task "aegis/module/task" + trace "aegis/module/trace" + + "github.com/gorilla/websocket" + "github.com/redis/go-redis/v9" +) + +type remoteAwareExecutionService struct { + execution.HandlerService + orchestrator *orchestratorclient.Client +} + +func (s remoteAwareExecutionService) SubmitAlgorithmExecution(ctx context.Context, req *execution.SubmitExecutionReq, groupID string, userID int) (*execution.SubmitExecutionResp, error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + return s.orchestrator.SubmitExecution(ctx, req, groupID, userID) + } + return nil, missingRemoteDependency("orchestrator-service") +} + +type remoteAwareInjectionService struct { + injection.HandlerService + orchestrator *orchestratorclient.Client +} + +func (s remoteAwareInjectionService) SubmitFaultInjection(ctx context.Context, req *injection.SubmitInjectionReq, groupID string, userID int, projectID *int) (*injection.SubmitInjectionResp, error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + return s.orchestrator.SubmitFaultInjection(ctx, req, groupID, userID, projectID) + } + return nil, missingRemoteDependency("orchestrator-service") +} + +func (s remoteAwareInjectionService) SubmitDatapackBuilding(ctx context.Context, req *injection.SubmitDatapackBuildingReq, groupID string, userID int, projectID *int) (*injection.SubmitDatapackBuildingResp, error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + return s.orchestrator.SubmitDatapackBuilding(ctx, req, groupID, userID, projectID) + } + return nil, missingRemoteDependency("orchestrator-service") +} + +type taskOrchestratorClient interface { + Enabled() bool + GetTask(context.Context, string) (*task.TaskDetailResp, error) + PollTaskLogs(context.Context, string, time.Time) (*task.TaskLogPollResp, error) + ListTasks(context.Context, *task.ListTaskReq) (*dto.ListResp[task.TaskResp], error) +} + +type traceOrchestratorClient interface { + Enabled() bool + GetTrace(context.Context, string) (*trace.TraceDetailResp, error) + ListTraces(context.Context, *trace.ListTraceReq) (*dto.ListResp[trace.TraceResp], error) + GetTraceStreamAlgorithms(context.Context, string) ([]dto.ContainerVersionItem, error) + ReadTraceStreamMessages(context.Context, string, string, int64, time.Duration) ([]redis.XStream, error) +} + +type remoteAwareTaskService struct { + task.HandlerService + orchestrator taskOrchestratorClient +} + +func (s remoteAwareTaskService) GetDetail(ctx context.Context, taskID string) (*task.TaskDetailResp, error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + return s.orchestrator.GetTask(ctx, taskID) + } + return nil, missingRemoteDependency("orchestrator-service") +} + +func (s remoteAwareTaskService) List(ctx context.Context, req *task.ListTaskReq) (*dto.ListResp[task.TaskResp], error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + return s.orchestrator.ListTasks(ctx, req) + } + return nil, missingRemoteDependency("orchestrator-service") +} + +func (s remoteAwareTaskService) GetForLogStream(ctx context.Context, taskID string) (*model.Task, error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + if _, err := s.orchestrator.GetTask(ctx, taskID); err != nil { + return nil, err + } + return &model.Task{ID: taskID}, nil + } + return nil, missingRemoteDependency("orchestrator-service") +} + +func (s remoteAwareTaskService) StreamLogs(ctx context.Context, conn *websocket.Conn, taskModel *model.Task) { + if s.orchestrator == nil || !s.orchestrator.Enabled() { + writeTaskWSMessage(conn, task.WSLogMessage{ + Type: consts.WSLogTypeError, + Message: missingRemoteDependency("orchestrator-service").Error(), + }) + _ = conn.Close() + return + } + + streamer := remoteTaskLogStreamer{ + conn: conn, + orchestrator: s.orchestrator, + taskID: taskModel.ID, + } + streamer.stream(ctx) +} + +const ( + remoteTaskLogWriteWait = 10 * time.Second + remoteTaskLogPongWait = 60 * time.Second + remoteTaskLogPingPeriod = 54 * time.Second + remoteTaskLogMaxMsgSize = 512 + remoteTaskPollInterval = time.Second + remoteTaskFlushWindow = 5 * time.Second +) + +type remoteTaskLogStreamer struct { + conn *websocket.Conn + orchestrator taskOrchestratorClient + taskID string +} + +func (s remoteTaskLogStreamer) stream(ctx context.Context) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + s.conn.SetReadLimit(remoteTaskLogMaxMsgSize) + _ = s.conn.SetReadDeadline(time.Now().Add(remoteTaskLogPongWait)) + s.conn.SetPongHandler(func(string) error { + _ = s.conn.SetReadDeadline(time.Now().Add(remoteTaskLogPongWait)) + return nil + }) + + go s.readLoop(cancel) + go s.pingLoop(ctx, cancel) + + initial, err := s.orchestrator.PollTaskLogs(ctx, s.taskID, time.Time{}) + if err != nil { + writeTaskWSMessage(s.conn, task.WSLogMessage{ + Type: consts.WSLogTypeError, + Message: err.Error(), + }) + _ = s.conn.Close() + return + } + lastTimestamp := initial.CreatedAt + if len(initial.Logs) > 0 { + writeTaskWSMessage(s.conn, task.WSLogMessage{ + Type: consts.WSLogTypeHistory, + Logs: initial.Logs, + Total: len(initial.Logs), + }) + lastTimestamp = initial.Logs[len(initial.Logs)-1].Timestamp + } + if initial.Terminal { + s.flushTerminalLogs(ctx, lastTimestamp) + return + } + + ticker := time.NewTicker(remoteTaskPollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + resp, err := s.orchestrator.PollTaskLogs(ctx, s.taskID, lastTimestamp) + if err != nil { + writeTaskWSMessage(s.conn, task.WSLogMessage{ + Type: consts.WSLogTypeError, + Message: err.Error(), + }) + return + } + if len(resp.Logs) > 0 { + writeTaskWSMessage(s.conn, task.WSLogMessage{ + Type: consts.WSLogTypeRealtime, + Logs: resp.Logs, + }) + lastTimestamp = resp.Logs[len(resp.Logs)-1].Timestamp + } + if resp.Terminal { + s.flushTerminalLogs(ctx, lastTimestamp) + return + } + } + } +} + +func (s remoteTaskLogStreamer) flushTerminalLogs(ctx context.Context, lastTimestamp time.Time) { + deadline := time.Now().Add(remoteTaskFlushWindow) + for time.Now().Before(deadline) { + resp, err := s.orchestrator.PollTaskLogs(ctx, s.taskID, lastTimestamp) + if err == nil && len(resp.Logs) > 0 { + writeTaskWSMessage(s.conn, task.WSLogMessage{ + Type: consts.WSLogTypeRealtime, + Logs: resp.Logs, + }) + lastTimestamp = resp.Logs[len(resp.Logs)-1].Timestamp + } + time.Sleep(remoteTaskPollInterval) + } + writeTaskWSMessage(s.conn, task.WSLogMessage{ + Type: consts.WSLogTypeEnd, + Message: "task completed", + }) + _ = s.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "task completed"), time.Now().Add(remoteTaskLogWriteWait)) +} + +func (s remoteTaskLogStreamer) readLoop(cancel context.CancelFunc) { + defer cancel() + for { + if _, _, err := s.conn.ReadMessage(); err != nil { + return + } + } +} + +func (s remoteTaskLogStreamer) pingLoop(ctx context.Context, cancel context.CancelFunc) { + ticker := time.NewTicker(remoteTaskLogPingPeriod) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(remoteTaskLogWriteWait)); err != nil { + cancel() + return + } + } + } +} + +func writeTaskWSMessage(conn *websocket.Conn, msg task.WSLogMessage) { + _ = conn.SetWriteDeadline(time.Now().Add(remoteTaskLogWriteWait)) + _ = conn.WriteJSON(msg) +} + +type remoteAwareTraceService struct { + trace.HandlerService + orchestrator traceOrchestratorClient +} + +func (s remoteAwareTraceService) GetTrace(ctx context.Context, traceID string) (*trace.TraceDetailResp, error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + return s.orchestrator.GetTrace(ctx, traceID) + } + return nil, missingRemoteDependency("orchestrator-service") +} + +func (s remoteAwareTraceService) ListTraces(ctx context.Context, req *trace.ListTraceReq) (*dto.ListResp[trace.TraceResp], error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + return s.orchestrator.ListTraces(ctx, req) + } + return nil, missingRemoteDependency("orchestrator-service") +} + +func (s remoteAwareTraceService) GetTraceStreamProcessor(ctx context.Context, traceID string) (*trace.StreamProcessor, error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + algorithms, err := s.orchestrator.GetTraceStreamAlgorithms(ctx, traceID) + if err != nil { + return nil, err + } + return trace.NewStreamProcessor(algorithms), nil + } + return nil, missingRemoteDependency("orchestrator-service") +} + +func (s remoteAwareTraceService) ReadTraceStreamMessages(ctx context.Context, streamKey, lastID string, count int64, block time.Duration) ([]redis.XStream, error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + return s.orchestrator.ReadTraceStreamMessages(ctx, streamKey, lastID, count, block) + } + return nil, missingRemoteDependency("orchestrator-service") +} + +type groupOrchestratorClient interface { + Enabled() bool + GetGroupStats(context.Context, string) (*group.GroupStats, error) + GetGroupTraceCount(context.Context, string) (int, error) + ReadGroupStreamMessages(context.Context, string, string, int64, time.Duration) ([]redis.XStream, error) +} + +type remoteAwareGroupService struct { + group.HandlerService + orchestrator groupOrchestratorClient +} + +func (s remoteAwareGroupService) GetGroupStats(ctx context.Context, req *group.GetGroupStatsReq) (*group.GroupStats, error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + return s.orchestrator.GetGroupStats(ctx, req.GroupID) + } + return nil, missingRemoteDependency("orchestrator-service") +} + +func (s remoteAwareGroupService) NewGroupStreamProcessor(ctx context.Context, groupID string) (*group.GroupStreamProcessor, error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + totalTraces, err := s.orchestrator.GetGroupTraceCount(ctx, groupID) + if err != nil { + return nil, err + } + return group.NewGroupStreamProcessor(totalTraces), nil + } + return nil, missingRemoteDependency("orchestrator-service") +} + +func (s remoteAwareGroupService) ReadGroupStreamMessages(ctx context.Context, streamKey, lastID string, count int64, block time.Duration) ([]redis.XStream, error) { + if s.orchestrator != nil && s.orchestrator.Enabled() { + return s.orchestrator.ReadGroupStreamMessages(ctx, streamKey, lastID, count, block) + } + return nil, missingRemoteDependency("orchestrator-service") +} + +type notificationOrchestratorClient interface { + Enabled() bool + ReadNotificationStreamMessages(context.Context, string, int64, time.Duration) ([]redis.XStream, error) +} + +type remoteAwareNotificationService struct { + notification.HandlerService + orchestrator notificationOrchestratorClient +} + +func (s remoteAwareNotificationService) ReadStreamMessages(ctx context.Context, streamKey, lastID string, count int64, block time.Duration) ([]redis.XStream, error) { + _ = streamKey + if s.orchestrator != nil && s.orchestrator.Enabled() { + return s.orchestrator.ReadNotificationStreamMessages(ctx, lastID, count, block) + } + return nil, missingRemoteDependency("orchestrator-service") +} diff --git a/src/app/gateway/orchestrator_services_test.go b/src/app/gateway/orchestrator_services_test.go new file mode 100644 index 00000000..3e2b332d --- /dev/null +++ b/src/app/gateway/orchestrator_services_test.go @@ -0,0 +1,187 @@ +package gateway + +import ( + "context" + "testing" + "time" + + "aegis/dto" + group "aegis/module/group" + task "aegis/module/task" + trace "aegis/module/trace" + + "github.com/redis/go-redis/v9" +) + +type orchestratorTaskClientStub struct { + enabled bool +} + +func (s *orchestratorTaskClientStub) Enabled() bool { return s.enabled } + +func (s *orchestratorTaskClientStub) GetTask(context.Context, string) (*task.TaskDetailResp, error) { + return &task.TaskDetailResp{TaskResp: task.TaskResp{ID: "task-1"}}, nil +} + +func (s *orchestratorTaskClientStub) PollTaskLogs(context.Context, string, time.Time) (*task.TaskLogPollResp, error) { + return &task.TaskLogPollResp{ + Logs: []dto.LogEntry{{TaskID: "task-1", Line: "hello"}}, + Terminal: true, + State: "completed", + CreatedAt: time.Unix(1710000000, 0), + }, nil +} + +func (s *orchestratorTaskClientStub) ListTasks(context.Context, *task.ListTaskReq) (*dto.ListResp[task.TaskResp], error) { + return &dto.ListResp[task.TaskResp]{Items: []task.TaskResp{{ID: "task-1"}}}, nil +} + +type orchestratorTraceClientStub struct { + enabled bool +} + +func (s *orchestratorTraceClientStub) Enabled() bool { return s.enabled } + +func (s *orchestratorTraceClientStub) GetTrace(context.Context, string) (*trace.TraceDetailResp, error) { + return &trace.TraceDetailResp{TraceResp: trace.TraceResp{ID: "trace-1"}}, nil +} + +func (s *orchestratorTraceClientStub) ListTraces(context.Context, *trace.ListTraceReq) (*dto.ListResp[trace.TraceResp], error) { + return &dto.ListResp[trace.TraceResp]{Items: []trace.TraceResp{{ID: "trace-1"}}}, nil +} + +func (s *orchestratorTraceClientStub) GetTraceStreamAlgorithms(context.Context, string) ([]dto.ContainerVersionItem, error) { + return []dto.ContainerVersionItem{{ContainerName: "algo-a"}}, nil +} + +func (s *orchestratorTraceClientStub) ReadTraceStreamMessages(context.Context, string, string, int64, time.Duration) ([]redis.XStream, error) { + return []redis.XStream{{Stream: "trace:trace-1:log"}}, nil +} + +type orchestratorGroupClientStub struct { + enabled bool +} + +func (s *orchestratorGroupClientStub) Enabled() bool { return s.enabled } + +func (s *orchestratorGroupClientStub) GetGroupStats(context.Context, string) (*group.GroupStats, error) { + return &group.GroupStats{TotalTraces: 2}, nil +} + +func (s *orchestratorGroupClientStub) GetGroupTraceCount(context.Context, string) (int, error) { + return 2, nil +} + +func (s *orchestratorGroupClientStub) ReadGroupStreamMessages(context.Context, string, string, int64, time.Duration) ([]redis.XStream, error) { + return []redis.XStream{{Stream: "group:group-1:log"}}, nil +} + +type orchestratorNotificationClientStub struct { + enabled bool +} + +func (s *orchestratorNotificationClientStub) Enabled() bool { return s.enabled } + +func (s *orchestratorNotificationClientStub) ReadNotificationStreamMessages(context.Context, string, int64, time.Duration) ([]redis.XStream, error) { + return []redis.XStream{{Stream: "notifications:global"}}, nil +} + +func TestRemoteAwareTaskServiceRequiresOrchestrator(t *testing.T) { + service := remoteAwareTaskService{} + if _, err := service.List(context.Background(), &task.ListTaskReq{}); err == nil { + t.Fatal("List() error = nil, want missing dependency") + } +} + +func TestRemoteAwareTaskServiceUsesOrchestratorClient(t *testing.T) { + service := remoteAwareTaskService{orchestrator: &orchestratorTaskClientStub{enabled: true}} + resp, err := service.GetDetail(context.Background(), "task-1") + if err != nil { + t.Fatalf("GetDetail() error = %v", err) + } + if resp.ID != "task-1" { + t.Fatalf("GetDetail() unexpected response: %+v", resp) + } + + task, err := service.GetForLogStream(context.Background(), "task-1") + if err != nil { + t.Fatalf("GetForLogStream() error = %v", err) + } + if task.ID != "task-1" { + t.Fatalf("GetForLogStream() unexpected response: %+v", task) + } +} + +func TestRemoteAwareTraceServiceRequiresOrchestrator(t *testing.T) { + service := remoteAwareTraceService{} + if _, err := service.ListTraces(context.Background(), &trace.ListTraceReq{}); err == nil { + t.Fatal("ListTraces() error = nil, want missing dependency") + } +} + +func TestRemoteAwareTraceServiceUsesOrchestratorClient(t *testing.T) { + service := remoteAwareTraceService{orchestrator: &orchestratorTraceClientStub{enabled: true}} + resp, err := service.GetTrace(context.Background(), "trace-1") + if err != nil { + t.Fatalf("GetTrace() error = %v", err) + } + if resp.ID != "trace-1" { + t.Fatalf("GetTrace() unexpected response: %+v", resp) + } + + processor, err := service.GetTraceStreamProcessor(context.Background(), "trace-1") + if err != nil { + t.Fatalf("GetTraceStreamProcessor() error = %v", err) + } + if processor == nil { + t.Fatal("GetTraceStreamProcessor() = nil") + } +} + +func TestRemoteAwareGroupServiceRequiresOrchestrator(t *testing.T) { + service := remoteAwareGroupService{} + if _, err := service.GetGroupStats(context.Background(), &group.GetGroupStatsReq{ + GroupID: "d7a4ed4b-1c91-4cdb-8af8-5520fa8d0ce0", + }); err == nil { + t.Fatal("GetGroupStats() error = nil, want missing dependency") + } +} + +func TestRemoteAwareGroupServiceUsesOrchestratorClient(t *testing.T) { + service := remoteAwareGroupService{orchestrator: &orchestratorGroupClientStub{enabled: true}} + resp, err := service.GetGroupStats(context.Background(), &group.GetGroupStatsReq{ + GroupID: "d7a4ed4b-1c91-4cdb-8af8-5520fa8d0ce0", + }) + if err != nil { + t.Fatalf("GetGroupStats() error = %v", err) + } + if resp.TotalTraces != 2 { + t.Fatalf("GetGroupStats() unexpected response: %+v", resp) + } + + processor, err := service.NewGroupStreamProcessor(context.Background(), "group-1") + if err != nil { + t.Fatalf("NewGroupStreamProcessor() error = %v", err) + } + if processor == nil { + t.Fatal("NewGroupStreamProcessor() = nil") + } +} + +func TestRemoteAwareNotificationServiceRequiresOrchestrator(t *testing.T) { + service := remoteAwareNotificationService{} + if _, err := service.ReadStreamMessages(context.Background(), "notifications:global", "0", 10, time.Second); err == nil { + t.Fatal("ReadStreamMessages() error = nil, want missing dependency") + } +} + +func TestRemoteAwareNotificationServiceUsesOrchestratorClient(t *testing.T) { + service := remoteAwareNotificationService{orchestrator: &orchestratorNotificationClientStub{enabled: true}} + resp, err := service.ReadStreamMessages(context.Background(), "notifications:global", "0", 10, time.Second) + if err != nil { + t.Fatalf("ReadStreamMessages() error = %v", err) + } + if len(resp) != 1 || resp[0].Stream != "notifications:global" { + t.Fatalf("ReadStreamMessages() unexpected response: %+v", resp) + } +} diff --git a/src/app/gateway/rbac_services.go b/src/app/gateway/rbac_services.go new file mode 100644 index 00000000..158a6600 --- /dev/null +++ b/src/app/gateway/rbac_services.go @@ -0,0 +1,129 @@ +package gateway + +import ( + "context" + + "aegis/dto" + rbac "aegis/module/rbac" +) + +type rbacIAMClient interface { + Enabled() bool + CreateRole(context.Context, *rbac.CreateRoleReq) (*rbac.RoleResp, error) + DeleteRole(context.Context, int) error + GetRole(context.Context, int) (*rbac.RoleDetailResp, error) + ListRoles(context.Context, *rbac.ListRoleReq) (*dto.ListResp[rbac.RoleResp], error) + UpdateRole(context.Context, *rbac.UpdateRoleReq, int) (*rbac.RoleResp, error) + AssignRolePermissions(context.Context, int, []int) error + RemoveRolePermissions(context.Context, int, []int) error + ListUsersFromRole(context.Context, int) ([]rbac.UserListItem, error) + GetPermission(context.Context, int) (*rbac.PermissionDetailResp, error) + ListPermissions(context.Context, *rbac.ListPermissionReq) (*dto.ListResp[rbac.PermissionResp], error) + ListRolesFromPermission(context.Context, int) ([]rbac.RoleResp, error) + GetResource(context.Context, int) (*rbac.ResourceResp, error) + ListResources(context.Context, *rbac.ListResourceReq) (*dto.ListResp[rbac.ResourceResp], error) + ListResourcePermissions(context.Context, int) ([]rbac.PermissionResp, error) +} + +type remoteAwareRBACService struct { + rbac.HandlerService + iam rbacIAMClient +} + +func (s remoteAwareRBACService) CreateRole(ctx context.Context, req *rbac.CreateRoleReq) (*rbac.RoleResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.CreateRole(ctx, req) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareRBACService) DeleteRole(ctx context.Context, roleID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.DeleteRole(ctx, roleID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareRBACService) GetRole(ctx context.Context, roleID int) (*rbac.RoleDetailResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.GetRole(ctx, roleID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareRBACService) ListRoles(ctx context.Context, req *rbac.ListRoleReq) (*dto.ListResp[rbac.RoleResp], error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.ListRoles(ctx, req) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareRBACService) UpdateRole(ctx context.Context, req *rbac.UpdateRoleReq, roleID int) (*rbac.RoleResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.UpdateRole(ctx, req, roleID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareRBACService) AssignRolePermissions(ctx context.Context, permissionIDs []int, roleID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.AssignRolePermissions(ctx, roleID, permissionIDs) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareRBACService) RemoveRolePermissions(ctx context.Context, permissionIDs []int, roleID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.RemoveRolePermissions(ctx, roleID, permissionIDs) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareRBACService) ListUsersFromRole(ctx context.Context, roleID int) ([]rbac.UserListItem, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.ListUsersFromRole(ctx, roleID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareRBACService) GetPermission(ctx context.Context, permissionID int) (*rbac.PermissionDetailResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.GetPermission(ctx, permissionID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareRBACService) ListPermissions(ctx context.Context, req *rbac.ListPermissionReq) (*dto.ListResp[rbac.PermissionResp], error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.ListPermissions(ctx, req) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareRBACService) ListRolesFromPermission(ctx context.Context, permissionID int) ([]rbac.RoleResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.ListRolesFromPermission(ctx, permissionID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareRBACService) GetResource(ctx context.Context, resourceID int) (*rbac.ResourceResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.GetResource(ctx, resourceID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareRBACService) ListResources(ctx context.Context, req *rbac.ListResourceReq) (*dto.ListResp[rbac.ResourceResp], error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.ListResources(ctx, req) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareRBACService) ListResourcePermissions(ctx context.Context, resourceID int) ([]rbac.PermissionResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.ListResourcePermissions(ctx, resourceID) + } + return nil, missingRemoteDependency("iam-service") +} diff --git a/src/app/gateway/remote_required.go b/src/app/gateway/remote_required.go new file mode 100644 index 00000000..cf4ca55a --- /dev/null +++ b/src/app/gateway/remote_required.go @@ -0,0 +1,7 @@ +package gateway + +import "fmt" + +func missingRemoteDependency(name string) error { + return fmt.Errorf("%s remote client is not configured for api-gateway", name) +} diff --git a/src/app/gateway/resource_services.go b/src/app/gateway/resource_services.go new file mode 100644 index 00000000..344f4503 --- /dev/null +++ b/src/app/gateway/resource_services.go @@ -0,0 +1,233 @@ +package gateway + +import ( + "context" + + "aegis/dto" + "aegis/internalclient/resourceclient" + chaossystem "aegis/module/chaossystem" + container "aegis/module/container" + dataset "aegis/module/dataset" + evaluation "aegis/module/evaluation" + label "aegis/module/label" + project "aegis/module/project" +) + +type remoteAwareProjectService struct { + project.HandlerService + resource *resourceclient.Client +} + +func (s remoteAwareProjectService) GetProjectDetail(ctx context.Context, projectID int) (*project.ProjectDetailResp, error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.GetProject(ctx, projectID) + } + return nil, missingRemoteDependency("resource-service") +} + +func (s remoteAwareProjectService) ListProjects(ctx context.Context, req *project.ListProjectReq) (*dto.ListResp[project.ProjectResp], error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.ListProjects(ctx, req) + } + return nil, missingRemoteDependency("resource-service") +} + +type remoteAwareContainerService struct { + container.HandlerService + resource *resourceclient.Client +} + +func (s remoteAwareContainerService) GetContainer(ctx context.Context, containerID int) (*container.ContainerDetailResp, error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.GetContainer(ctx, containerID) + } + return nil, missingRemoteDependency("resource-service") +} + +func (s remoteAwareContainerService) ListContainers(ctx context.Context, req *container.ListContainerReq) (*dto.ListResp[container.ContainerResp], error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.ListContainers(ctx, req) + } + return nil, missingRemoteDependency("resource-service") +} + +type remoteAwareDatasetService struct { + dataset.HandlerService + resource *resourceclient.Client +} + +func (s remoteAwareDatasetService) GetDataset(ctx context.Context, datasetID int) (*dataset.DatasetDetailResp, error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.GetDataset(ctx, datasetID) + } + return nil, missingRemoteDependency("resource-service") +} + +func (s remoteAwareDatasetService) ListDatasets(ctx context.Context, req *dataset.ListDatasetReq) (*dto.ListResp[dataset.DatasetResp], error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.ListDatasets(ctx, req) + } + return nil, missingRemoteDependency("resource-service") +} + +type remoteAwareEvaluationService struct { + evaluation.HandlerService + resource *resourceclient.Client +} + +func (s remoteAwareEvaluationService) ListDatapackEvaluationResults(ctx context.Context, req *evaluation.BatchEvaluateDatapackReq, userID int) (*evaluation.BatchEvaluateDatapackResp, error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.ListDatapackEvaluationResults(ctx, req, userID) + } + return nil, missingRemoteDependency("resource-service") +} + +func (s remoteAwareEvaluationService) ListDatasetEvaluationResults(ctx context.Context, req *evaluation.BatchEvaluateDatasetReq, userID int) (*evaluation.BatchEvaluateDatasetResp, error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.ListDatasetEvaluationResults(ctx, req, userID) + } + return nil, missingRemoteDependency("resource-service") +} + +func (s remoteAwareEvaluationService) ListEvaluations(ctx context.Context, req *evaluation.ListEvaluationReq) (*dto.ListResp[evaluation.EvaluationResp], error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.ListEvaluations(ctx, req) + } + return nil, missingRemoteDependency("resource-service") +} + +func (s remoteAwareEvaluationService) GetEvaluation(ctx context.Context, evaluationID int) (*evaluation.EvaluationResp, error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.GetEvaluation(ctx, evaluationID) + } + return nil, missingRemoteDependency("resource-service") +} + +func (s remoteAwareEvaluationService) DeleteEvaluation(ctx context.Context, evaluationID int) error { + if s.resource != nil && s.resource.Enabled() { + return s.resource.DeleteEvaluation(ctx, evaluationID) + } + return missingRemoteDependency("resource-service") +} + +type remoteAwareLabelService struct { + label.HandlerService + resource labelResourceClient +} + +type labelResourceClient interface { + Enabled() bool + BatchDeleteLabels(context.Context, []int) error + CreateLabel(context.Context, *label.CreateLabelReq) (*label.LabelResp, error) + DeleteLabel(context.Context, int) error + GetLabel(context.Context, int) (*label.LabelDetailResp, error) + ListLabels(context.Context, *label.ListLabelReq) (*dto.ListResp[label.LabelResp], error) + UpdateLabel(context.Context, *label.UpdateLabelReq, int) (*label.LabelResp, error) +} + +func (s remoteAwareLabelService) BatchDelete(ctx context.Context, ids []int) error { + if s.resource != nil && s.resource.Enabled() { + return s.resource.BatchDeleteLabels(ctx, ids) + } + return missingRemoteDependency("resource-service") +} + +func (s remoteAwareLabelService) Create(ctx context.Context, req *label.CreateLabelReq) (*label.LabelResp, error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.CreateLabel(ctx, req) + } + return nil, missingRemoteDependency("resource-service") +} + +func (s remoteAwareLabelService) Delete(ctx context.Context, labelID int) error { + if s.resource != nil && s.resource.Enabled() { + return s.resource.DeleteLabel(ctx, labelID) + } + return missingRemoteDependency("resource-service") +} + +func (s remoteAwareLabelService) GetDetail(ctx context.Context, labelID int) (*label.LabelDetailResp, error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.GetLabel(ctx, labelID) + } + return nil, missingRemoteDependency("resource-service") +} + +func (s remoteAwareLabelService) List(ctx context.Context, req *label.ListLabelReq) (*dto.ListResp[label.LabelResp], error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.ListLabels(ctx, req) + } + return nil, missingRemoteDependency("resource-service") +} + +func (s remoteAwareLabelService) Update(ctx context.Context, req *label.UpdateLabelReq, labelID int) (*label.LabelResp, error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.UpdateLabel(ctx, req, labelID) + } + return nil, missingRemoteDependency("resource-service") +} + +type remoteAwareChaosSystemService struct { + chaossystem.HandlerService + resource chaosSystemResourceClient +} + +type chaosSystemResourceClient interface { + Enabled() bool + ListChaosSystems(context.Context, *chaossystem.ListChaosSystemReq) (*dto.ListResp[chaossystem.ChaosSystemResp], error) + GetChaosSystem(context.Context, int) (*chaossystem.ChaosSystemResp, error) + CreateChaosSystem(context.Context, *chaossystem.CreateChaosSystemReq) (*chaossystem.ChaosSystemResp, error) + UpdateChaosSystem(context.Context, *chaossystem.UpdateChaosSystemReq, int) (*chaossystem.ChaosSystemResp, error) + DeleteChaosSystem(context.Context, int) error + UpsertChaosSystemMetadata(context.Context, int, *chaossystem.BulkUpsertSystemMetadataReq) error + ListChaosSystemMetadata(context.Context, int, string) ([]chaossystem.SystemMetadataResp, error) +} + +func (s remoteAwareChaosSystemService) ListSystems(ctx context.Context, req *chaossystem.ListChaosSystemReq) (*dto.ListResp[chaossystem.ChaosSystemResp], error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.ListChaosSystems(ctx, req) + } + return nil, missingRemoteDependency("resource-service") +} + +func (s remoteAwareChaosSystemService) GetSystem(ctx context.Context, id int) (*chaossystem.ChaosSystemResp, error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.GetChaosSystem(ctx, id) + } + return nil, missingRemoteDependency("resource-service") +} + +func (s remoteAwareChaosSystemService) CreateSystem(ctx context.Context, req *chaossystem.CreateChaosSystemReq) (*chaossystem.ChaosSystemResp, error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.CreateChaosSystem(ctx, req) + } + return nil, missingRemoteDependency("resource-service") +} + +func (s remoteAwareChaosSystemService) UpdateSystem(ctx context.Context, id int, req *chaossystem.UpdateChaosSystemReq) (*chaossystem.ChaosSystemResp, error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.UpdateChaosSystem(ctx, req, id) + } + return nil, missingRemoteDependency("resource-service") +} + +func (s remoteAwareChaosSystemService) DeleteSystem(ctx context.Context, id int) error { + if s.resource != nil && s.resource.Enabled() { + return s.resource.DeleteChaosSystem(ctx, id) + } + return missingRemoteDependency("resource-service") +} + +func (s remoteAwareChaosSystemService) UpsertMetadata(ctx context.Context, id int, req *chaossystem.BulkUpsertSystemMetadataReq) error { + if s.resource != nil && s.resource.Enabled() { + return s.resource.UpsertChaosSystemMetadata(ctx, id, req) + } + return missingRemoteDependency("resource-service") +} + +func (s remoteAwareChaosSystemService) ListMetadata(ctx context.Context, id int, metadataType string) ([]chaossystem.SystemMetadataResp, error) { + if s.resource != nil && s.resource.Enabled() { + return s.resource.ListChaosSystemMetadata(ctx, id, metadataType) + } + return nil, missingRemoteDependency("resource-service") +} diff --git a/src/app/gateway/resource_services_test.go b/src/app/gateway/resource_services_test.go new file mode 100644 index 00000000..2c4947e7 --- /dev/null +++ b/src/app/gateway/resource_services_test.go @@ -0,0 +1,104 @@ +package gateway + +import ( + "context" + "testing" + + "aegis/dto" + chaossystem "aegis/module/chaossystem" + label "aegis/module/label" +) + +type resourceLabelClientStub struct { + enabled bool +} + +func (s *resourceLabelClientStub) Enabled() bool { return s.enabled } + +func (s *resourceLabelClientStub) CreateLabel(context.Context, *label.CreateLabelReq) (*label.LabelResp, error) { + return &label.LabelResp{ID: 3, Key: "env", Value: "prod"}, nil +} + +func (s *resourceLabelClientStub) GetLabel(context.Context, int) (*label.LabelDetailResp, error) { + return &label.LabelDetailResp{LabelResp: label.LabelResp{ID: 3, Key: "env", Value: "prod"}}, nil +} + +func (s *resourceLabelClientStub) ListLabels(context.Context, *label.ListLabelReq) (*dto.ListResp[label.LabelResp], error) { + return &dto.ListResp[label.LabelResp]{Items: []label.LabelResp{{ID: 3, Key: "env", Value: "prod"}}}, nil +} + +func (s *resourceLabelClientStub) UpdateLabel(context.Context, *label.UpdateLabelReq, int) (*label.LabelResp, error) { + return &label.LabelResp{ID: 3, Key: "env", Value: "prod"}, nil +} + +func (s *resourceLabelClientStub) DeleteLabel(context.Context, int) error { return nil } + +func (s *resourceLabelClientStub) BatchDeleteLabels(context.Context, []int) error { return nil } + +func TestRemoteAwareLabelServiceRequiresResource(t *testing.T) { + service := remoteAwareLabelService{} + if _, err := service.List(context.Background(), &label.ListLabelReq{}); err == nil { + t.Fatal("List() error = nil, want missing dependency") + } +} + +func TestRemoteAwareLabelServiceUsesResourceClient(t *testing.T) { + service := remoteAwareLabelService{resource: &resourceLabelClientStub{enabled: true}} + resp, err := service.GetDetail(context.Background(), 3) + if err != nil { + t.Fatalf("GetDetail() error = %v", err) + } + if resp.ID != 3 || resp.Key != "env" { + t.Fatalf("GetDetail() unexpected response: %+v", resp) + } +} + +type resourceChaosSystemClientStub struct { + enabled bool +} + +func (s *resourceChaosSystemClientStub) Enabled() bool { return s.enabled } + +func (s *resourceChaosSystemClientStub) ListChaosSystems(context.Context, *chaossystem.ListChaosSystemReq) (*dto.ListResp[chaossystem.ChaosSystemResp], error) { + return &dto.ListResp[chaossystem.ChaosSystemResp]{Items: []chaossystem.ChaosSystemResp{{ID: 8, Name: "k8s"}}}, nil +} + +func (s *resourceChaosSystemClientStub) GetChaosSystem(context.Context, int) (*chaossystem.ChaosSystemResp, error) { + return &chaossystem.ChaosSystemResp{ID: 8, Name: "k8s"}, nil +} + +func (s *resourceChaosSystemClientStub) CreateChaosSystem(context.Context, *chaossystem.CreateChaosSystemReq) (*chaossystem.ChaosSystemResp, error) { + return &chaossystem.ChaosSystemResp{ID: 8, Name: "k8s"}, nil +} + +func (s *resourceChaosSystemClientStub) UpdateChaosSystem(context.Context, *chaossystem.UpdateChaosSystemReq, int) (*chaossystem.ChaosSystemResp, error) { + return &chaossystem.ChaosSystemResp{ID: 8, Name: "k8s"}, nil +} + +func (s *resourceChaosSystemClientStub) DeleteChaosSystem(context.Context, int) error { return nil } + +func (s *resourceChaosSystemClientStub) UpsertChaosSystemMetadata(context.Context, int, *chaossystem.BulkUpsertSystemMetadataReq) error { + return nil +} + +func (s *resourceChaosSystemClientStub) ListChaosSystemMetadata(context.Context, int, string) ([]chaossystem.SystemMetadataResp, error) { + return []chaossystem.SystemMetadataResp{{ID: 1, SystemName: "k8s"}}, nil +} + +func TestRemoteAwareChaosSystemServiceRequiresResource(t *testing.T) { + service := remoteAwareChaosSystemService{} + if _, err := service.ListSystems(context.Background(), &chaossystem.ListChaosSystemReq{}); err == nil { + t.Fatal("ListSystems() error = nil, want missing dependency") + } +} + +func TestRemoteAwareChaosSystemServiceUsesResourceClient(t *testing.T) { + service := remoteAwareChaosSystemService{resource: &resourceChaosSystemClientStub{enabled: true}} + resp, err := service.GetSystem(context.Background(), 8) + if err != nil { + t.Fatalf("GetSystem() error = %v", err) + } + if resp.ID != 8 || resp.Name != "k8s" { + t.Fatalf("GetSystem() unexpected response: %+v", resp) + } +} diff --git a/src/app/gateway/system_services.go b/src/app/gateway/system_services.go new file mode 100644 index 00000000..b6d31a65 --- /dev/null +++ b/src/app/gateway/system_services.go @@ -0,0 +1,97 @@ +package gateway + +import ( + "context" + + "aegis/dto" + "aegis/internalclient/systemclient" + system "aegis/module/system" + systemmetric "aegis/module/systemmetric" +) + +type remoteAwareSystemService struct { + system.HandlerService + system *systemclient.Client +} + +func (s remoteAwareSystemService) GetHealth(ctx context.Context) (*system.HealthCheckResp, error) { + if s.system != nil && s.system.Enabled() { + return s.system.GetHealth(ctx) + } + return nil, missingRemoteDependency("system-service") +} + +func (s remoteAwareSystemService) GetMetrics(ctx context.Context) (*system.MonitoringMetricsResp, error) { + if s.system != nil && s.system.Enabled() { + return s.system.GetMetrics(ctx) + } + return nil, missingRemoteDependency("system-service") +} + +func (s remoteAwareSystemService) GetSystemInfo(ctx context.Context) (*system.SystemInfo, error) { + if s.system != nil && s.system.Enabled() { + return s.system.GetSystemInfo(ctx) + } + return nil, missingRemoteDependency("system-service") +} + +func (s remoteAwareSystemService) ListNamespaceLocks(ctx context.Context) (*system.ListNamespaceLockResp, error) { + if s.system != nil && s.system.Enabled() { + return s.system.ListNamespaceLocks(ctx) + } + return nil, missingRemoteDependency("system-service") +} + +func (s remoteAwareSystemService) ListQueuedTasks(ctx context.Context) (*system.QueuedTasksResp, error) { + if s.system != nil && s.system.Enabled() { + return s.system.ListQueuedTasks(ctx) + } + return nil, missingRemoteDependency("system-service") +} + +func (s remoteAwareSystemService) GetAuditLog(ctx context.Context, id int) (*system.AuditLogDetailResp, error) { + if s.system != nil && s.system.Enabled() { + return s.system.GetAuditLog(ctx, id) + } + return nil, missingRemoteDependency("system-service") +} + +func (s remoteAwareSystemService) ListAuditLogs(ctx context.Context, req *system.ListAuditLogReq) (*dto.ListResp[system.AuditLogResp], error) { + if s.system != nil && s.system.Enabled() { + return s.system.ListAuditLogs(ctx, req) + } + return nil, missingRemoteDependency("system-service") +} + +func (s remoteAwareSystemService) GetConfig(ctx context.Context, configID int) (*system.ConfigDetailResp, error) { + if s.system != nil && s.system.Enabled() { + return s.system.GetConfig(ctx, configID) + } + return nil, missingRemoteDependency("system-service") +} + +func (s remoteAwareSystemService) ListConfigs(ctx context.Context, req *system.ListConfigReq) (*dto.ListResp[system.ConfigResp], error) { + if s.system != nil && s.system.Enabled() { + return s.system.ListConfigs(ctx, req) + } + return nil, missingRemoteDependency("system-service") +} + +type remoteAwareSystemMetricService struct { + systemmetric.HandlerService + system *systemclient.Client +} + +func (s remoteAwareSystemMetricService) GetSystemMetrics(ctx context.Context) (*systemmetric.SystemMetricsResp, error) { + if s.system != nil && s.system.Enabled() { + return s.system.GetSystemMetrics(ctx) + } + return nil, missingRemoteDependency("system-service") +} + +func (s remoteAwareSystemMetricService) GetSystemMetricsHistory(ctx context.Context) (*systemmetric.SystemMetricsHistoryResp, error) { + if s.system != nil && s.system.Enabled() { + return s.system.GetSystemMetricsHistory(ctx) + } + return nil, missingRemoteDependency("system-service") +} diff --git a/src/app/gateway/team_services.go b/src/app/gateway/team_services.go new file mode 100644 index 00000000..1174e371 --- /dev/null +++ b/src/app/gateway/team_services.go @@ -0,0 +1,97 @@ +package gateway + +import ( + "context" + + "aegis/dto" + team "aegis/module/team" +) + +type teamIAMClient interface { + Enabled() bool + CreateTeam(context.Context, *team.CreateTeamReq, int) (*team.TeamResp, error) + DeleteTeam(context.Context, int) error + GetTeam(context.Context, int) (*team.TeamDetailResp, error) + ListTeams(context.Context, *team.ListTeamReq, int, bool) (*dto.ListResp[team.TeamResp], error) + UpdateTeam(context.Context, *team.UpdateTeamReq, int) (*team.TeamResp, error) + ListTeamProjects(context.Context, *team.TeamProjectListReq, int) (*dto.ListResp[team.TeamProjectItem], error) + AddTeamMember(context.Context, *team.AddTeamMemberReq, int) error + RemoveTeamMember(context.Context, int, int, int) error + UpdateTeamMemberRole(context.Context, *team.UpdateTeamMemberRoleReq, int, int, int) error + ListTeamMembers(context.Context, *team.ListTeamMemberReq, int) (*dto.ListResp[team.TeamMemberResp], error) +} + +type remoteAwareTeamService struct { + team.HandlerService + iam teamIAMClient +} + +func (s remoteAwareTeamService) CreateTeam(ctx context.Context, req *team.CreateTeamReq, userID int) (*team.TeamResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.CreateTeam(ctx, req, userID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareTeamService) DeleteTeam(ctx context.Context, teamID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.DeleteTeam(ctx, teamID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareTeamService) GetTeamDetail(ctx context.Context, teamID int) (*team.TeamDetailResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.GetTeam(ctx, teamID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareTeamService) ListTeams(ctx context.Context, req *team.ListTeamReq, userID int, isAdmin bool) (*dto.ListResp[team.TeamResp], error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.ListTeams(ctx, req, userID, isAdmin) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareTeamService) UpdateTeam(ctx context.Context, req *team.UpdateTeamReq, teamID int) (*team.TeamResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.UpdateTeam(ctx, req, teamID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareTeamService) ListTeamProjects(ctx context.Context, req *team.TeamProjectListReq, teamID int) (*dto.ListResp[team.TeamProjectItem], error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.ListTeamProjects(ctx, req, teamID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareTeamService) AddMember(ctx context.Context, req *team.AddTeamMemberReq, teamID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.AddTeamMember(ctx, req, teamID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareTeamService) RemoveMember(ctx context.Context, teamID, currentUserID, targetUserID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.RemoveTeamMember(ctx, teamID, currentUserID, targetUserID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareTeamService) UpdateMemberRole(ctx context.Context, req *team.UpdateTeamMemberRoleReq, teamID, targetUserID, currentUserID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.UpdateTeamMemberRole(ctx, req, teamID, targetUserID, currentUserID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareTeamService) ListMembers(ctx context.Context, req *team.ListTeamMemberReq, teamID int) (*dto.ListResp[team.TeamMemberResp], error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.ListTeamMembers(ctx, req, teamID) + } + return nil, missingRemoteDependency("iam-service") +} diff --git a/src/app/gateway/team_services_test.go b/src/app/gateway/team_services_test.go new file mode 100644 index 00000000..07201210 --- /dev/null +++ b/src/app/gateway/team_services_test.go @@ -0,0 +1,60 @@ +package gateway + +import ( + "context" + "testing" + + "aegis/dto" + team "aegis/module/team" +) + +type iamTeamClientStub struct { + enabled bool +} + +func (s *iamTeamClientStub) Enabled() bool { return s.enabled } + +func (s *iamTeamClientStub) CreateTeam(context.Context, *team.CreateTeamReq, int) (*team.TeamResp, error) { + return &team.TeamResp{ID: 1, Name: "core"}, nil +} +func (s *iamTeamClientStub) DeleteTeam(context.Context, int) error { return nil } +func (s *iamTeamClientStub) GetTeam(context.Context, int) (*team.TeamDetailResp, error) { + return &team.TeamDetailResp{TeamResp: team.TeamResp{ID: 1, Name: "core"}}, nil +} +func (s *iamTeamClientStub) ListTeams(context.Context, *team.ListTeamReq, int, bool) (*dto.ListResp[team.TeamResp], error) { + return &dto.ListResp[team.TeamResp]{Items: []team.TeamResp{{ID: 1, Name: "core"}}}, nil +} +func (s *iamTeamClientStub) UpdateTeam(context.Context, *team.UpdateTeamReq, int) (*team.TeamResp, error) { + return &team.TeamResp{ID: 1, Name: "core"}, nil +} +func (s *iamTeamClientStub) ListTeamProjects(context.Context, *team.TeamProjectListReq, int) (*dto.ListResp[team.TeamProjectItem], error) { + return &dto.ListResp[team.TeamProjectItem]{}, nil +} +func (s *iamTeamClientStub) AddTeamMember(context.Context, *team.AddTeamMemberReq, int) error { + return nil +} +func (s *iamTeamClientStub) RemoveTeamMember(context.Context, int, int, int) error { return nil } +func (s *iamTeamClientStub) UpdateTeamMemberRole(context.Context, *team.UpdateTeamMemberRoleReq, int, int, int) error { + return nil +} +func (s *iamTeamClientStub) ListTeamMembers(context.Context, *team.ListTeamMemberReq, int) (*dto.ListResp[team.TeamMemberResp], error) { + return &dto.ListResp[team.TeamMemberResp]{}, nil +} + +func TestRemoteAwareTeamServiceRequiresIAM(t *testing.T) { + service := remoteAwareTeamService{} + if _, err := service.ListTeams(context.Background(), &team.ListTeamReq{}, 7, true); err == nil { + t.Fatal("ListTeams() error = nil, want missing dependency") + } +} + +func TestRemoteAwareTeamServiceUsesIAMClient(t *testing.T) { + service := remoteAwareTeamService{iam: &iamTeamClientStub{enabled: true}} + resp, err := service.GetTeamDetail(context.Background(), 1) + if err != nil { + t.Fatalf("GetTeamDetail() error = %v", err) + } + if resp.ID != 1 || resp.Name != "core" { + t.Fatalf("GetTeamDetail() unexpected response: %+v", resp) + } +} diff --git a/src/app/gateway/user_services.go b/src/app/gateway/user_services.go new file mode 100644 index 00000000..10712452 --- /dev/null +++ b/src/app/gateway/user_services.go @@ -0,0 +1,137 @@ +package gateway + +import ( + "context" + + "aegis/dto" + user "aegis/module/user" +) + +type userIAMClient interface { + Enabled() bool + CreateUser(context.Context, *user.CreateUserReq) (*user.UserResp, error) + DeleteUser(context.Context, int) error + GetUser(context.Context, int) (*user.UserDetailResp, error) + ListUsers(context.Context, *user.ListUserReq) (*dto.ListResp[user.UserResp], error) + UpdateUser(context.Context, *user.UpdateUserReq, int) (*user.UserResp, error) + AssignUserRole(context.Context, int, int) error + RemoveUserRole(context.Context, int, int) error + AssignUserPermissions(context.Context, int, *user.AssignUserPermissionReq) error + RemoveUserPermissions(context.Context, int, *user.RemoveUserPermissionReq) error + AssignUserContainer(context.Context, int, int, int) error + RemoveUserContainer(context.Context, int, int) error + AssignUserDataset(context.Context, int, int, int) error + RemoveUserDataset(context.Context, int, int) error + AssignUserProject(context.Context, int, int, int) error + RemoveUserProject(context.Context, int, int) error +} + +type remoteAwareUserService struct { + user.HandlerService + iam userIAMClient +} + +func (s remoteAwareUserService) CreateUser(ctx context.Context, req *user.CreateUserReq) (*user.UserResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.CreateUser(ctx, req) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareUserService) DeleteUser(ctx context.Context, userID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.DeleteUser(ctx, userID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareUserService) GetUserDetail(ctx context.Context, userID int) (*user.UserDetailResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.GetUser(ctx, userID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareUserService) ListUsers(ctx context.Context, req *user.ListUserReq) (*dto.ListResp[user.UserResp], error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.ListUsers(ctx, req) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareUserService) UpdateUser(ctx context.Context, req *user.UpdateUserReq, userID int) (*user.UserResp, error) { + if s.iam != nil && s.iam.Enabled() { + return s.iam.UpdateUser(ctx, req, userID) + } + return nil, missingRemoteDependency("iam-service") +} + +func (s remoteAwareUserService) AssignRole(ctx context.Context, userID, roleID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.AssignUserRole(ctx, userID, roleID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareUserService) RemoveRole(ctx context.Context, userID, roleID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.RemoveUserRole(ctx, userID, roleID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareUserService) AssignPermissions(ctx context.Context, req *user.AssignUserPermissionReq, userID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.AssignUserPermissions(ctx, userID, req) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareUserService) RemovePermissions(ctx context.Context, req *user.RemoveUserPermissionReq, userID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.RemoveUserPermissions(ctx, userID, req) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareUserService) AssignContainer(ctx context.Context, userID, containerID, roleID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.AssignUserContainer(ctx, userID, containerID, roleID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareUserService) RemoveContainer(ctx context.Context, userID, containerID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.RemoveUserContainer(ctx, userID, containerID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareUserService) AssignDataset(ctx context.Context, userID, datasetID, roleID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.AssignUserDataset(ctx, userID, datasetID, roleID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareUserService) RemoveDataset(ctx context.Context, userID, datasetID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.RemoveUserDataset(ctx, userID, datasetID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareUserService) AssignProject(ctx context.Context, userID, projectID, roleID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.AssignUserProject(ctx, userID, projectID, roleID) + } + return missingRemoteDependency("iam-service") +} + +func (s remoteAwareUserService) RemoveProject(ctx context.Context, userID, projectID int) error { + if s.iam != nil && s.iam.Enabled() { + return s.iam.RemoveUserProject(ctx, userID, projectID) + } + return missingRemoteDependency("iam-service") +} diff --git a/src/app/http_modules.go b/src/app/http_modules.go new file mode 100644 index 00000000..cf22dc57 --- /dev/null +++ b/src/app/http_modules.go @@ -0,0 +1,63 @@ +package app + +import ( + auth "aegis/module/auth" + chaossystem "aegis/module/chaossystem" + container "aegis/module/container" + dataset "aegis/module/dataset" + evaluation "aegis/module/evaluation" + execution "aegis/module/execution" + group "aegis/module/group" + injection "aegis/module/injection" + label "aegis/module/label" + metric "aegis/module/metric" + notification "aegis/module/notification" + pedestal "aegis/module/pedestal" + project "aegis/module/project" + ratelimiter "aegis/module/ratelimiter" + rbac "aegis/module/rbac" + sdk "aegis/module/sdk" + system "aegis/module/system" + systemmetric "aegis/module/systemmetric" + task "aegis/module/task" + team "aegis/module/team" + trace "aegis/module/trace" + user "aegis/module/user" + "aegis/router" + + "go.uber.org/fx" +) + +func ExecutionInjectionOwnerModules() fx.Option { + return fx.Options( + execution.Module, + injection.Module, + ) +} + +func ProducerHTTPModules() fx.Option { + return fx.Options( + auth.Module, + chaossystem.Module, + container.Module, + dataset.Module, + evaluation.Module, + ExecutionInjectionOwnerModules(), + group.Module, + label.Module, + metric.Module, + notification.Module, + pedestal.Module, + project.Module, + ratelimiter.Module, + rbac.Module, + sdk.Module, + system.Module, + systemmetric.Module, + task.Module, + team.Module, + trace.Module, + user.Module, + router.Module, + ) +} diff --git a/src/app/iam/options.go b/src/app/iam/options.go new file mode 100644 index 00000000..d3f27d9f --- /dev/null +++ b/src/app/iam/options.go @@ -0,0 +1,35 @@ +package iam + +import ( + "aegis/app" + grpciam "aegis/interface/grpc/iam" + "aegis/internalclient/resourceclient" + "aegis/middleware" + auth "aegis/module/auth" + rbac "aegis/module/rbac" + team "aegis/module/team" + user "aegis/module/user" + + "go.uber.org/fx" +) + +// Options builds the dedicated IAM service runtime. +func Options(confPath string) fx.Option { + return fx.Options( + app.BaseOptions(confPath), + app.ObserveOptions(), + app.DataOptions(), + app.RequireConfiguredTargets( + "iam-service", + app.RequiredConfigTarget{Name: "resource-service", PrimaryKey: "clients.resource.target", LegacyKey: "resource.grpc.target"}, + ), + resourceclient.Module, + team.RemoteProjectReaderOption(), + auth.Module, + rbac.Module, + team.Module, + user.Module, + fx.Provide(middleware.NewService), + grpciam.Module, + ) +} diff --git a/src/app/options.go b/src/app/options.go new file mode 100644 index 00000000..6029b857 --- /dev/null +++ b/src/app/options.go @@ -0,0 +1,13 @@ +package app + +import "strings" + +func normalizeAddr(port string) string { + if port == "" { + return ":8080" + } + if strings.HasPrefix(port, ":") { + return port + } + return ":" + port +} diff --git a/src/app/orchestrator/options.go b/src/app/orchestrator/options.go new file mode 100644 index 00000000..102fdfd6 --- /dev/null +++ b/src/app/orchestrator/options.go @@ -0,0 +1,29 @@ +package orchestrator + +import ( + "aegis/app" + grpcorchestrator "aegis/interface/grpc/orchestrator" + group "aegis/module/group" + metric "aegis/module/metric" + notification "aegis/module/notification" + task "aegis/module/task" + trace "aegis/module/trace" + + "go.uber.org/fx" +) + +// Options builds the dedicated orchestrator service runtime. +func Options(confPath string) fx.Option { + return fx.Options( + app.BaseOptions(confPath), + app.ObserveOptions(), + app.DataOptions(), + app.ExecutionInjectionOwnerModules(), + group.Module, + metric.Module, + notification.Module, + task.Module, + trace.Module, + grpcorchestrator.Module, + ) +} diff --git a/src/app/producer.go b/src/app/producer.go new file mode 100644 index 00000000..f0390511 --- /dev/null +++ b/src/app/producer.go @@ -0,0 +1,66 @@ +package app + +import ( + "context" + + chaos "aegis/infra/chaos" + etcd "aegis/infra/etcd" + k8s "aegis/infra/k8s" + redis "aegis/infra/redis" + httpapi "aegis/interface/http" + commonservice "aegis/service/common" + "aegis/service/initialization" + "aegis/utils" + + "go.uber.org/fx" + "gorm.io/gorm" +) + +func ProducerOptions(confPath string, port string) fx.Option { + return fx.Options( + CommonOptions(confPath), + chaos.Module, + k8s.Module, + ProducerHTTPOptions(port), + ) +} + +func ProducerHTTPOptions(port string) fx.Option { + return fx.Options( + fx.Provide(newProducerInitializer), + fx.Invoke(registerProducerInitialization), + ProducerHTTPModules(), + fx.Supply(httpapi.ServerConfig{Addr: normalizeAddr(port)}), + httpapi.Module, + ) +} + +type ProducerInitializer struct { + etcd *etcd.Gateway + redis *redis.Gateway + db *gorm.DB + StartFunc func(context.Context) error +} + +func newProducerInitializer(etcd *etcd.Gateway, redis *redis.Gateway, db *gorm.DB) *ProducerInitializer { + return &ProducerInitializer{etcd: etcd, redis: redis, db: db} +} + +func (i *ProducerInitializer) start(ctx context.Context) error { + if i.StartFunc != nil { + return i.StartFunc(ctx) + } + if err := initialization.InitializeProducer(i.db, i.redis, commonservice.NewConfigUpdateListener(ctx, i.db, i.etcd)); err != nil { + return err + } + utils.InitValidator() + return nil +} + +func registerProducerInitialization(lc fx.Lifecycle, initializer *ProducerInitializer) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + return initializer.start(ctx) + }, + }) +} diff --git a/src/app/remote_require.go b/src/app/remote_require.go new file mode 100644 index 00000000..3937eb31 --- /dev/null +++ b/src/app/remote_require.go @@ -0,0 +1,58 @@ +package app + +import ( + "context" + "fmt" + "strings" + + "aegis/config" + + "go.uber.org/fx" +) + +type RequiredConfigTarget struct { + Name string + PrimaryKey string + LegacyKey string +} + +func RequireConfiguredTargets(component string, targets ...RequiredConfigTarget) fx.Option { + return fx.Invoke(func(lc fx.Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(context.Context) error { + missing := missingRequiredTargets(targets...) + if len(missing) == 0 { + return nil + } + return fmt.Errorf("%s requires configured internal client targets: %s", component, strings.Join(missing, ", ")) + }, + }) + }) +} + +func missingRequiredTargets(targets ...RequiredConfigTarget) []string { + missing := make([]string, 0) + for _, target := range targets { + if target.PrimaryKey == "" { + continue + } + + primaryValue := strings.TrimSpace(config.GetString(target.PrimaryKey)) + legacyValue := strings.TrimSpace(config.GetString(target.LegacyKey)) + if primaryValue != "" || legacyValue != "" { + continue + } + + label := target.Name + if label == "" { + label = target.PrimaryKey + } + if target.LegacyKey != "" { + label = fmt.Sprintf("%s (%s or %s)", label, target.PrimaryKey, target.LegacyKey) + } else { + label = fmt.Sprintf("%s (%s)", label, target.PrimaryKey) + } + missing = append(missing, label) + } + return missing +} diff --git a/src/app/remote_require_test.go b/src/app/remote_require_test.go new file mode 100644 index 00000000..7f8ca783 --- /dev/null +++ b/src/app/remote_require_test.go @@ -0,0 +1,52 @@ +package app + +import ( + "testing" + + "github.com/spf13/viper" +) + +func TestMissingRequiredTargets(t *testing.T) { + primaryKey := "clients.runtime.target" + legacyKey := "runtime_worker.grpc.target" + + originalPrimary := viper.Get(primaryKey) + originalLegacy := viper.Get(legacyKey) + t.Cleanup(func() { + viper.Set(primaryKey, originalPrimary) + viper.Set(legacyKey, originalLegacy) + }) + + viper.Set(primaryKey, "") + viper.Set(legacyKey, "") + + missing := missingRequiredTargets(RequiredConfigTarget{ + Name: "runtime-worker-service", + PrimaryKey: primaryKey, + LegacyKey: legacyKey, + }) + if len(missing) != 1 { + t.Fatalf("expected 1 missing target, got %d: %v", len(missing), missing) + } + + viper.Set(primaryKey, "127.0.0.1:9094") + missing = missingRequiredTargets(RequiredConfigTarget{ + Name: "runtime-worker-service", + PrimaryKey: primaryKey, + LegacyKey: legacyKey, + }) + if len(missing) != 0 { + t.Fatalf("expected no missing target when primary key is set, got %v", missing) + } + + viper.Set(primaryKey, "") + viper.Set(legacyKey, "127.0.0.1:9094") + missing = missingRequiredTargets(RequiredConfigTarget{ + Name: "runtime-worker-service", + PrimaryKey: primaryKey, + LegacyKey: legacyKey, + }) + if len(missing) != 0 { + t.Fatalf("expected no missing target when legacy key is set, got %v", missing) + } +} diff --git a/src/app/resource/options.go b/src/app/resource/options.go new file mode 100644 index 00000000..91e87547 --- /dev/null +++ b/src/app/resource/options.go @@ -0,0 +1,38 @@ +package resource + +import ( + "aegis/app" + grpcresource "aegis/interface/grpc/resource" + "aegis/internalclient/orchestratorclient" + chaossystem "aegis/module/chaossystem" + container "aegis/module/container" + dataset "aegis/module/dataset" + evaluation "aegis/module/evaluation" + label "aegis/module/label" + project "aegis/module/project" + + "go.uber.org/fx" +) + +// Options builds the dedicated resource service runtime. +func Options(confPath string) fx.Option { + return fx.Options( + app.BaseOptions(confPath), + app.ObserveOptions(), + app.DataOptions(), + app.RequireConfiguredTargets( + "resource-service", + app.RequiredConfigTarget{Name: "orchestrator-service", PrimaryKey: "clients.orchestrator.target", LegacyKey: "orchestrator.grpc.target"}, + ), + orchestratorclient.Module, + evaluation.RemoteQueryOption(), + project.RemoteStatisticsOption(), + chaossystem.Module, + container.Module, + dataset.Module, + evaluation.Module, + label.Module, + project.Module, + grpcresource.Module, + ) +} diff --git a/src/app/runtime/options.go b/src/app/runtime/options.go new file mode 100644 index 00000000..b2e4965a --- /dev/null +++ b/src/app/runtime/options.go @@ -0,0 +1,25 @@ +package runtimeapp + +import ( + "aegis/app" + "aegis/service/consumer" + + "go.uber.org/fx" +) + +// Options builds the dedicated runtime-worker-service runtime. +func Options(confPath string) fx.Option { + return fx.Options( + app.BaseOptions(confPath), + app.ObserveOptions(), + app.DataOptions(), + app.CoordinationOptions(), + app.BuildInfraOptions(), + app.RuntimeWorkerStackOptions(), + consumer.RemoteOwnerOptions(), + app.RequireConfiguredTargets( + "runtime-worker-service", + app.RequiredConfigTarget{Name: "orchestrator-service", PrimaryKey: "clients.orchestrator.target", LegacyKey: "orchestrator.grpc.target"}, + ), + ) +} diff --git a/src/app/runtime_stack.go b/src/app/runtime_stack.go new file mode 100644 index 00000000..8920c2be --- /dev/null +++ b/src/app/runtime_stack.go @@ -0,0 +1,37 @@ +package app + +import ( + chaos "aegis/infra/chaos" + k8s "aegis/infra/k8s" + runtimeinfra "aegis/infra/runtime" + controller "aegis/interface/controller" + grpcruntime "aegis/interface/grpc/runtime" + receiver "aegis/interface/receiver" + worker "aegis/interface/worker" + "aegis/internalclient/orchestratorclient" + "aegis/service/consumer" + + "go.uber.org/fx" +) + +func RuntimeWorkerStackOptions() fx.Option { + return fx.Options( + runtimeinfra.Module, + chaos.Module, + k8s.Module, + orchestratorclient.Module, + fx.Provide( + consumer.NewMonitor, + fx.Annotate(consumer.NewRestartPedestalRateLimiter, fx.ResultTags(`name:"restart_limiter"`)), + fx.Annotate(consumer.NewBuildContainerRateLimiter, fx.ResultTags(`name:"build_limiter"`)), + fx.Annotate(consumer.NewAlgoExecutionRateLimiter, fx.ResultTags(`name:"algo_limiter"`)), + consumer.NewFaultBatchManager, + consumer.NewExecutionOwner, + consumer.NewInjectionOwner, + ), + worker.Module, + controller.Module, + grpcruntime.Module, + receiver.Module, + ) +} diff --git a/src/app/service_entrypoints_test.go b/src/app/service_entrypoints_test.go new file mode 100644 index 00000000..e983dbdb --- /dev/null +++ b/src/app/service_entrypoints_test.go @@ -0,0 +1,390 @@ +package app_test + +import ( + "context" + "fmt" + "net" + "net/http" + "testing" + "time" + + "aegis/app" + gateway "aegis/app/gateway" + iam "aegis/app/iam" + orchestrator "aegis/app/orchestrator" + resource "aegis/app/resource" + runtimeapp "aegis/app/runtime" + system "aegis/app/system" + buildkit "aegis/infra/buildkit" + etcd "aegis/infra/etcd" + harbor "aegis/infra/harbor" + helm "aegis/infra/helm" + k8s "aegis/infra/k8s" + loki "aegis/infra/loki" + redisinfra "aegis/infra/redis" + controllerapi "aegis/interface/controller" + httpapi "aegis/interface/http" + receiverapi "aegis/interface/receiver" + workerapi "aegis/interface/worker" + resourcev1 "aegis/proto/resource/v1" + runtimev1 "aegis/proto/runtime/v1" + systemv1 "aegis/proto/system/v1" + + "github.com/DATA-DOG/go-sqlmock" + goredis "github.com/redis/go-redis/v9" + "github.com/spf13/viper" + clientv3 "go.etcd.io/etcd/client/v3" + "go.opentelemetry.io/otel/sdk/trace" + "go.uber.org/fx" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "k8s.io/client-go/rest" +) + +func newSmokeDB(t *testing.T) (*gorm.DB, func()) { + t.Helper() + + sqlDB, _, err := sqlmock.New() + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return db, func() { + _ = sqlDB.Close() + } +} + +func newDedicatedServiceReplacements(t *testing.T) (fx.Option, func()) { + t.Helper() + + db, cleanupDB := newSmokeDB(t) + redisClient := goredis.NewClient(&goredis.Options{Addr: "127.0.0.1:0"}) + redisGateway := redisinfra.NewGateway(redisClient) + etcdClient := &clientv3.Client{} + etcdGateway := etcd.NewGateway(etcdClient) + traceProvider := trace.NewTracerProvider() + controller := &k8s.Controller{} + k8sGateway := k8s.NewGateway(controller) + + return fx.Replace( + db, + redisGateway, + redisClient, + etcdGateway, + etcdClient, + &loki.Client{}, + traceProvider, + &rest.Config{}, + controller, + k8sGateway, + harbor.NewGateway(), + helm.NewGateway(), + buildkit.NewGateway(), + &app.ProducerInitializer{StartFunc: func(context.Context) error { return nil }}, + &workerapi.Lifecycle{StartFunc: func(context.Context) error { return nil }}, + &controllerapi.Lifecycle{RunFunc: func(context.Context, context.CancelFunc) error { return nil }}, + &receiverapi.Lifecycle{StartFunc: func(context.Context) error { return nil }}, + ), func() { + _ = redisClient.Close() + _ = traceProvider.Shutdown(context.Background()) + cleanupDB() + } +} + +func reserveLoopbackAddr(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen on loopback: %v", err) + } + addr := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatalf("close reserved listener: %v", err) + } + return addr +} + +func setConfigValue(t *testing.T, key string, value any) { + t.Helper() + + original := viper.Get(key) + viper.Set(key, value) + t.Cleanup(func() { + viper.Set(key, original) + }) +} + +func waitForHTTPStatus(t *testing.T, client *http.Client, method, url string, want int) { + t.Helper() + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + req, err := http.NewRequest(method, url, nil) + if err != nil { + t.Fatalf("create request %s %s: %v", method, url, err) + } + + resp, err := client.Do(req) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == want { + return + } + } + time.Sleep(50 * time.Millisecond) + } + + req, _ := http.NewRequest(method, url, nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request %s %s failed: %v", method, url, err) + } + defer func() { + _ = resp.Body.Close() + }() + t.Fatalf("expected %d from %s %s, got %d", want, method, url, resp.StatusCode) +} + +func waitForRuntimePing(t *testing.T, addr string) { + t.Helper() + + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("create runtime grpc client: %v", err) + } + defer func() { + _ = conn.Close() + }() + + client := runtimev1.NewRuntimeServiceClient(conn) + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + resp, err := client.Ping(context.Background(), &runtimev1.PingRequest{}) + if err == nil && resp.GetService() != "" { + return + } + time.Sleep(50 * time.Millisecond) + } + + resp, err := client.GetRuntimeStatus(context.Background(), &runtimev1.RuntimeStatusRequest{}) + if err != nil { + t.Fatalf("runtime grpc request failed: %v", err) + } + if resp.GetService() == "" { + t.Fatalf("runtime status missing service name: %+v", resp) + } +} + +func waitForResourcePing(t *testing.T, addr string) { + t.Helper() + + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("create resource grpc client: %v", err) + } + defer func() { + _ = conn.Close() + }() + + client := resourcev1.NewResourceServiceClient(conn) + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + resp, err := client.Ping(context.Background(), &resourcev1.PingRequest{}) + if err == nil && resp.GetService() != "" { + return + } + time.Sleep(50 * time.Millisecond) + } + + resp, err := client.Ping(context.Background(), &resourcev1.PingRequest{}) + if err != nil { + t.Fatalf("resource grpc request failed: %v", err) + } + if resp.GetService() == "" { + t.Fatalf("resource ping missing service name: %+v", resp) + } +} + +func waitForSystemPing(t *testing.T, addr string) { + t.Helper() + + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("create system grpc client: %v", err) + } + defer func() { + _ = conn.Close() + }() + + client := systemv1.NewSystemServiceClient(conn) + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + resp, err := client.Ping(context.Background(), &systemv1.PingRequest{}) + if err == nil && resp.GetService() != "" { + return + } + time.Sleep(50 * time.Millisecond) + } + + resp, err := client.Ping(context.Background(), &systemv1.PingRequest{}) + if err != nil { + t.Fatalf("system grpc request failed: %v", err) + } + if resp.GetService() == "" { + t.Fatalf("system ping missing service name: %+v", resp) + } +} + +func TestDedicatedServiceOptionsValidate(t *testing.T) { + for _, tc := range []struct { + name string + option fx.Option + }{ + {name: "gateway", option: gateway.Options("..", "0")}, + {name: "runtime", option: runtimeapp.Options("..")}, + {name: "resource", option: resource.Options("..")}, + {name: "system", option: system.Options("..")}, + {name: "iam", option: iam.Options("..")}, + {name: "orchestrator", option: orchestrator.Options("..")}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := fx.ValidateApp(tc.option); err != nil { + t.Fatalf("validate %s app: %v", tc.name, err) + } + }) + } +} + +func TestAPIGatewayStandaloneHTTPIntegrationSmoke(t *testing.T) { + replacements, cleanup := newDedicatedServiceReplacements(t) + defer cleanup() + + setConfigValue(t, "clients.iam.target", reserveLoopbackAddr(t)) + setConfigValue(t, "clients.orchestrator.target", reserveLoopbackAddr(t)) + setConfigValue(t, "clients.resource.target", reserveLoopbackAddr(t)) + setConfigValue(t, "clients.system.target", reserveLoopbackAddr(t)) + + addr := reserveLoopbackAddr(t) + appInstance := fx.New( + gateway.Options("..", "0"), + replacements, + fx.Replace(httpapi.ServerConfig{Addr: addr}), + ) + + startCtx, startCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer startCancel() + if err := appInstance.Start(startCtx); err != nil { + t.Fatalf("gateway app start failed: %v", err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := appInstance.Stop(stopCtx); err != nil { + t.Fatalf("gateway app stop failed: %v", err) + } + }() + + client := &http.Client{Timeout: time.Second} + baseURL := fmt.Sprintf("http://%s", addr) + waitForHTTPStatus(t, client, http.MethodGet, baseURL+"/docs/doc.json", http.StatusOK) + waitForHTTPStatus(t, client, http.MethodGet, baseURL+"/api/v2/system/configs/abc", http.StatusUnauthorized) +} + +func TestRuntimeWorkerStandaloneGRPCIntegrationSmoke(t *testing.T) { + replacements, cleanup := newDedicatedServiceReplacements(t) + defer cleanup() + + setConfigValue(t, "clients.orchestrator.target", reserveLoopbackAddr(t)) + addr := reserveLoopbackAddr(t) + setConfigValue(t, "runtime_worker.grpc.addr", addr) + + appInstance := fx.New( + runtimeapp.Options(".."), + replacements, + ) + + startCtx, startCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer startCancel() + if err := appInstance.Start(startCtx); err != nil { + t.Fatalf("runtime app start failed: %v", err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := appInstance.Stop(stopCtx); err != nil { + t.Fatalf("runtime app stop failed: %v", err) + } + }() + + waitForRuntimePing(t, addr) +} + +func TestResourceServiceStandaloneGRPCIntegrationSmoke(t *testing.T) { + replacements, cleanup := newDedicatedServiceReplacements(t) + defer cleanup() + + setConfigValue(t, "clients.orchestrator.target", reserveLoopbackAddr(t)) + addr := reserveLoopbackAddr(t) + setConfigValue(t, "resource.grpc.addr", addr) + + appInstance := fx.New( + resource.Options(".."), + replacements, + ) + + startCtx, startCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer startCancel() + if err := appInstance.Start(startCtx); err != nil { + t.Fatalf("resource app start failed: %v", err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := appInstance.Stop(stopCtx); err != nil { + t.Fatalf("resource app stop failed: %v", err) + } + }() + + waitForResourcePing(t, addr) +} + +func TestSystemServiceStandaloneGRPCIntegrationSmoke(t *testing.T) { + replacements, cleanup := newDedicatedServiceReplacements(t) + defer cleanup() + + setConfigValue(t, "clients.runtime.target", reserveLoopbackAddr(t)) + addr := reserveLoopbackAddr(t) + setConfigValue(t, "system.grpc.addr", addr) + + appInstance := fx.New( + system.Options(".."), + replacements, + ) + + startCtx, startCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer startCancel() + if err := appInstance.Start(startCtx); err != nil { + t.Fatalf("system app start failed: %v", err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := appInstance.Stop(stopCtx); err != nil { + t.Fatalf("system app stop failed: %v", err) + } + }() + + waitForSystemPing(t, addr) +} diff --git a/src/app/startup_smoke_test.go b/src/app/startup_smoke_test.go new file mode 100644 index 00000000..6690e9f0 --- /dev/null +++ b/src/app/startup_smoke_test.go @@ -0,0 +1,333 @@ +package app + +import ( + "context" + "fmt" + "net" + "net/http" + "sync/atomic" + "testing" + "time" + + buildkit "aegis/infra/buildkit" + etcd "aegis/infra/etcd" + harbor "aegis/infra/harbor" + helm "aegis/infra/helm" + k8s "aegis/infra/k8s" + loki "aegis/infra/loki" + redisinfra "aegis/infra/redis" + controllerapi "aegis/interface/controller" + httpapi "aegis/interface/http" + receiverapi "aegis/interface/receiver" + workerapi "aegis/interface/worker" + + "github.com/DATA-DOG/go-sqlmock" + goredis "github.com/redis/go-redis/v9" + clientv3 "go.etcd.io/etcd/client/v3" + "go.opentelemetry.io/otel/sdk/trace" + "go.uber.org/fx" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "k8s.io/client-go/rest" +) + +type smokeLifecycleSpies struct { + producerStarts int32 + workerStarts int32 + workerStops int32 + controllerStarts int32 + controllerStops int32 + receiverStarts int32 + receiverStops int32 +} + +func newSmokeDB(t *testing.T) (*gorm.DB, func()) { + t.Helper() + + sqlDB, _, err := sqlmock.New() + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + + db, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + + return db, func() { + _ = sqlDB.Close() + } +} + +func newSmokeReplacements(t *testing.T, spies *smokeLifecycleSpies) (fx.Option, func()) { + t.Helper() + + db, cleanupDB := newSmokeDB(t) + redisClient := goredis.NewClient(&goredis.Options{Addr: "127.0.0.1:0"}) + redisGateway := redisinfra.NewGateway(redisClient) + etcdClient := &clientv3.Client{} + etcdGateway := etcd.NewGateway(etcdClient) + traceProvider := trace.NewTracerProvider() + controller := &k8s.Controller{} + k8sGateway := k8s.NewGateway(controller) + + producerInitializer := &ProducerInitializer{StartFunc: func(context.Context) error { + if spies != nil { + atomic.AddInt32(&spies.producerStarts, 1) + } + return nil + }} + workerLifecycle := &workerapi.Lifecycle{ + StartFunc: func(context.Context) error { + if spies != nil { + atomic.AddInt32(&spies.workerStarts, 1) + } + return nil + }, + StopFunc: func() { + if spies != nil { + atomic.AddInt32(&spies.workerStops, 1) + } + }, + } + controllerLifecycle := &controllerapi.Lifecycle{ + RunFunc: func(context.Context, context.CancelFunc) error { + if spies != nil { + atomic.AddInt32(&spies.controllerStarts, 1) + } + return nil + }, + StopFunc: func() { + if spies != nil { + atomic.AddInt32(&spies.controllerStops, 1) + } + }, + } + receiverLifecycle := &receiverapi.Lifecycle{ + StartFunc: func(context.Context) error { + if spies != nil { + atomic.AddInt32(&spies.receiverStarts, 1) + } + return nil + }, + StopFunc: func() { + if spies != nil { + atomic.AddInt32(&spies.receiverStops, 1) + } + }, + } + + return fx.Replace( + db, + redisGateway, + redisClient, + etcdGateway, + etcdClient, + &loki.Client{}, + traceProvider, + &rest.Config{}, + controller, + k8sGateway, + harbor.NewGateway(), + helm.NewGateway(), + buildkit.NewGateway(), + producerInitializer, + workerLifecycle, + controllerLifecycle, + receiverLifecycle, + ), func() { + _ = redisClient.Close() + _ = traceProvider.Shutdown(context.Background()) + cleanupDB() + } +} + +func startAndStopApp(t *testing.T, option fx.Option) { + t.Helper() + + app := fx.New(option) + startCtx, startCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer startCancel() + if err := app.Start(startCtx); err != nil { + t.Fatalf("app start failed: %v", err) + } + + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := app.Stop(stopCtx); err != nil { + t.Fatalf("app stop failed: %v", err) + } +} + +func reserveLoopbackAddr(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen on loopback: %v", err) + } + addr := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatalf("close reserved listener: %v", err) + } + return addr +} + +func waitForHTTPStatus(t *testing.T, client *http.Client, method, url string, want int) { + t.Helper() + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + req, err := http.NewRequest(method, url, nil) + if err != nil { + t.Fatalf("create request %s %s: %v", method, url, err) + } + + resp, err := client.Do(req) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == want { + return + } + } + time.Sleep(50 * time.Millisecond) + } + + req, _ := http.NewRequest(method, url, nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request %s %s failed: %v", method, url, err) + } + defer func() { + _ = resp.Body.Close() + }() + t.Fatalf("expected %d from %s %s, got %d", want, method, url, resp.StatusCode) +} + +func requireLifecycleCallCount(t *testing.T, name string, got *int32, want int32) { + t.Helper() + + if actual := atomic.LoadInt32(got); actual != want { + t.Fatalf("expected %s call count %d, got %d", name, want, actual) + } +} + +func TestProducerOptionsStartStopSmoke(t *testing.T) { + replacements, cleanup := newSmokeReplacements(t, nil) + defer cleanup() + + startAndStopApp(t, fx.Options( + ProducerOptions("..", "0"), + replacements, + )) +} + +func TestConsumerOptionsStartStopSmoke(t *testing.T) { + replacements, cleanup := newSmokeReplacements(t, nil) + defer cleanup() + + startAndStopApp(t, fx.Options( + ConsumerOptions(".."), + replacements, + )) +} + +func TestBothOptionsStartStopSmoke(t *testing.T) { + replacements, cleanup := newSmokeReplacements(t, nil) + defer cleanup() + + startAndStopApp(t, fx.Options( + BothOptions("..", "0"), + replacements, + )) +} + +func TestProducerOptionsHTTPIntegrationSmoke(t *testing.T) { + replacements, cleanup := newSmokeReplacements(t, nil) + defer cleanup() + + addr := reserveLoopbackAddr(t) + app := fx.New( + ProducerOptions("..", "0"), + replacements, + fx.Replace(httpapi.ServerConfig{Addr: addr}), + ) + + startCtx, startCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer startCancel() + if err := app.Start(startCtx); err != nil { + t.Fatalf("app start failed: %v", err) + } + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := app.Stop(stopCtx); err != nil { + t.Fatalf("app stop failed: %v", err) + } + }() + + client := &http.Client{Timeout: time.Second} + baseURL := fmt.Sprintf("http://%s", addr) + waitForHTTPStatus(t, client, http.MethodGet, baseURL+"/docs/doc.json", http.StatusOK) + waitForHTTPStatus(t, client, http.MethodGet, baseURL+"/api/v2/system/configs/abc", http.StatusUnauthorized) +} + +func TestConsumerOptionsLifecycleIntegrationSmoke(t *testing.T) { + spies := &smokeLifecycleSpies{} + replacements, cleanup := newSmokeReplacements(t, spies) + defer cleanup() + + startAndStopApp(t, fx.Options( + ConsumerOptions(".."), + replacements, + )) + + requireLifecycleCallCount(t, "worker start", &spies.workerStarts, 1) + requireLifecycleCallCount(t, "worker stop", &spies.workerStops, 1) + requireLifecycleCallCount(t, "controller start", &spies.controllerStarts, 1) + requireLifecycleCallCount(t, "controller stop", &spies.controllerStops, 1) + requireLifecycleCallCount(t, "receiver start", &spies.receiverStarts, 1) + requireLifecycleCallCount(t, "receiver stop", &spies.receiverStops, 1) +} + +func TestBothOptionsHTTPAndLifecycleIntegrationSmoke(t *testing.T) { + spies := &smokeLifecycleSpies{} + replacements, cleanup := newSmokeReplacements(t, spies) + defer cleanup() + + addr := reserveLoopbackAddr(t) + app := fx.New( + BothOptions("..", "0"), + replacements, + fx.Replace(httpapi.ServerConfig{Addr: addr}), + ) + + startCtx, startCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer startCancel() + if err := app.Start(startCtx); err != nil { + t.Fatalf("app start failed: %v", err) + } + + client := &http.Client{Timeout: time.Second} + baseURL := fmt.Sprintf("http://%s", addr) + waitForHTTPStatus(t, client, http.MethodGet, baseURL+"/docs/doc.json", http.StatusOK) + waitForHTTPStatus(t, client, http.MethodGet, baseURL+"/api/v2/system/configs/abc", http.StatusUnauthorized) + requireLifecycleCallCount(t, "producer start", &spies.producerStarts, 1) + requireLifecycleCallCount(t, "worker start", &spies.workerStarts, 1) + requireLifecycleCallCount(t, "controller start", &spies.controllerStarts, 1) + requireLifecycleCallCount(t, "receiver start", &spies.receiverStarts, 1) + + stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stopCancel() + if err := app.Stop(stopCtx); err != nil { + t.Fatalf("app stop failed: %v", err) + } + + requireLifecycleCallCount(t, "worker stop", &spies.workerStops, 1) + requireLifecycleCallCount(t, "controller stop", &spies.controllerStops, 1) + requireLifecycleCallCount(t, "receiver stop", &spies.receiverStops, 1) +} diff --git a/src/app/startup_validate_test.go b/src/app/startup_validate_test.go new file mode 100644 index 00000000..02d524b4 --- /dev/null +++ b/src/app/startup_validate_test.go @@ -0,0 +1,25 @@ +package app + +import ( + "testing" + + "go.uber.org/fx" +) + +func TestProducerOptionsValidate(t *testing.T) { + if err := fx.ValidateApp(ProducerOptions("..", "0")); err != nil { + t.Fatalf("producer fx graph validation failed: %v", err) + } +} + +func TestConsumerOptionsValidate(t *testing.T) { + if err := fx.ValidateApp(ConsumerOptions("..")); err != nil { + t.Fatalf("consumer fx graph validation failed: %v", err) + } +} + +func TestBothOptionsValidate(t *testing.T) { + if err := fx.ValidateApp(BothOptions("..", "0")); err != nil { + t.Fatalf("both fx graph validation failed: %v", err) + } +} diff --git a/src/app/system/options.go b/src/app/system/options.go new file mode 100644 index 00000000..81842430 --- /dev/null +++ b/src/app/system/options.go @@ -0,0 +1,33 @@ +package system + +import ( + "aegis/app" + k8s "aegis/infra/k8s" + grpcsystem "aegis/interface/grpc/system" + "aegis/internalclient/runtimeclient" + system "aegis/module/system" + systemmetric "aegis/module/systemmetric" + + "go.uber.org/fx" +) + +// Options builds the dedicated system service runtime. +func Options(confPath string) fx.Option { + return fx.Options( + app.BaseOptions(confPath), + app.ObserveOptions(), + app.DataOptions(), + app.CoordinationOptions(), + app.BuildInfraOptions(), + app.RequireConfiguredTargets( + "system-service", + app.RequiredConfigTarget{Name: "runtime-worker-service", PrimaryKey: "clients.runtime.target", LegacyKey: "runtime_worker.grpc.target"}, + ), + system.RemoteRuntimeQueryOption(), + k8s.Module, + runtimeclient.Module, + system.Module, + systemmetric.Module, + grpcsystem.Module, + ) +} diff --git a/src/client/debug/status_registry.go b/src/client/debug/status_registry.go deleted file mode 100644 index 539d6f7b..00000000 --- a/src/client/debug/status_registry.go +++ /dev/null @@ -1,247 +0,0 @@ -package debug - -import ( - "context" - "encoding/json" - "fmt" - "sync" - "sync/atomic" - "time" - - "aegis/client" - "aegis/utils" - - "github.com/redis/go-redis/v9" - "github.com/sirupsen/logrus" -) - -type EntryType string - -const ( - EntryTypeReadOnly EntryType = "readonly" - EntryTypeReadWrite EntryType = "readwrite" - - HistoryKey string = "rcabench:debug:history" - - DefaultHistoryLimit int = 100 -) - -type DebugEntry struct { - Name string `json:"name"` - Description string `json:"description"` - Category string `json:"category"` - Type EntryType `json:"type"` // "readonly", "readwrite", "action", "health_check" - GetFunc func() (any, error) `json:"-"` - SetFunc func(any) error `json:"-"` - AutoFix bool `json:"auto_fix"` // Whether auto-fix is supported -} - -// HistoryEntry operation history -type HistoryEntry struct { - ID string `json:"id"` - Timestamp time.Time `json:"timestamp"` - Action string `json:"action"` - Target string `json:"target"` - OldValue any `json:"old_value,omitempty"` - NewValue any `json:"new_value,omitempty"` - Success bool `json:"success"` - Error string `json:"error,omitempty"` -} - -type DebugRegistry struct { - mu sync.RWMutex - entries map[string]*DebugEntry - - ctx context.Context - cancel context.CancelFunc - - // State variable - debugMode int32 // atomic operation -} - -func NewDebugRegistry() *DebugRegistry { - ctx, cancel := context.WithCancel(context.Background()) - - registry := &DebugRegistry{ - entries: make(map[string]*DebugEntry), - ctx: ctx, - cancel: cancel, - } - registry.registerEntries() - - return registry -} - -func (r *DebugRegistry) Get(name string) (map[string]any, error) { - r.mu.RLock() - entry, exists := r.entries[name] - r.mu.RUnlock() - - if !exists { - return nil, fmt.Errorf("entry %s not found", name) - } - - entryData := utils.StructToMap(entry) - if entry.GetFunc != nil { - value, err := entry.GetFunc() - if err != nil { - entryData["value"] = fmt.Sprintf("Error: %v", err) - entryData["error"] = true - } else { - entryData["value"] = value - entryData["error"] = false - } - } - - return entryData, nil -} - -func (r *DebugRegistry) GetAll() map[string]any { - r.mu.RLock() - defer r.mu.RUnlock() - - result := make(map[string]any) - for name, entry := range r.entries { - entryData := utils.StructToMap(entry) - if entry.GetFunc != nil { - if value, err := entry.GetFunc(); err != nil { - entryData["value"] = fmt.Sprintf("Error: %v", err) - entryData["error"] = true - } else { - entryData["value"] = value - entryData["error"] = false - } - } - - result[name] = entryData - } - - return result -} - -func (r *DebugRegistry) GetHistory(limit int) ([]HistoryEntry, error) { - if limit <= 0 { - limit = DefaultHistoryLimit - } - - streamResult, err := client.GetRedisClient().XRead(r.ctx, &redis.XReadArgs{ - Streams: []string{HistoryKey, "0"}, - Count: int64(limit), - Block: -1, - }).Result() - if err != nil { - return nil, fmt.Errorf("failed to read history from redis: %v", err) - } - - errorTemplate := "invalid or missing '%s' in task payload" - - var history []HistoryEntry - for _, result := range streamResult { - for _, message := range result.Messages { - entry, err := utils.MapToStruct[HistoryEntry](message.Values, "", errorTemplate) - if err != nil { - return nil, fmt.Errorf("failed to parse history entry: %v", err) - } - - history = append(history, *entry) - } - } - - return history, nil -} - -func (r *DebugRegistry) Register(entry *DebugEntry) { - r.mu.Lock() - defer r.mu.Unlock() - r.entries[entry.Name] = entry -} - -func (r *DebugRegistry) Set(name string, value any) error { - r.mu.RLock() - entry, exists := r.entries[name] - r.mu.RUnlock() - - if !exists { - return fmt.Errorf("entry %s not found", name) - } - - if entry.Type == EntryTypeReadOnly { - return fmt.Errorf("entry %s is readonly", name) - } - - if entry.SetFunc == nil { - return fmt.Errorf("set function not implemented for %s", name) - } - - var oldValue any - if entry.GetFunc != nil { - oldValue, _ = entry.GetFunc() - } - - err := entry.SetFunc(value) - r.addHistory(HistoryEntry{ - ID: fmt.Sprintf("%s_%d", name, time.Now().UnixNano()), - Timestamp: time.Now(), - Action: "set", - Target: name, - OldValue: oldValue, - NewValue: value, - Success: err == nil, - Error: func() string { - if err != nil { - return err.Error() - } - return "" - }(), - }) - - return err -} - -func (r *DebugRegistry) addHistory(entry HistoryEntry) { - entryJSON, err := json.Marshal(entry) - if err != nil { - return - } - - _, err = client.GetRedisClient().XAdd(r.ctx, &redis.XAddArgs{ - Stream: HistoryKey, - MaxLen: 10000, - Approx: true, - ID: "*", - Values: entryJSON, - }).Result() - if err != nil { - logrus.Errorf("failed to add event to Redis stream %s: %v", HistoryKey, err) - } -} - -func (r *DebugRegistry) registerEntries() { - r.Register(&DebugEntry{ - Name: "debug_mode", - Description: "Debug mode status", - Category: "system", - Type: EntryTypeReadWrite, - GetFunc: func() (any, error) { - return atomic.LoadInt32(&r.debugMode) == 1, nil - }, - SetFunc: func(value any) error { - var newValue int32 - switch v := value.(type) { - case bool: - if v { - newValue = 1 - } - case string: - if v == "true" || v == "1" { - newValue = 1 - } - default: - return fmt.Errorf("invalid value type: %T", value) - } - - atomic.StoreInt32(&r.debugMode, newValue) - return nil - }, - }) -} diff --git a/src/client/etcd_client.go b/src/client/etcd_client.go deleted file mode 100644 index c4e214c1..00000000 --- a/src/client/etcd_client.go +++ /dev/null @@ -1,160 +0,0 @@ -package client - -import ( - "context" - "fmt" - "sync" - "time" - - "aegis/config" - - "github.com/sirupsen/logrus" - clientv3 "go.etcd.io/etcd/client/v3" -) - -// Singleton pattern etcd client -var ( - etcdClient *clientv3.Client - etcdOnce sync.Once -) - -// GetEtcdClient returns the singleton etcd client instance -// It initializes the client on first call using configuration from config package -func GetEtcdClient() *clientv3.Client { - etcdOnce.Do(func() { - endpoints := config.GetStringSlice("etcd.endpoints") - if len(endpoints) == 0 { - endpoints = []string{"localhost:2379"} - logrus.Warn("etcd.endpoints not configured, using default: localhost:2379") - } - - logrus.Infof("Connecting to etcd endpoints: %v", endpoints) - - var err error - etcdClient, err = clientv3.New(clientv3.Config{ - Endpoints: endpoints, - DialTimeout: 5 * time.Second, - Username: config.GetString("etcd.username"), - Password: config.GetString("etcd.password"), - }) - if err != nil { - logrus.Fatalf("Failed to connect to etcd: %v", err) - } - - // Test connection - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - if _, err := etcdClient.Status(ctx, endpoints[0]); err != nil { - logrus.Fatalf("Failed to verify etcd connection: %v", err) - } - - logrus.Info("Successfully connected to etcd") - }) - return etcdClient -} - -// CloseEtcdClient closes the etcd client connection -// Should be called during application shutdown -func CloseEtcdClient() error { - if etcdClient != nil { - logrus.Info("Closing etcd client connection") - return etcdClient.Close() - } - return nil -} - -// EtcdPut writes a key-value pair to etcd with optional TTL -func EtcdPut(ctx context.Context, key, value string, ttl time.Duration) error { - client := GetEtcdClient() - - if ttl > 0 { - // Create lease for TTL - lease, err := client.Grant(ctx, int64(ttl.Seconds())) - if err != nil { - return fmt.Errorf("failed to create lease: %w", err) - } - - _, err = client.Put(ctx, key, value, clientv3.WithLease(lease.ID)) - if err != nil { - return fmt.Errorf("failed to put key with lease: %w", err) - } - } else { - _, err := client.Put(ctx, key, value) - if err != nil { - return fmt.Errorf("failed to put key: %w", err) - } - } - - return nil -} - -// EtcdGet retrieves a value from etcd by key -func EtcdGet(ctx context.Context, key string) (string, error) { - client := GetEtcdClient() - - resp, err := client.Get(ctx, key) - if err != nil { - return "", fmt.Errorf("failed to get key: %w", err) - } - - if len(resp.Kvs) == 0 { - return "", fmt.Errorf("key not found: %s", key) - } - - return string(resp.Kvs[0].Value), nil -} - -// EtcdDelete deletes a key from etcd -func EtcdDelete(ctx context.Context, key string) error { - client := GetEtcdClient() - - _, err := client.Delete(ctx, key) - if err != nil { - return fmt.Errorf("failed to delete key: %w", err) - } - - return nil -} - -// EtcdWatch watches for changes on a key or prefix -// Returns a channel that receives watch events -func EtcdWatch(ctx context.Context, key string, withPrefix bool) clientv3.WatchChan { - client := GetEtcdClient() - - var opts []clientv3.OpOption - if withPrefix { - opts = append(opts, clientv3.WithPrefix()) - } - - return client.Watch(ctx, key, opts...) -} - -// EtcdGetWithRevision retrieves a value and its revision -func EtcdGetWithRevision(ctx context.Context, key string) (string, int64, error) { - client := GetEtcdClient() - - resp, err := client.Get(ctx, key) - if err != nil { - return "", 0, fmt.Errorf("failed to get key: %w", err) - } - - if len(resp.Kvs) == 0 { - return "", 0, fmt.Errorf("key not found: %s", key) - } - - return string(resp.Kvs[0].Value), resp.Kvs[0].ModRevision, nil -} - -// EtcdWatchFromRevision watches for changes starting from a specific revision -func EtcdWatchFromRevision(ctx context.Context, key string, revision int64, withPrefix bool) clientv3.WatchChan { - client := GetEtcdClient() - - var opts []clientv3.OpOption - if withPrefix { - opts = append(opts, clientv3.WithPrefix()) - } - opts = append(opts, clientv3.WithRev(revision)) - - return client.Watch(ctx, key, opts...) -} diff --git a/src/client/harbor_client.go b/src/client/harbor_client.go deleted file mode 100644 index b8581240..00000000 --- a/src/client/harbor_client.go +++ /dev/null @@ -1,151 +0,0 @@ -package client - -import ( - "context" - "fmt" - "sort" - "sync" - "time" - - "github.com/goharbor/go-client/pkg/harbor" - "github.com/goharbor/go-client/pkg/sdk/v2.0/client/artifact" - "github.com/goharbor/go-client/pkg/sdk/v2.0/models" - - "aegis/config" - "aegis/consts" -) - -// Singleton pattern Harbor client -var ( - harborClient *HarborClient - harborOnce sync.Once -) - -type HarborClient struct { - registry string - namespace string - username string - password string - clientSet *harbor.ClientSet -} - -func GetHarborClient() *HarborClient { - harborOnce.Do(func() { - registry := config.GetString("harbor.registry") - namespace := config.GetString("harbor.namespace") - username := config.GetString("harbor.username") - password := config.GetString("harbor.password") - - // Build complete Harbor URL - harborURL := fmt.Sprintf("http://%s", registry) - - clientSet, err := harbor.NewClientSet(&harbor.ClientSetConfig{ - URL: harborURL, - Username: username, - Password: password, - Insecure: true, // Adjust as needed - }) - if err != nil { - // If client creation fails, log error but continue using nil client - // Will return error in actual methods - harborClient = &HarborClient{ - registry: registry, - namespace: namespace, - username: username, - password: password, - clientSet: nil, - } - return - } - - harborClient = &HarborClient{ - registry: registry, - namespace: namespace, - username: username, - password: password, - clientSet: clientSet, - } - }) - return harborClient -} - -func (h *HarborClient) GetLatestTag(image string) (string, error) { - if h.clientSet == nil { - return "", fmt.Errorf("harbor client is not initialized") - } - - ctx, cancel := context.WithTimeout(context.Background(), consts.HarborTimeout*consts.HarborTimeUnit) - defer cancel() - - params := &artifact.ListArtifactsParams{ - ProjectName: h.namespace, - RepositoryName: image, - Context: ctx, - } - - response, err := h.clientSet.V2().Artifact.ListArtifacts(ctx, params) - if err != nil { - return "", fmt.Errorf("failed to list artifacts: %v", err) - } - - if len(response.Payload) == 0 { - return "", fmt.Errorf("no artifacts found for image %s", image) - } - - var allTags []*models.Tag - for _, artifact := range response.Payload { - if artifact.Tags != nil { - allTags = append(allTags, artifact.Tags...) - } - } - - if len(allTags) == 0 { - return "", fmt.Errorf("no tags found for image %s", image) - } - - sort.Slice(allTags, func(i, j int) bool { - return time.Time(allTags[i].PushTime).After(time.Time(allTags[j].PushTime)) - }) - - return allTags[0].Name, nil -} - -func (h *HarborClient) CheckImageExists(repository, tag string) (bool, error) { - if h.clientSet == nil { - return false, fmt.Errorf("harbor client is not initialized") - } - - ctx, cancel := context.WithTimeout(context.Background(), consts.HarborTimeout*consts.HarborTimeUnit) - defer cancel() - - params := &artifact.ListArtifactsParams{ - ProjectName: h.namespace, - RepositoryName: repository, - Context: ctx, - } - - response, err := h.clientSet.V2().Artifact.ListArtifacts(ctx, params) - if err != nil { - return false, nil - } - - if len(response.Payload) == 0 { - return false, nil - } - - if tag == "" || tag == consts.DefaultContainerTag { - return true, nil - } - - for _, artifact := range response.Payload { - if artifact.Tags != nil { - for _, t := range artifact.Tags { - if t.Name == tag { - return true, nil - } - } - } - } - - return false, nil -} diff --git a/src/client/helm.go b/src/client/helm.go deleted file mode 100644 index 9d8d5e2b..00000000 --- a/src/client/helm.go +++ /dev/null @@ -1,323 +0,0 @@ -package client - -import ( - "context" - "fmt" - "log" - "os" - "path/filepath" - "strings" - "time" - - "aegis/config" - "aegis/tracing" - - "github.com/sirupsen/logrus" - "helm.sh/helm/v3/pkg/action" - "helm.sh/helm/v3/pkg/chart/loader" - "helm.sh/helm/v3/pkg/cli" - "helm.sh/helm/v3/pkg/getter" - "helm.sh/helm/v3/pkg/repo" - - "k8s.io/cli-runtime/pkg/genericclioptions" - "sigs.k8s.io/yaml" -) - -// HelmClient represents a client for interacting with Helm -type HelmClient struct { - namespace string - actionConfig *action.Configuration - settings *cli.EnvSettings -} - -// NewHelmClient creates a new Helm client with the specified namespace -func NewHelmClient(namespace string) (*HelmClient, error) { - settings := cli.New() - settings.SetNamespace(namespace) - settings.Debug = config.GetBool("helm.debug") - - actionConfig := new(action.Configuration) - configFlags := genericclioptions.NewConfigFlags(true) - configFlags.Namespace = &namespace - - if err := actionConfig.Init(configFlags, namespace, os.Getenv("HELM_DRIVER"), log.Printf); err != nil { - return nil, fmt.Errorf("failed to initialize Helm action configuration: %w", err) - } - - return &HelmClient{ - namespace: namespace, - actionConfig: actionConfig, - settings: settings, - }, nil -} - -// AddRepo adds a Helm repository with the given name and URL -func (c *HelmClient) AddRepo(name, url string) error { - repoFile := c.settings.RepositoryConfig - - // Ensure the repository directory exists - err := os.MkdirAll(c.settings.RepositoryCache, 0755) - if err != nil && !os.IsExist(err) { - return fmt.Errorf("could not create repository cache directory: %w", err) - } - - // Check if repo file exists - b, err := os.ReadFile(repoFile) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("could not read repository file: %w", err) - } - - var f repo.File - if err == nil { - if err := yaml.Unmarshal(b, &f); err != nil { - return fmt.Errorf("cannot unmarshal repository file: %w", err) - } - } - - // Check if the repo already exists - if f.Has(name) { - if f.Get(name).URL != url { - f.Get(name).URL = url - } - - if err := f.WriteFile(repoFile, 0644); err != nil { - return fmt.Errorf("failed to write repository file: %w", err) - } - - logrus.Infof("Updated repository %s URL to %s", name, url) - return nil - } - - // Create new repository entry - entry := &repo.Entry{ - Name: name, - URL: url, - } - r, err := repo.NewChartRepository(entry, getter.All(c.settings)) - if err != nil { - return fmt.Errorf("failed to create chart repository: %w", err) - } - - if _, err := r.DownloadIndexFile(); err != nil { - return fmt.Errorf("looks like %q is not a valid chart repository or cannot be reached: %w", url, err) - } - - f.Update(entry) - if err := f.WriteFile(repoFile, 0644); err != nil { - return fmt.Errorf("failed to write repository file: %w", err) - } - - return nil -} - -// UpdateRepo updates all Helm repositories -func (c *HelmClient) UpdateRepo(name string) error { - repoFile := c.settings.RepositoryConfig - - // Read repo file - b, err := os.ReadFile(repoFile) - if err != nil { - return fmt.Errorf("could not read repository file: %w", err) - } - - var f repo.File - if err := yaml.Unmarshal(b, &f); err != nil { - return fmt.Errorf("cannot unmarshal repository file: %w", err) - } - - // Update each repository - for _, entry := range f.Repositories { - if name == entry.Name || name == "" { - logrus.Infof("Updating repository %s", entry.Name) - - r, err := repo.NewChartRepository(entry, getter.All(c.settings)) - if err != nil { - return fmt.Errorf("failed to create chart repository for %s: %w", entry.Name, err) - } - - if _, err := r.DownloadIndexFile(); err != nil { - return fmt.Errorf("failed to update repository %s: %w", entry.Name, err) - } - } - } - - return nil -} - -func (c *HelmClient) SearchRepo(repoName string) ([]*repo.Entry, error) { - repoFile := c.settings.RepositoryConfig - - b, err := os.ReadFile(repoFile) - if err != nil { - return nil, fmt.Errorf("could not read repository file: %w", err) - } - - var f repo.File - if err := yaml.Unmarshal(b, &f); err != nil { - return nil, fmt.Errorf("cannot unmarshal repository file: %w", err) - } - - var repos []*repo.Entry - for _, r := range f.Repositories { - if repoName == "" || r.Name == repoName { - repos = append(repos, r) - } - } - - return repos, nil -} - -func (c *HelmClient) IsReleaseInstalled(releaseName string) (bool, error) { - client := action.NewStatus(c.actionConfig) - - _, err := client.Run(releaseName) - if err != nil { - if strings.Contains(err.Error(), "not found") { - return false, nil - } - return false, fmt.Errorf("failed to get release status: %w", err) - } - - return true, nil -} - -func (c *HelmClient) UninstallRelease(releaseName string, timeout time.Duration) error { - client := action.NewUninstall(c.actionConfig) - client.Wait = true - client.Timeout = timeout - - _, err := client.Run(releaseName) - if err != nil { - if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "release: not found") { - logrus.Infof("Release %s is not installed, nothing to uninstall", releaseName) - return nil - } - - return fmt.Errorf("failed to uninstall release %s: %w", releaseName, err) - } - - return nil -} - -func (c *HelmClient) isChartCachedLocally(chartName string) (string, bool) { - // Check if it's an absolute path or relative path first - if _, err := os.Stat(chartName); err == nil { - abs, err := filepath.Abs(chartName) - if err == nil { - logrus.Infof("Found local chart at: %s", abs) - return abs, true - } - } - - // If it's not a local path, check the cache directory - // The cache directory structure is: {RepositoryCache}/{repo-name}/{chart-name}-{version}.tgz - // We need to check for the chart without knowing the exact version - cacheDir := c.settings.RepositoryCache - - // Try to find any cached version of this chart - // Chart name format: {repo}/{chart} or just {chart} - var searchPatterns []string - - if strings.Contains(chartName, "/") { - // Format like "train-ticket/trainticket" - parts := strings.Split(chartName, "/") - if len(parts) == 2 { - chartBaseName := parts[1] - // Look for patterns like: cache/{repo-hash}/{chart-name}-{version}.tgz - searchPatterns = append(searchPatterns, - fmt.Sprintf("%s/*/%s-*.tgz", cacheDir, chartBaseName), - fmt.Sprintf("%s/%s-*.tgz", cacheDir, chartBaseName), - ) - } - } else { - // Just chart name, search in all subdirectories - searchPatterns = append(searchPatterns, - fmt.Sprintf("%s/*/%s-*.tgz", cacheDir, chartName), - fmt.Sprintf("%s/%s-*.tgz", cacheDir, chartName), - ) - } - - // Check each pattern - for _, pattern := range searchPatterns { - matches, err := filepath.Glob(pattern) - if err == nil && len(matches) > 0 { - // Return the first (most recent if sorted) match - cachedPath := matches[0] - logrus.Infof("Found cached chart at: %s", cachedPath) - return cachedPath, true - } - } - - // Also check if the chart directory exists (for local development) - localChartDir := filepath.Join(cacheDir, chartName) - if stat, err := os.Stat(localChartDir); err == nil && stat.IsDir() { - logrus.Infof("Found cached chart directory at: %s", localChartDir) - return localChartDir, true - } - - return "", false -} - -func (c *HelmClient) InstallRelease(ctx context.Context, releaseName, chartName, version string, vals map[string]any, timeout time.Duration) error { - return tracing.WithSpan(ctx, func(ctx context.Context) error { - now := time.Now() - - defer func() { - log.Printf("InstallRelease took %s", time.Since(now)) - }() - - client := action.NewInstall(c.actionConfig) - client.ReleaseName = releaseName - client.Namespace = c.namespace - client.Wait = true - client.Timeout = timeout - client.CreateNamespace = true - client.Version = version - - var cp string - var err error - - // Check if chart is cached locally first - if cachedPath, isCached := c.isChartCachedLocally(chartName); isCached { - logrus.Infof("Using cached chart for %s at %s", chartName, cachedPath) - cp = cachedPath - } else { - logrus.Infof("Chart %s not found in cache, downloading...", chartName) - cp, err = client.LocateChart(chartName, c.settings) - if err != nil { - return fmt.Errorf("failed to locate chart %s: %w", chartName, err) - } - } - - chart, err := loader.Load(cp) - if err != nil { - return fmt.Errorf("failed to load chart %s: %w", chartName, err) - } - - _, err = client.Run(chart, vals) - if err != nil { - return fmt.Errorf("failed to install release %s: %v", releaseName, err) - } - - return nil - }) -} - -func (c *HelmClient) Install(ctx context.Context, releaseName, chartName, version string, values map[string]any, installTimeout, unInstallTimeout time.Duration) error { - installed, err := c.IsReleaseInstalled(releaseName) - if err != nil { - return err - } - - // If installed, uninstall it first - if installed { - logrus.Infof("Uninstalling existing %s release", releaseName) - if err := c.UninstallRelease(releaseName, unInstallTimeout); err != nil { - return err - } - } else { - logrus.Infof("No existing %s release found", releaseName) - } - - return c.InstallRelease(ctx, releaseName, chartName, version, values, installTimeout) -} diff --git a/src/client/helm_test.go b/src/client/helm_test.go deleted file mode 100644 index d22d9353..00000000 --- a/src/client/helm_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package client - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "helm.sh/helm/v3/pkg/action" - "helm.sh/helm/v3/pkg/cli" - "k8s.io/cli-runtime/pkg/genericclioptions" -) - -// mockActionConfig creates a mock Helm action configuration for testing -func mockActionConfig(t *testing.T) *action.Configuration { - actionConfig := new(action.Configuration) - configFlags := genericclioptions.NewConfigFlags(true) - namespace := "test-namespace" - configFlags.Namespace = &namespace - - // Use memory driver for testing to avoid real k8s connections - err := actionConfig.Init(configFlags, namespace, "memory", func(format string, v ...interface{}) { - t.Logf(format, v...) - }) - if err != nil { - t.Fatalf("Failed to initialize action config: %v", err) - } - - return actionConfig -} - -// createMockHelmClient creates a test HelmClient with mock configuration -func createMockHelmClient(t *testing.T) *HelmClient { - settings := cli.New() - namespace := "test-namespace" - settings.SetNamespace(namespace) - - // Create temporary directories for testing - tempDir := t.TempDir() - settings.RepositoryConfig = filepath.Join(tempDir, "repositories.yaml") - settings.RepositoryCache = filepath.Join(tempDir, "cache") - - return &HelmClient{ - namespace: namespace, - actionConfig: mockActionConfig(t), - settings: settings, - } -} - -func TestHelmClient_isChartCachedLocally(t *testing.T) { - tests := []struct { - name string - chartName string - expectFound bool - }{ - { - name: "chart does not exist in cache", - chartName: "non-existent-chart", - expectFound: false, - }, - { - name: "empty chart name", - chartName: "", - expectFound: false, - }, - { - name: "invalid chart name", - chartName: "invalid/chart/name", - expectFound: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := createMockHelmClient(t) - - gotPath, gotFound := client.isChartCachedLocally(tt.chartName) - - if gotFound != tt.expectFound { - t.Errorf("isChartCachedLocally() gotFound = %v, want %v", gotFound, tt.expectFound) - } - - if tt.expectFound { - if gotPath == "" { - t.Error("Expected non-empty chart path when chart is found") - } - - // Verify the chart path exists - if _, err := os.Stat(gotPath); err != nil { - t.Errorf("Chart path should exist: %v", err) - } - } else { - if gotPath != "" { - t.Errorf("Expected empty chart path when not found, got: %s", gotPath) - } - } - }) - } -} - -func TestHelmClient_isChartCachedLocally_FileSystem(t *testing.T) { - client := createMockHelmClient(t) - - // Test with a chart that definitely doesn't exist - path, found := client.isChartCachedLocally("definitely-non-existent-chart-12345") - if found { - t.Error("Should not find non-existent chart") - } - if path != "" { - t.Errorf("Path should be empty for non-existent chart, got: %s", path) - } -} - -func TestHelmClient_isChartCachedLocally_NoDownload(t *testing.T) { - client := createMockHelmClient(t) - - // This test ensures that the cache check doesn't trigger a download - // We test with a chart name that would normally trigger a download - startTime := time.Now() - - path, found := client.isChartCachedLocally("non-existent-repo/non-existent-chart") - - elapsed := time.Since(startTime) - - // The operation should be very fast since it's only checking local filesystem - if elapsed > 1*time.Second { - t.Errorf("Cache check took too long (%v), might be triggering download", elapsed) - } - - // Should not find the chart since it doesn't exist locally - if found { - t.Error("Should not find non-existent chart") - } - - if path != "" { - t.Errorf("Path should be empty for non-existent chart, got: %s", path) - } - - t.Logf("Cache check completed in %v (no download triggered)", elapsed) -} - -func TestHelmClient_InstallRelease_UsesCachedChart(t *testing.T) { - // This test verifies the method handles the basic flow - client := createMockHelmClient(t) - ctx := context.Background() - - // Create a simple test that verifies the method doesn't panic - // and handles the basic flow (though it will fail due to missing chart) - err := client.InstallRelease(ctx, "test-release", "non-existent-chart", "0.0.0.", map[string]any{}, 500*time.Second) - - // We expect an error since the chart doesn't exist, but it should be a specific error - if err == nil { - t.Error("Expected error for non-existent chart") - } - - if !strings.Contains(err.Error(), "failed to locate chart") { - t.Errorf("Expected 'failed to locate chart' error, got: %v", err) - } -} - -func TestHelmClient_NewHelmClient(t *testing.T) { - namespace := "test-namespace" - - // This test might fail in environments without proper k8s config - // but we can test the basic structure - client, err := NewHelmClient(namespace) - - if err != nil { - // If we can't create a real client (e.g., no k8s config), that's expected in test env - t.Logf("Expected error in test environment: %v", err) - return - } - - if client == nil { - t.Error("Expected non-nil client") - return - } - - if client.namespace != namespace { - t.Errorf("Expected namespace %s, got %s", namespace, client.namespace) - return - } - - if client.actionConfig == nil { - t.Error("Expected non-nil actionConfig") - return - } - - if client.settings == nil { - t.Error("Expected non-nil settings") - } -} diff --git a/src/client/k8s/client.go b/src/client/k8s/client.go deleted file mode 100644 index 65814784..00000000 --- a/src/client/k8s/client.go +++ /dev/null @@ -1,88 +0,0 @@ -package k8s - -import ( - "os" - "path/filepath" - "sync" - - "github.com/sirupsen/logrus" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" -) - -var ( - k8sRestConfig *rest.Config - k8sClient *kubernetes.Clientset - k8sDynamicClient *dynamic.DynamicClient - k8sController *Controller - - k8sRestConfigOnce sync.Once - k8sClientOnce sync.Once - k8sDynamicClientOnce sync.Once - controllerOnce sync.Once -) - -func GetK8sClient() *kubernetes.Clientset { - k8sClientOnce.Do(func() { - restConfig := GetK8sRestConfig() - clientset, err := kubernetes.NewForConfig(restConfig) - if err != nil { - logrus.Fatalf("failed to create Kubernetes clientset: %v", err) - } - - k8sClient = clientset - }) - return k8sClient -} - -func GetK8sDynamicClient() *dynamic.DynamicClient { - k8sDynamicClientOnce.Do(func() { - restConfig := GetK8sRestConfig() - dynamicClient, err := dynamic.NewForConfig(restConfig) - if err != nil { - logrus.Fatalf("failed to create Kubernetes dynamic client: %v", err) - } - - k8sDynamicClient = dynamicClient - }) - return k8sDynamicClient -} - -func GetK8sRestConfig() *rest.Config { - k8sRestConfigOnce.Do(func() { - var restConfig *rest.Config - var err error - var currentContext string - - restConfig, err = rest.InClusterConfig() - if err == nil { - logrus.Info("Successfully loaded In-Cluster Kubernetes configuration.") - currentContext = "In-Cluster" - k8sRestConfig = restConfig - logrus.Infof("Using Kubernetes Context: %s", currentContext) - return - } - - logrus.Warn("In-cluster config not found, trying kubeconfig file") - kubeconfig := filepath.Join(os.Getenv("HOME"), ".kube", "config") - config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) - if err != nil { - logrus.Fatalf("Failed to load Kubernetes config: %v", err) - } - - k8sRestConfig = config - if k8sRestConfig == nil { - logrus.Fatalf("Failed to establish Kubernetes REST config: Neither In-Cluster nor external Kubeconfig available.") - } - }) - return k8sRestConfig -} - -func GetK8sController() *Controller { - controllerOnce.Do(func() { - k8sController = NewController() - }) - return k8sController -} diff --git a/src/client/k8s/k8s_test.go b/src/client/k8s/k8s_test.go deleted file mode 100644 index 5fc0f87b..00000000 --- a/src/client/k8s/k8s_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package k8s - -import ( - "aegis/config" - "aegis/utils" - "context" - "testing" - - "github.com/k0kubun/pp/v3" - corev1 "k8s.io/api/core/v1" -) - -func TestGetVolumeMountConfigs(t *testing.T) { - config.Init("../..") - - volumeMountConfigs := make([]VolumeMountConfig, 0) - mapData := config.GetMap("k8s.job.volume_mount") - for _, cfgData := range mapData { - cfg, err := utils.ConvertToType[VolumeMountConfig](cfgData) - if err != nil { - t.Errorf("invalid volume mount config %v: %v", cfgData, err) - } - - volumeMountConfigs = append(volumeMountConfigs, cfg) - } - - volumeMounts := []corev1.VolumeMount{} - volumes := []corev1.Volume{} - for _, cfg := range volumeMountConfigs { - volumeMounts = append(volumeMounts, cfg.GetVolumeMount()) - volumes = append(volumes, cfg.GetVolume()) - } - - pp.Println(volumeMountConfigs) //nolint:errcheck - pp.Println(volumeMounts) //nolint:errcheck - pp.Println(volumes) //nolint:errcheck -} - -func TestCreateGetDeleteK8sJob(t *testing.T) { - jobName := "example-job" - namespace := "default" - image := "busybox" - command := []string{"sh", "-c", "for i in $(seq 1 5); do echo \"Log line $i\"; sleep 1; done"} - restartPolicy := corev1.RestartPolicyNever - backoffLimit := int32(2) - parallelism := int32(2) - completions := int32(2) - - envVars := []corev1.EnvVar{ - {Name: "ENV_TEST", Value: "test"}, - } - - // Step 1: Create Job - if err := CreateJob(context.Background(), &JobConfig{ - Namespace: namespace, - JobName: jobName, - Image: image, - Command: command, - RestartPolicy: restartPolicy, - BackoffLimit: backoffLimit, - Parallelism: parallelism, - Completions: completions, - EnvVars: envVars, - }); err != nil { - t.Fatalf("CreateK8sJob failed: %v", err) - } - t.Logf("Job %s created successfully.", jobName) - - // Step 2: Get Job - job, err := GetJob(context.Background(), namespace, jobName) - if err != nil { - t.Fatalf("GetK8sJob failed: %v", err) - } - t.Logf("Fetched job: %v", job) - - // Ensure job was created with the correct name - if job.Name != jobName { - t.Errorf("expected job name %s, got %s", jobName, job.Name) - } - - // Step 3: Wait for Job completion - t.Logf("Waiting for job %s to complete...", jobName) - if err := WaitForJobCompletion(context.Background(), namespace, jobName); err != nil { - t.Fatalf("WaitForJobCompletion failed: %v", err) - } - t.Logf("Job %s completed successfully.", jobName) - - // Step 4: Get Pod Logs - logs, err := GetJobPodLogs(context.Background(), namespace, jobName) - if err != nil { - t.Fatalf("GetJobPodLogs failed: %v", err) - } - - t.Logf("Logs for job %s:\n", jobName) - for podName, log := range logs { - t.Logf("Pod %s logs:\n%s", podName, log) - } - - // Step 5: Delete Job - if err := deleteJob(context.Background(), namespace, jobName); err != nil { - t.Fatalf("DeleteK8sJob failed: %v", err) - } - t.Logf("Job %s and its associated pods deleted successfully.", jobName) -} diff --git a/src/client/redis_client.go b/src/client/redis_client.go deleted file mode 100644 index dafb2c33..00000000 --- a/src/client/redis_client.go +++ /dev/null @@ -1,164 +0,0 @@ -package client - -import ( - "context" - "encoding/json" - "fmt" - "sync" - "time" - - "aegis/config" - - "github.com/redis/go-redis/v9" - "github.com/sirupsen/logrus" -) - -// Singleton pattern Redis client -var ( - redisClient *redis.Client - redisOnce sync.Once -) - -// Get Redis client -func GetRedisClient() *redis.Client { - redisOnce.Do(func() { - logrus.Infof("Connecting to Redis %s", config.GetString("redis.host")) - redisClient = redis.NewClient(&redis.Options{ - Addr: config.GetString("redis.host"), - Password: "", - DB: 0, - }) - - if err := redisClient.Ping(context.Background()).Err(); err != nil { - logrus.Fatalf("Failed to connect to Redis: %v", err) - } - }) - return redisClient -} - -// CheckCachedField checks if a field exists in Redis cache -func CheckCachedField(ctx context.Context, key, field string) bool { - exists, err := GetRedisClient().HExists(ctx, key, field).Result() - if err != nil { - logrus.Errorf("failed to check if field %s exists in cache: %v", field, err) - return false - } - - return exists -} - -// GetHashField retrieves a field from Redis hash and unmarshals it into the target -func GetHashField[T any](ctx context.Context, key, field string, target *T) error { - itemJSON, err := GetRedisClient().HGet(ctx, key, field).Result() - if err != nil && err != redis.Nil { - return fmt.Errorf("failed to get hash field %s from key %s: %w", field, key, err) - } - - if itemJSON == "" { - logrus.Warnf("field %s not found in cache key %s", field, key) - return nil - } - - if err := json.Unmarshal([]byte(itemJSON), target); err != nil { - return fmt.Errorf("failed to unmarshal cached items for field %s: %w", field, err) - } - - return nil -} - -// SetHashField sets a field in Redis hash with the provided item -func SetHashField[T any](ctx context.Context, key, field string, item T) error { - itemJSON, err := json.Marshal(item) - if err != nil { - return fmt.Errorf("failed to marshal items to JSON: %w", err) - } - - if _, err := GetRedisClient().Pipelined(ctx, func(pipe redis.Pipeliner) error { - pipe.HSet(ctx, key, field, itemJSON) - return nil - }); err != nil { - return fmt.Errorf("failed to set hash field %s in key %s: %w", field, key, err) - } - - return nil -} - -// GetRedisListRange retrieves all elements from a Redis list -func GetRedisListRange(ctx context.Context, key string) ([]string, error) { - result, err := GetRedisClient().LRange(ctx, key, 0, -1).Result() - if err != nil { - return nil, fmt.Errorf("failed to get list range for key '%s': %w", key, err) - } - return result, nil -} - -// GetRedisZRangeByScoreWithScores retrieves elements from a Redis sorted set by score with a limit -func GetRedisZRangeByScoreWithScores(ctx context.Context, key string, limit int64) ([]redis.Z, error) { - if limit <= 0 { - return nil, fmt.Errorf("limit must be a positive number") - } - options := &redis.ZRangeBy{ - Min: "-inf", - Max: "+inf", - Offset: 0, - Count: limit, - } - - results, err := GetRedisClient().ZRangeByScoreWithScores(ctx, key, options).Result() - if err != nil { - return nil, fmt.Errorf("failed to get scheduled tasks from key '%s': %w", key, err) - } - - return results, nil -} - -// RedisXAdd adds an entry to a Redis stream -func RedisXAdd(ctx context.Context, stream string, values map[string]any) error { - _, err := GetRedisClient().XAdd(ctx, &redis.XAddArgs{ - Stream: stream, - MaxLen: 1000, - Approx: true, - ID: "*", - Values: values, - }).Result() - - if err != nil { - return fmt.Errorf("redis XADD failed for stream '%s': %w", stream, err) - } - return nil -} - -// RedisXRead reads entries from Redis streams -func RedisXRead(ctx context.Context, streams []string, count int64, block time.Duration) ([]redis.XStream, error) { - result, err := GetRedisClient().XRead(ctx, &redis.XReadArgs{ - Streams: streams, - Count: count, - Block: block, - }).Result() - - if err != nil && err != redis.Nil { - return nil, fmt.Errorf("redis XREAD failed: %w", err) - } - - return result, nil -} - -// RedisPublish publishes a message to a Redis channel -func RedisPublish(ctx context.Context, channel string, message any) error { - var payload string - switch v := message.(type) { - case string: - payload = v - default: - data, err := json.Marshal(message) - if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) - } - payload = string(data) - } - - if err := GetRedisClient().Publish(ctx, channel, payload).Err(); err != nil { - return fmt.Errorf("redis PUBLISH failed for channel '%s': %w", channel, err) - } - return nil -} diff --git a/src/cmd/aegisctl/client/auth.go b/src/cmd/aegisctl/client/auth.go index 95e5cda4..fe09527e 100644 --- a/src/cmd/aegisctl/client/auth.go +++ b/src/cmd/aegisctl/client/auth.go @@ -1,26 +1,47 @@ package client import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" "fmt" + "strconv" + "strings" "time" ) -// loginRequest matches dto.LoginReq. -type loginRequest struct { - Username string `json:"username"` - Password string `json:"password"` +const apiKeyTokenPath = "/api/v2/auth/api-key/token" + +// APIKeyTokenDebug contains the fully materialized signed request data for +// POST /api/v2/auth/api-key/token. +type APIKeyTokenDebug struct { + Method string + Path string + KeyID string + Timestamp string + Nonce string + BodySHA256 string + CanonicalString string + Signature string } -// loginResponseData matches dto.LoginResp. -type loginResponseData struct { +func (d *APIKeyTokenDebug) Headers() map[string]string { + return map[string]string{ + "X-Key-Id": d.KeyID, + "X-Timestamp": d.Timestamp, + "X-Nonce": d.Nonce, + "X-Signature": d.Signature, + } +} + +// apiKeyTokenResponseData matches dto.APIKeyTokenResp. +type apiKeyTokenResponseData struct { Token string `json:"token"` + TokenType string `json:"token_type"` ExpiresAt time.Time `json:"expires_at"` - User struct { - ID int `json:"id"` - Username string `json:"username"` - Avatar string `json:"avatar,omitempty"` - Role string `json:"role,omitempty"` - } `json:"user"` + AuthType string `json:"auth_type"` + KeyID string `json:"key_id"` } // tokenRefreshRequest matches dto.TokenRefreshReq. @@ -38,26 +59,37 @@ type tokenRefreshResponseData struct { type LoginResult struct { Token string ExpiresAt time.Time - Username string + AuthType string + KeyID string } -// Login authenticates against the server and returns a token. -func Login(server, username, password string) (*LoginResult, error) { - c := NewClient(server, "", 30*time.Second) +// LoginWithAPIKey exchanges a Key ID / Key Secret signature for a bearer token. +func LoginWithAPIKey(server, keyID, keySecret string) (*LoginResult, error) { + keyID = strings.TrimSpace(keyID) + keySecret = strings.TrimSpace(keySecret) + if keyID == "" { + return nil, fmt.Errorf("key id is required") + } + if keySecret == "" { + return nil, fmt.Errorf("key secret is required") + } - var resp APIResponse[loginResponseData] - err := c.Post("/api/v2/auth/login", loginRequest{ - Username: username, - Password: password, - }, &resp) + c := NewClient(server, "", 30*time.Second) + debugInfo, err := PrepareAPIKeyTokenDebug(keyID, keySecret, time.Now().UTC(), "") if err != nil { - return nil, fmt.Errorf("login failed: %w", err) + return nil, fmt.Errorf("prepare signed headers: %w", err) + } + + var resp APIResponse[apiKeyTokenResponseData] + if err := c.PostWithHeaders(apiKeyTokenPath, debugInfo.Headers(), &resp); err != nil { + return nil, fmt.Errorf("exchange api key token failed: %w", err) } return &LoginResult{ Token: resp.Data.Token, ExpiresAt: resp.Data.ExpiresAt, - Username: resp.Data.User.Username, + AuthType: resp.Data.AuthType, + KeyID: resp.Data.KeyID, }, nil } @@ -103,3 +135,110 @@ func IsTokenExpired(expiry time.Time) bool { } return time.Now().After(expiry) } + +// PrepareAPIKeyTokenDebug builds the canonical string, signature, and +// headers for the token exchange request. +func PrepareAPIKeyTokenDebug(keyID, keySecret string, now time.Time, nonce string) (*APIKeyTokenDebug, error) { + keyID = strings.TrimSpace(keyID) + keySecret = strings.TrimSpace(keySecret) + nonce = strings.TrimSpace(nonce) + if keyID == "" { + return nil, fmt.Errorf("key id is required") + } + if keySecret == "" { + return nil, fmt.Errorf("key secret is required") + } + var err error + if nonce == "" { + nonce, err = newAPIKeyNonce() + if err != nil { + return nil, err + } + } + + timestamp := strconv.FormatInt(now.Unix(), 10) + bodySHA256 := sha256Hex("") + canonical := canonicalAPIKeyString("POST", apiKeyTokenPath, timestamp, nonce, bodySHA256) + + return &APIKeyTokenDebug{ + Method: "POST", + Path: apiKeyTokenPath, + KeyID: keyID, + Timestamp: timestamp, + Nonce: nonce, + BodySHA256: bodySHA256, + CanonicalString: canonical, + Signature: signAPIKeyRequest(keySecret, canonical), + }, nil +} + +func buildAPIKeyHeaders(keyID, keySecret string, now time.Time, path string) (map[string]string, error) { + debugInfo, err := prepareAPIKeyDebug(keyID, keySecret, now, path, "") + if err != nil { + return nil, err + } + return debugInfo.Headers(), nil +} + +func prepareAPIKeyDebug(keyID, keySecret string, now time.Time, path, nonce string) (*APIKeyTokenDebug, error) { + keyID = strings.TrimSpace(keyID) + keySecret = strings.TrimSpace(keySecret) + nonce = strings.TrimSpace(nonce) + if keyID == "" { + return nil, fmt.Errorf("key id is required") + } + if keySecret == "" { + return nil, fmt.Errorf("key secret is required") + } + var err error + if nonce == "" { + nonce, err = newAPIKeyNonce() + if err != nil { + return nil, err + } + } + + timestamp := strconv.FormatInt(now.Unix(), 10) + bodySHA256 := sha256Hex("") + canonical := canonicalAPIKeyString("POST", path, timestamp, nonce, bodySHA256) + + return &APIKeyTokenDebug{ + Method: "POST", + Path: path, + KeyID: keyID, + Timestamp: timestamp, + Nonce: nonce, + BodySHA256: bodySHA256, + CanonicalString: canonical, + Signature: signAPIKeyRequest(keySecret, canonical), + }, nil +} + +func canonicalAPIKeyString(method, path, timestamp, nonce, bodySHA256 string) string { + return strings.Join([]string{ + strings.ToUpper(method), + path, + timestamp, + nonce, + bodySHA256, + }, "\n") +} + +func signAPIKeyRequest(secretKey, payload string) string { + mac := hmac.New(sha256.New, []byte(secretKey)) + mac.Write([]byte(payload)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func newAPIKeyNonce() (string, error) { + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("generate nonce: %w", err) + } + return hex.EncodeToString(nonce), nil +} + +func sha256Hex(payload string) string { + sum := sha256.Sum256([]byte(payload)) + return hex.EncodeToString(sum[:]) +} diff --git a/src/cmd/aegisctl/client/auth_test.go b/src/cmd/aegisctl/client/auth_test.go new file mode 100644 index 00000000..e728ae62 --- /dev/null +++ b/src/cmd/aegisctl/client/auth_test.go @@ -0,0 +1,96 @@ +package client + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestCanonicalAPIKeyString(t *testing.T) { + got := canonicalAPIKeyString( + "post", + "/api/v2/auth/api-key/token", + "1713333333", + "abc123", + "body_hash", + ) + + want := "POST\n/api/v2/auth/api-key/token\n1713333333\nabc123\nbody_hash" + if got != want { + t.Fatalf("canonical string mismatch:\nwant: %q\ngot: %q", want, got) + } +} + +func TestBuildAPIKeyHeaders(t *testing.T) { + headers, err := buildAPIKeyHeaders( + "pk_demo", + "ks_demo", + time.Unix(1713333333, 0).UTC(), + "/api/v2/auth/api-key/token", + ) + if err != nil { + t.Fatalf("buildAPIKeyHeaders returned error: %v", err) + } + + if headers["X-Key-Id"] != "pk_demo" { + t.Fatalf("unexpected key id header: %q", headers["X-Key-Id"]) + } + if headers["X-Timestamp"] != "1713333333" { + t.Fatalf("unexpected timestamp header: %q", headers["X-Timestamp"]) + } + if headers["X-Nonce"] == "" { + t.Fatal("expected nonce header to be set") + } + if len(headers["X-Signature"]) != 64 { + t.Fatalf("unexpected signature length: %d", len(headers["X-Signature"])) + } +} + +func TestPrepareAPIKeyTokenDebug(t *testing.T) { + debugInfo, err := PrepareAPIKeyTokenDebug( + "pk_demo", + "ks_demo", + time.Unix(1713333333, 0).UTC(), + "abc123", + ) + if err != nil { + t.Fatalf("PrepareAPIKeyTokenDebug returned error: %v", err) + } + + if debugInfo.Method != "POST" { + t.Fatalf("unexpected method: %q", debugInfo.Method) + } + if debugInfo.Path != "/api/v2/auth/api-key/token" { + t.Fatalf("unexpected path: %q", debugInfo.Path) + } + if debugInfo.CanonicalString != "POST\n/api/v2/auth/api-key/token\n1713333333\nabc123\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" { + t.Fatalf("unexpected canonical string: %q", debugInfo.CanonicalString) + } + if debugInfo.BodySHA256 != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" { + t.Fatalf("unexpected body hash: %q", debugInfo.BodySHA256) + } + if debugInfo.Headers()["X-Signature"] != debugInfo.Signature { + t.Fatal("signature header mismatch") + } +} + +func TestPostWithHeaders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Key-Id"); got != "pk_demo" { + t.Fatalf("unexpected X-Key-Id header: %q", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"code":0,"message":"ok"}`)) + })) + defer server.Close() + + c := NewClient(server.URL, "", 5*time.Second) + var resp APIResponse[map[string]any] + if err := c.PostWithHeaders("/api/v2/auth/api-key/token", map[string]string{ + "X-Key-Id": "pk_demo", + }, &resp); err != nil { + t.Fatalf("PostWithHeaders returned error: %v", err) + } +} diff --git a/src/cmd/aegisctl/client/client.go b/src/cmd/aegisctl/client/client.go index b0e40d39..e265ca57 100644 --- a/src/cmd/aegisctl/client/client.go +++ b/src/cmd/aegisctl/client/client.go @@ -65,7 +65,7 @@ func NewClient(baseURL, token string, timeout time.Duration) *Client { } // doRequest executes an HTTP request and decodes the JSON response into dest. -func (c *Client) doRequest(method, path string, body any, dest any) error { +func (c *Client) doRequest(method, path string, body any, headers map[string]string, dest any) error { var bodyReader io.Reader if body != nil { data, err := json.Marshal(body) @@ -86,12 +86,17 @@ func (c *Client) doRequest(method, path string, body any, dest any) error { if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } + for key, value := range headers { + req.Header.Set(key, value) + } resp, err := c.HTTPClient.Do(req) if err != nil { return fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() respBody, err := io.ReadAll(resp.Body) if err != nil { @@ -123,25 +128,30 @@ func (c *Client) doRequest(method, path string, body any, dest any) error { // Get sends a GET request. func (c *Client) Get(path string, dest any) error { - return c.doRequest(http.MethodGet, path, nil, dest) + return c.doRequest(http.MethodGet, path, nil, nil, dest) } // Post sends a POST request. func (c *Client) Post(path string, body any, dest any) error { - return c.doRequest(http.MethodPost, path, body, dest) + return c.doRequest(http.MethodPost, path, body, nil, dest) +} + +// PostWithHeaders sends a POST request with additional headers. +func (c *Client) PostWithHeaders(path string, headers map[string]string, dest any) error { + return c.doRequest(http.MethodPost, path, nil, headers, dest) } // Put sends a PUT request. func (c *Client) Put(path string, body any, dest any) error { - return c.doRequest(http.MethodPut, path, body, dest) + return c.doRequest(http.MethodPut, path, body, nil, dest) } // Patch sends a PATCH request. func (c *Client) Patch(path string, body any, dest any) error { - return c.doRequest(http.MethodPatch, path, body, dest) + return c.doRequest(http.MethodPatch, path, body, nil, dest) } // Delete sends a DELETE request. func (c *Client) Delete(path string, dest any) error { - return c.doRequest(http.MethodDelete, path, nil, dest) + return c.doRequest(http.MethodDelete, path, nil, nil, dest) } diff --git a/src/cmd/aegisctl/client/sse.go b/src/cmd/aegisctl/client/sse.go index b16d4bee..1a83961c 100644 --- a/src/cmd/aegisctl/client/sse.go +++ b/src/cmd/aegisctl/client/sse.go @@ -92,7 +92,9 @@ func (r *SSEReader) readStream(ctx context.Context, events chan<- SSEEvent) erro if err != nil { return fmt.Errorf("SSE connect: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("SSE server returned status %d", resp.StatusCode) diff --git a/src/cmd/aegisctl/client/ws.go b/src/cmd/aegisctl/client/ws.go index a4fb5e94..1dcfdec2 100644 --- a/src/cmd/aegisctl/client/ws.go +++ b/src/cmd/aegisctl/client/ws.go @@ -55,14 +55,16 @@ func (r *WSReader) Stream(ctx context.Context) (<-chan string, <-chan error) { errs <- fmt.Errorf("websocket connect: %w", err) return } - defer conn.Close() + defer func() { + _ = conn.Close() + }() // Close the connection when context is cancelled. go func() { <-ctx.Done() - conn.WriteMessage(websocket.CloseMessage, + _ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) - conn.Close() + _ = conn.Close() }() for { diff --git a/src/cmd/aegisctl/cmd/auth.go b/src/cmd/aegisctl/cmd/auth.go index 987c88d1..212b8530 100644 --- a/src/cmd/aegisctl/cmd/auth.go +++ b/src/cmd/aegisctl/cmd/auth.go @@ -2,6 +2,8 @@ package cmd import ( "fmt" + "os" + "strings" "time" "aegis/cmd/aegisctl/client" @@ -19,13 +21,13 @@ var authCmd = &cobra.Command{ // --- auth login --- var authLoginServer string -var authLoginUsername string -var authLoginPassword string +var authLoginKeyID string +var authLoginKeySecret string var authLoginContext string var authLoginCmd = &cobra.Command{ Use: "login", - Short: "Authenticate with an AegisLab server", + Short: "Exchange Key ID / Key Secret for a bearer token", RunE: func(cmd *cobra.Command, args []string) error { server := authLoginServer if server == "" { @@ -35,16 +37,25 @@ var authLoginCmd = &cobra.Command{ return fmt.Errorf("--server is required for login") } - if authLoginUsername == "" { - return fmt.Errorf("--username is required") + keyID := authLoginKeyID + if keyID == "" { + keyID = os.Getenv("AEGIS_KEY_ID") } - if authLoginPassword == "" { - return fmt.Errorf("--password is required") + if keyID == "" { + return fmt.Errorf("--key-id is required") } - output.PrintInfo(fmt.Sprintf("Logging in to %s as %s...", server, authLoginUsername)) + keySecret := authLoginKeySecret + if keySecret == "" { + keySecret = os.Getenv("AEGIS_KEY_SECRET") + } + if keySecret == "" { + return fmt.Errorf("--key-secret is required") + } + + output.PrintInfo(fmt.Sprintf("Exchanging API key token with %s using %s...", server, keyID)) - result, err := client.Login(server, authLoginUsername, authLoginPassword) + result, err := client.LoginWithAPIKey(server, keyID, keySecret) if err != nil { return err } @@ -59,6 +70,8 @@ var authLoginCmd = &cobra.Command{ cfg.Contexts[ctxName] = config.Context{ Server: server, Token: result.Token, + AuthType: result.AuthType, + KeyID: result.KeyID, TokenExpiry: result.ExpiresAt, } cfg.CurrentContext = ctxName @@ -71,11 +84,12 @@ var authLoginCmd = &cobra.Command{ output.PrintJSON(map[string]any{ "context": ctxName, "server": server, - "username": result.Username, + "auth_type": result.AuthType, + "key_id": result.KeyID, "expires_at": result.ExpiresAt.Format(time.RFC3339), }) } else { - output.PrintInfo(fmt.Sprintf("Logged in as %s (context: %s)", result.Username, ctxName)) + output.PrintInfo(fmt.Sprintf("Token issued for key id %s (context: %s)", result.KeyID, ctxName)) output.PrintInfo(fmt.Sprintf("Token expires at %s", result.ExpiresAt.Format(time.RFC3339))) } return nil @@ -108,6 +122,8 @@ var authStatusCmd = &cobra.Command{ "context": ctxName, "server": ctx.Server, "status": status, + "auth_type": ctx.AuthType, + "key_id": ctx.KeyID, "expires_at": ctx.TokenExpiry.Format(time.RFC3339), }) return nil @@ -135,7 +151,171 @@ var authStatusCmd = &cobra.Command{ } else { output.PrintInfo(fmt.Sprintf("Authenticated as: %s (id: %d)", profile.Username, profile.ID)) } + if ctx.KeyID != "" { + output.PrintInfo(fmt.Sprintf("Issued via key id: %s", ctx.KeyID)) + } + + return nil + }, +} + +// --- auth inspect --- + +var authInspectCmd = &cobra.Command{ + Use: "inspect", + Short: "Inspect locally stored authentication context", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, ctxName, err := config.GetCurrentContext(cfg) + if err != nil { + return err + } + + tokenPreview := "" + if ctx.Token != "" { + tokenPreview = ctx.Token + if len(tokenPreview) > 20 { + tokenPreview = tokenPreview[:10] + "..." + tokenPreview[len(tokenPreview)-10:] + } + } + expiresAt := "" + if !ctx.TokenExpiry.IsZero() { + expiresAt = ctx.TokenExpiry.Format(time.RFC3339) + } + expired := false + if !ctx.TokenExpiry.IsZero() { + expired = client.IsTokenExpired(ctx.TokenExpiry) + } + + if output.OutputFormat(flagOutput) == output.FormatJSON { + output.PrintJSON(map[string]any{ + "context": ctxName, + "server": ctx.Server, + "auth_type": ctx.AuthType, + "key_id": ctx.KeyID, + "token_present": ctx.Token != "", + "token_preview": tokenPreview, + "token_expired": expired, + "expires_at": expiresAt, + }) + return nil + } + + output.PrintTable( + []string{"Context", "Server", "AuthType", "KeyID", "Token", "Expired", "Expires"}, + [][]string{{ + ctxName, + ctx.Server, + emptyOrValue(ctx.AuthType, "-"), + emptyOrValue(ctx.KeyID, "-"), + emptyOrValue(tokenPreview, "-"), + fmt.Sprintf("%t", expired), + emptyOrValue(expiresAt, "-"), + }}, + ) + return nil + }, +} + +// --- auth sign-debug --- + +var authSignDebugKeyID string +var authSignDebugKeySecret string +var authSignDebugTimestamp int64 +var authSignDebugNonce string +var authSignDebugExecute bool +var authSignDebugSaveContext bool + +var authSignDebugCmd = &cobra.Command{ + Use: "sign-debug", + Short: "Print canonical string and signature headers for Key ID / Key Secret token exchange", + RunE: func(cmd *cobra.Command, args []string) error { + keyID := authSignDebugKeyID + if keyID == "" { + keyID = os.Getenv("AEGIS_KEY_ID") + } + if keyID == "" { + return fmt.Errorf("--key-id is required") + } + + keySecret := authSignDebugKeySecret + if keySecret == "" { + keySecret = os.Getenv("AEGIS_KEY_SECRET") + } + if keySecret == "" { + return fmt.Errorf("--key-secret is required") + } + + signTime := time.Now().UTC() + if authSignDebugTimestamp > 0 { + signTime = time.Unix(authSignDebugTimestamp, 0).UTC() + } + debugInfo, err := client.PrepareAPIKeyTokenDebug(keyID, keySecret, signTime, authSignDebugNonce) + if err != nil { + return err + } + + server := strings.TrimRight(resolveServerForAuthDebug(), "/") + if authSignDebugExecute && (server == "" || strings.Contains(server, "HOST:8082")) { + return fmt.Errorf("--execute requires a real --server or configured AEGIS_SERVER/current context") + } + curlCommand := buildAPIKeyCurl(server, debugInfo) + var executeResp map[string]any + if authSignDebugExecute { + executeResp, err = executeAPIKeyTokenExchange(server, debugInfo) + if err != nil { + return err + } + if authSignDebugSaveContext { + if err := saveAPIKeyContext(server, executeResp); err != nil { + return err + } + } + } else if authSignDebugSaveContext { + return fmt.Errorf("--save-context requires --execute") + } + + if output.OutputFormat(flagOutput) == output.FormatJSON { + result := map[string]any{ + "server": server, + "method": debugInfo.Method, + "path": debugInfo.Path, + "key_id": debugInfo.KeyID, + "timestamp": debugInfo.Timestamp, + "nonce": debugInfo.Nonce, + "body_sha256": debugInfo.BodySHA256, + "canonical_string": debugInfo.CanonicalString, + "signature": debugInfo.Signature, + "headers": debugInfo.Headers(), + "curl": curlCommand, + "executed": authSignDebugExecute, + "saved_context": authSignDebugSaveContext, + } + if authSignDebugExecute { + result["response"] = executeResp + } + output.PrintJSON(result) + return nil + } + + fmt.Printf("Server: %s\n", server) + fmt.Printf("Method: %s\n", debugInfo.Method) + fmt.Printf("Path: %s\n", debugInfo.Path) + fmt.Printf("Key-Id: %s\n", debugInfo.KeyID) + fmt.Printf("Timestamp: %s\n", debugInfo.Timestamp) + fmt.Printf("Nonce: %s\n", debugInfo.Nonce) + fmt.Printf("Body-SHA256: %s\n", debugInfo.BodySHA256) + fmt.Printf("Signature: %s\n\n", debugInfo.Signature) + fmt.Println("Canonical String:") + fmt.Println(debugInfo.CanonicalString) + fmt.Println() + fmt.Println("curl:") + fmt.Println(curlCommand) + if authSignDebugExecute { + fmt.Println() + fmt.Println("response:") + output.PrintJSON(executeResp) + } return nil }, } @@ -175,6 +355,9 @@ var authTokenCmd = &cobra.Command{ ctx := cfg.Contexts[ctxName] ctx.Token = authTokenSet + ctx.AuthType = "token" + ctx.KeyID = "" + ctx.TokenExpiry = time.Time{} cfg.Contexts[ctxName] = ctx cfg.CurrentContext = ctxName @@ -189,13 +372,111 @@ var authTokenCmd = &cobra.Command{ func init() { authLoginCmd.Flags().StringVar(&authLoginServer, "server", "", "Server URL") - authLoginCmd.Flags().StringVar(&authLoginUsername, "username", "", "Username") - authLoginCmd.Flags().StringVar(&authLoginPassword, "password", "", "Password") + authLoginCmd.Flags().StringVar(&authLoginKeyID, "key-id", "", "Key ID (env: AEGIS_KEY_ID)") + authLoginCmd.Flags().StringVar(&authLoginKeySecret, "key-secret", "", "Key secret (env: AEGIS_KEY_SECRET)") authLoginCmd.Flags().StringVar(&authLoginContext, "context", "", "Context name to save credentials under (default: \"default\")") + authSignDebugCmd.Flags().StringVar(&authSignDebugKeyID, "key-id", "", "Key ID (env: AEGIS_KEY_ID)") + authSignDebugCmd.Flags().StringVar(&authSignDebugKeySecret, "key-secret", "", "Key secret (env: AEGIS_KEY_SECRET)") + authSignDebugCmd.Flags().Int64Var(&authSignDebugTimestamp, "timestamp", 0, "Override unix timestamp in seconds") + authSignDebugCmd.Flags().StringVar(&authSignDebugNonce, "nonce", "", "Override nonce for reproducible signature output") + authSignDebugCmd.Flags().BoolVar(&authSignDebugExecute, "execute", false, "Execute the signed token exchange request and print the response") + authSignDebugCmd.Flags().BoolVar(&authSignDebugSaveContext, "save-context", false, "Save the exchanged bearer token into the current context after --execute succeeds") authTokenCmd.Flags().StringVar(&authTokenSet, "set", "", "Set token directly") authCmd.AddCommand(authLoginCmd) authCmd.AddCommand(authStatusCmd) + authCmd.AddCommand(authInspectCmd) + authCmd.AddCommand(authSignDebugCmd) authCmd.AddCommand(authTokenCmd) } + +func resolveServerForAuthDebug() string { + if flagServer != "" { + return flagServer + } + if value := os.Getenv("AEGIS_SERVER"); value != "" { + return value + } + if cfg != nil { + if ctx, _, err := config.GetCurrentContext(cfg); err == nil && ctx.Server != "" { + return ctx.Server + } + } + return "http://HOST:8082" +} + +func buildAPIKeyCurl(server string, debugInfo *client.APIKeyTokenDebug) string { + return fmt.Sprintf( + "curl -X POST %s%s -H 'Accept: application/json' -H 'X-Key-Id: %s' -H 'X-Timestamp: %s' -H 'X-Nonce: %s' -H 'X-Signature: %s'", + server, + debugInfo.Path, + debugInfo.KeyID, + debugInfo.Timestamp, + debugInfo.Nonce, + debugInfo.Signature, + ) +} + +func executeAPIKeyTokenExchange(server string, debugInfo *client.APIKeyTokenDebug) (map[string]any, error) { + httpClient := client.NewClient(server, "", 30*time.Second) + var response map[string]any + if err := httpClient.PostWithHeaders(debugInfo.Path, debugInfo.Headers(), &response); err != nil { + return nil, fmt.Errorf("execute token exchange: %w", err) + } + return response, nil +} + +func saveAPIKeyContext(server string, executeResp map[string]any) error { + ctxName := resolveContextNameForSave() + ctx := cfg.Contexts[ctxName] + ctx.Server = server + + data, ok := executeResp["data"].(map[string]any) + if !ok { + return fmt.Errorf("execute response does not contain a valid data payload") + } + + token, _ := data["token"].(string) + if strings.TrimSpace(token) == "" { + return fmt.Errorf("execute response does not contain a token") + } + ctx.Token = token + + if authType, _ := data["auth_type"].(string); strings.TrimSpace(authType) != "" { + ctx.AuthType = authType + } + if keyID, _ := data["key_id"].(string); strings.TrimSpace(keyID) != "" { + ctx.KeyID = keyID + } + if expiresAt, _ := data["expires_at"].(string); strings.TrimSpace(expiresAt) != "" { + parsed, err := time.Parse(time.RFC3339, expiresAt) + if err != nil { + return fmt.Errorf("parse expires_at: %w", err) + } + ctx.TokenExpiry = parsed + } + + cfg.Contexts[ctxName] = ctx + cfg.CurrentContext = ctxName + if err := config.SaveConfig(cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + + output.PrintInfo(fmt.Sprintf("Saved token to context %q", ctxName)) + return nil +} + +func resolveContextNameForSave() string { + if cfg != nil && strings.TrimSpace(cfg.CurrentContext) != "" { + return cfg.CurrentContext + } + return "default" +} + +func emptyOrValue(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} diff --git a/src/cmd/aegisctl/cmd/inject.go b/src/cmd/aegisctl/cmd/inject.go index bb6cadac..6b11d291 100644 --- a/src/cmd/aegisctl/cmd/inject.go +++ b/src/cmd/aegisctl/cmd/inject.go @@ -557,7 +557,9 @@ var injectDownloadCmd = &cobra.Command{ if err != nil { return fmt.Errorf("download request failed: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) @@ -568,7 +570,9 @@ var injectDownloadCmd = &cobra.Command{ if err != nil { return fmt.Errorf("create output file: %w", err) } - defer f.Close() + defer func() { + _ = f.Close() + }() n, err := io.Copy(f, resp.Body) if err != nil { diff --git a/src/cmd/aegisctl/cmd/root.go b/src/cmd/aegisctl/cmd/root.go index 82869698..7c95bc05 100644 --- a/src/cmd/aegisctl/cmd/root.go +++ b/src/cmd/aegisctl/cmd/root.go @@ -33,8 +33,8 @@ var rootCmd = &cobra.Command{ fault-injection and root-cause-analysis benchmarking platform. QUICK START: - # 1. Login (saves token to ~/.aegisctl/config.yaml) - aegisctl auth login --server http://HOST:8082 --username admin --password admin123 + # 1. Exchange Key ID / Key Secret for a token (saves token to ~/.aegisctl/config.yaml) + aegisctl auth login --server http://HOST:8082 --key-id pk_xxx --key-secret ks_xxx # 2. Set default project so you don't need --project every time aegisctl context set --name default --default-project pair_diagnosis @@ -68,11 +68,13 @@ OUTPUT: Use --quiet (-q) to suppress informational messages. ENVIRONMENT VARIABLES: - AEGIS_SERVER - Server URL (overridden by --server flag) - AEGIS_TOKEN - Auth token (overridden by --token flag) - AEGIS_PROJECT - Default project name (overridden by --project flag) - AEGIS_OUTPUT - Output format: table|json (overridden by --output flag) - AEGIS_TIMEOUT - Request timeout in seconds (overridden by --request-timeout flag) + AEGIS_SERVER - Server URL (overridden by --server flag) + AEGIS_TOKEN - Auth token (overridden by --token flag) + AEGIS_KEY_ID - API key ID for 'aegisctl auth login' + AEGIS_KEY_SECRET - API key secret for 'aegisctl auth login' + AEGIS_PROJECT - Default project name (overridden by --project flag) + AEGIS_OUTPUT - Output format: table|json (overridden by --output flag) + AEGIS_TIMEOUT - Request timeout in seconds (overridden by --request-timeout flag) NAMING CONVENTION: Most commands accept human-readable names instead of numeric IDs. @@ -142,7 +144,7 @@ NAMING CONVENTION: flagRequestTimeout = 30 } - // Wire quiet flag into output package. + // Forward quiet flag into the output package. output.Quiet = flagQuiet return nil diff --git a/src/cmd/aegisctl/cmd/wait.go b/src/cmd/aegisctl/cmd/wait.go index fa2860e5..c4d05cf7 100644 --- a/src/cmd/aegisctl/cmd/wait.go +++ b/src/cmd/aegisctl/cmd/wait.go @@ -121,11 +121,6 @@ func detectResourceType(c *client.Client, id string) (string, error) { return "", fmt.Errorf("lookup trace %s: %w", id, err) } -// stateResponse is a minimal struct to extract the state field from API responses. -type stateResponse struct { - State string `json:"state"` -} - // pollState fetches the current state and full data for the given resource. func pollState(c *client.Client, resourceType, id string) (string, any, error) { var path string diff --git a/src/cmd/aegisctl/config/config.go b/src/cmd/aegisctl/config/config.go index 605f9f9a..6313de22 100644 --- a/src/cmd/aegisctl/config/config.go +++ b/src/cmd/aegisctl/config/config.go @@ -20,10 +20,36 @@ type Config struct { type Context struct { Server string `yaml:"server"` Token string `yaml:"token,omitempty"` + AuthType string `yaml:"auth-type,omitempty"` + KeyID string `yaml:"key-id,omitempty"` DefaultProject string `yaml:"default-project,omitempty"` TokenExpiry time.Time `yaml:"token-expiry,omitempty"` } +func (c *Context) UnmarshalYAML(value *yaml.Node) error { + type rawContext struct { + Server string `yaml:"server"` + Token string `yaml:"token,omitempty"` + AuthType string `yaml:"auth-type,omitempty"` + KeyID string `yaml:"key-id,omitempty"` + DefaultProject string `yaml:"default-project,omitempty"` + TokenExpiry time.Time `yaml:"token-expiry,omitempty"` + } + + var raw rawContext + if err := value.Decode(&raw); err != nil { + return err + } + + c.Server = raw.Server + c.Token = raw.Token + c.AuthType = raw.AuthType + c.KeyID = raw.KeyID + c.DefaultProject = raw.DefaultProject + c.TokenExpiry = raw.TokenExpiry + return nil +} + // Preferences holds user-level defaults. type Preferences struct { Output string `yaml:"output,omitempty"` diff --git a/src/cmd/api-gateway/main.go b/src/cmd/api-gateway/main.go new file mode 100644 index 00000000..00f7df11 --- /dev/null +++ b/src/cmd/api-gateway/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "flag" + + gateway "aegis/app/gateway" + + "go.uber.org/fx" +) + +func main() { + conf := flag.String("conf", "/etc/rcabench/config.prod.toml", "path to configuration file") + port := flag.String("port", "8080", "port to run the API gateway on") + flag.Parse() + + fx.New(gateway.Options(*conf, *port)).Run() +} diff --git a/src/cmd/iam-service/main.go b/src/cmd/iam-service/main.go new file mode 100644 index 00000000..dbe1b5de --- /dev/null +++ b/src/cmd/iam-service/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "flag" + + iam "aegis/app/iam" + + "go.uber.org/fx" +) + +func main() { + conf := flag.String("conf", "/etc/rcabench/config.prod.toml", "path to configuration file") + flag.Parse() + + fx.New(iam.Options(*conf)).Run() +} diff --git a/src/cmd/orchestrator-service/main.go b/src/cmd/orchestrator-service/main.go new file mode 100644 index 00000000..123255fe --- /dev/null +++ b/src/cmd/orchestrator-service/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "flag" + + orchestrator "aegis/app/orchestrator" + + "go.uber.org/fx" +) + +func main() { + conf := flag.String("conf", "/etc/rcabench/config.prod.toml", "path to configuration file") + flag.Parse() + + fx.New(orchestrator.Options(*conf)).Run() +} diff --git a/src/cmd/resource-service/main.go b/src/cmd/resource-service/main.go new file mode 100644 index 00000000..06ffd811 --- /dev/null +++ b/src/cmd/resource-service/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "flag" + + resource "aegis/app/resource" + + "go.uber.org/fx" +) + +func main() { + conf := flag.String("conf", "/etc/rcabench/config.prod.toml", "path to configuration file") + flag.Parse() + + fx.New(resource.Options(*conf)).Run() +} diff --git a/src/cmd/runtime-worker-service/main.go b/src/cmd/runtime-worker-service/main.go new file mode 100644 index 00000000..8a122a6d --- /dev/null +++ b/src/cmd/runtime-worker-service/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "flag" + + runtimeapp "aegis/app/runtime" + + "go.uber.org/fx" +) + +func main() { + conf := flag.String("conf", "/etc/rcabench/config.prod.toml", "path to configuration file") + flag.Parse() + + fx.New(runtimeapp.Options(*conf)).Run() +} diff --git a/src/cmd/system-service/main.go b/src/cmd/system-service/main.go new file mode 100644 index 00000000..fa5cf539 --- /dev/null +++ b/src/cmd/system-service/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "flag" + + system "aegis/app/system" + + "go.uber.org/fx" +) + +func main() { + conf := flag.String("conf", "/etc/rcabench/config.prod.toml", "path to configuration file") + flag.Parse() + + fx.New(system.Options(*conf)).Run() +} diff --git a/src/config.dev.toml b/src/config.dev.toml index e13e2805..56bf931a 100644 --- a/src/config.dev.toml +++ b/src/config.dev.toml @@ -60,6 +60,36 @@ experiment_storage_path = "/mnt/jfs/experiment_storage" [buildkit] address = "localhost:1234" +[clients.iam] +target = "localhost:9091" + +[clients.orchestrator] +target = "localhost:9092" + +[clients.resource] +target = "localhost:9093" + +[clients.runtime] +target = "localhost:9094" + +[clients.system] +target = "localhost:9095" + +[iam.grpc] +addr = ":9091" + +[orchestrator.grpc] +addr = ":9092" + +[resource.grpc] +addr = ":9093" + +[runtime_worker.grpc] +addr = ":9094" + +[system.grpc] +addr = ":9095" + [loki] address = "http://10.10.10.161:3100" timeout = "10s" diff --git a/src/consts/consts.go b/src/consts/consts.go index 0eb43640..7bc7d407 100644 --- a/src/consts/consts.go +++ b/src/consts/consts.go @@ -187,6 +187,15 @@ func (ds DatapackState) MarshalJSON() ([]byte, error) { return json.Marshal(GetDatapackStateName(ds)) } +func (ds *DatapackState) UnmarshalJSON(data []byte) error { + var stateName string + if err := json.Unmarshal(data, &stateName); err != nil { + return err + } + *ds = *GetDatapackStateByName(stateName) + return nil +} + type ExecutionState int const ( @@ -314,15 +323,15 @@ const ( RestartFaultDuration = "fault_duration" RestartInjectPayload = "inject_payload" - InjectBenchmark = "benchmark_version" - InjectPreDuration = "pre_duration" - InjectNodes = "nodes" - InjectGuidedConfigs = "guided_configs" - InjectNamespace = "namespace" - InjectPedestal = "pedestal" - InjectPedestalID = "pedestal_id" - InjectLabels = "labels" - InjectSystem = "system" + InjectBenchmark = "benchmark_version" + InjectPreDuration = "pre_duration" + InjectNodes = "nodes" + InjectGuidedConfigs = "guided_configs" + InjectNamespace = "namespace" + InjectPedestal = "pedestal" + InjectPedestalID = "pedestal_id" + InjectLabels = "labels" + InjectSystem = "system" BuildBenchmark = "benchmark" BuildDatapack = "datapack" diff --git a/src/database/database.go b/src/database/database.go deleted file mode 100644 index 7a001b7a..00000000 --- a/src/database/database.go +++ /dev/null @@ -1,159 +0,0 @@ -package database - -import ( - "fmt" - "log" - "os" - "time" - - "aegis/config" - - "github.com/sirupsen/logrus" - - "gorm.io/driver/mysql" - "gorm.io/gorm" - "gorm.io/gorm/logger" - "gorm.io/plugin/opentelemetry/tracing" -) - -type DatabaseConfig struct { - Type string - Host string - Port int - User string - Password string - Database string - Timezone string -} - -func NewDatabaseConfig(databaseType string) *DatabaseConfig { - return &DatabaseConfig{ - Type: databaseType, - Host: config.GetString(fmt.Sprintf("database.%s.host", databaseType)), - Port: config.GetInt(fmt.Sprintf("database.%s.port", databaseType)), - User: config.GetString(fmt.Sprintf("database.%s.user", databaseType)), - Password: config.GetString(fmt.Sprintf("database.%s.password", databaseType)), - Database: config.GetString(fmt.Sprintf("database.%s.db", databaseType)), - Timezone: config.GetString(fmt.Sprintf("database.%s.timezone", databaseType)), - } -} - -func (d *DatabaseConfig) ToDSN() (string, error) { - if d.Type != "mysql" { - return "", fmt.Errorf("unsupported database type: %s", d.Type) - } - - dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", - d.User, d.Password, d.Host, d.Port, d.Database) - return dsn, nil -} - -// Global DB object -var DB *gorm.DB - -func InitDB() { - var err error - - mysqlConfig := NewDatabaseConfig("mysql") - - connectWithRetry(mysqlConfig) - - if err = DB.AutoMigrate( - // Core entities - &Container{}, - &ContainerVersion{}, - &HelmConfig{}, - &ParameterConfig{}, - &Dataset{}, - &DatasetVersion{}, - &Project{}, - &Label{}, - &User{}, - &Role{}, - &Permission{}, - &Resource{}, - &AuditLog{}, - - // Business entities - &Task{}, - &FaultInjection{}, - &Execution{}, - &DetectorResult{}, - &GranularityResult{}, - - // Many-to-many relationship tables - &ContainerLabel{}, - &DatasetLabel{}, - &ProjectLabel{}, - &ContainerVersionEnvVar{}, - &HelmConfigValue{}, - &DatasetVersionInjection{}, - &FaultInjectionLabel{}, - &ExecutionInjectionLabel{}, - &ConfigLabel{}, - - &UserContainer{}, - &UserDataset{}, - &UserProject{}, - &UserRole{}, - &RolePermission{}, - &UserPermission{}, - &UserTeam{}, - - // Dynamic configuration entities - &DynamicConfig{}, - &ConfigHistory{}, - - // Evaluation entities - &Evaluation{}, - - // System registration entities - &System{}, - &SystemMetadata{}, - ); err != nil { - logrus.Fatalf("Failed to migrate database: %v", err) - } - - createDetectorViews() -} - -func connectWithRetry(dbConfig *DatabaseConfig) { - maxRetries := 3 - retryDelay := 10 * time.Second - - dsn, err := dbConfig.ToDSN() - if err != nil { - logrus.Fatalf("Failed to construct DSN: %v", err) - } - - for i := 0; i <= maxRetries; i++ { - DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ - Logger: logger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), - logger.Config{ - SlowThreshold: time.Second, - LogLevel: logger.Warn, - IgnoreRecordNotFoundError: true, - Colorful: true, - }), - TranslateError: true, - }) - if err == nil { - logrus.Info("Successfully connected to the database") - if err := DB.Use(tracing.NewPlugin()); err != nil { - panic(err) - } - - break - } - - logrus.Errorf("Failed to connect to database (attempt %d/%d): %v", i+1, maxRetries+1, err) - if i < maxRetries { - logrus.Infof("Retrying in %v...", retryDelay) - time.Sleep(retryDelay) - } - } - - if err != nil { - logrus.Fatalf("Failed to connect to database after %d attempts: %v", maxRetries+1, err) - } -} diff --git a/src/database/scope.go b/src/database/scope.go deleted file mode 100644 index 506c4eb4..00000000 --- a/src/database/scope.go +++ /dev/null @@ -1,51 +0,0 @@ -package database - -import ( - "fmt" - - "gorm.io/gorm" -) - -// Fuzzy search Scope -func KeywordSearch(keyword string, fields ...string) func(db *gorm.DB) *gorm.DB { - return func(db *gorm.DB) *gorm.DB { - if keyword == "" { - return db - } - query := "" - for i, field := range fields { - if i > 0 { - query += " OR " - } - query += fmt.Sprintf("%s LIKE ?", field) - } - return db.Where(query, "%"+keyword+"%") - } -} - -func CursorPaginate(lastID uint, size int) func(db *gorm.DB) *gorm.DB { - return func(db *gorm.DB) *gorm.DB { - if lastID > 0 { - db = db.Where("id > ?", lastID) - } - return db.Limit(size) - } -} - -// Pagination Scope -func Paginate(pageNum, pageSize int) func(db *gorm.DB) *gorm.DB { - return func(db *gorm.DB) *gorm.DB { - offset := (pageNum - 1) * pageSize - return db.Offset(offset).Limit(pageSize) - } -} - -// Sort Scope -func Sort(sort string) func(db *gorm.DB) *gorm.DB { - return func(db *gorm.DB) *gorm.DB { - if sort == "" { - sort = "id desc" - } - return db.Order(sort) - } -} diff --git a/src/database/view.go b/src/database/view.go deleted file mode 100644 index 918f8bac..00000000 --- a/src/database/view.go +++ /dev/null @@ -1,120 +0,0 @@ -package database - -import ( - "time" - - chaos "github.com/OperationsPAI/chaos-experiment/handler" - "github.com/sirupsen/logrus" - "gorm.io/gorm" -) - -// FaultInjectionNoIssues view model -type FaultInjectionNoIssues struct { - ID int `gorm:"column:datapack_id"` - Name string `gorm:"column:datapack_name"` - FaultType chaos.ChaosType `gorm:"column:fault_type"` - Category chaos.SystemType `gorm:"column:category"` - EngineConfig string `gorm:"column:engine_config"` - LabelKey string `gorm:"column:label_key"` - LabelValue string `gorm:"column:value_key"` - CreatedAt time.Time `gorm:"column:created_at"` -} - -func (FaultInjectionNoIssues) TableName() string { - return "fault_injection_no_issues" -} - -// FaultInjectionWithIssues view model -type FaultInjectionWithIssues struct { - ID int `gorm:"column:datapack_id"` - Name string `gorm:"column:datapack_name"` - FaultType chaos.ChaosType `gorm:"column:fault_type"` - Category chaos.SystemType `gorm:"column:category"` - EngineConfig string `gorm:"column:engine_config"` - LabelKey string `gorm:"column:label_key"` - LabelValue string `gorm:"column:value_key"` - CreatedAt time.Time `gorm:"column:created_at"` - Issues string `gorm:"column:issues"` - AbnormalAvgDuration float64 `gorm:"column:abnormal_avg_duration"` - NormalAvgDuration float64 `gorm:"column:normal_avg_duration"` - AbnormalSuccRate float64 `gorm:"column:abnormal_succ_rate"` - NormalSuccRate float64 `gorm:"column:normal_succ_rate"` - AbnormalP99 float64 `gorm:"column:abnormal_p99"` - NormalP99 float64 `gorm:"column:normal_p99"` -} - -func (FaultInjectionWithIssues) TableName() string { - return "fault_injection_with_issues" -} - -func addDetectorJoins(query *gorm.DB) *gorm.DB { - return query. - Joins(`JOIN ( - SELECT - e.id, - c.id AS algorithm_id, - e.datapack_id, - ROW_NUMBER() OVER ( - PARTITION BY c.id, e.datapack_id - ORDER BY e.created_at DESC, e.id DESC - ) as rn - FROM executions e - JOIN container_versions cv ON e.algorithm_version_id = cv.id - JOIN containers c ON c.id = cv.container_id - WHERE e.state = 2 AND e.status = 1 AND c.id = ? - ) er_ranked ON fi.id = er_ranked.datapack_id AND er_ranked.rn = 1`, 1). - Joins("JOIN detector_results dr ON er_ranked.id = dr.execution_id") -} - -func createDetectorViews() { - var err error - - _ = DB.Migrator().DropView("fault_injection_no_issues") - _ = DB.Migrator().DropView("fault_injection_with_issues") - - // Create view for fault injections with no issues - noIssuesQuery := addDetectorJoins(DB.Table("fault_injections fi"). - Select(`DISTINCT - fi.id AS datapack_id, - fi.name AS name, - fi.fault_type AS fault_type, - fi.category AS category, - fi.engine_config AS engine_config, - l.label_key as label_key, - l.label_value as label_value, - fi.created_at`). - Joins("LEFT JOIN fault_injection_labels fil ON fil.fault_injection_id = fi.id"). - Joins("LEFT JOIN labels l ON fil.label_id = l.id"). - Group("fi.id, fi.name, fi.fault_type, fi.engine_config, fi.created_at, l.label_key, l.label_value"), - ).Where("dr.issues = '{}' OR dr.issues IS NULL") - if err = DB.Migrator().CreateView("fault_injection_no_issues", gorm.ViewOption{Query: noIssuesQuery}); err != nil { - logrus.Errorf("failed to create fault_injection_no_issues view: %v", err) - } - - // Create view for fault injections with issues - withIssuesQuery := addDetectorJoins(DB.Table("fault_injections fi"). - Select(`DISTINCT - fi.id AS datapack_id, - fi.name AS name, - fi.fault_type AS fault_type, - fi.category AS category, - fi.engine_config AS engine_config, - l.label_key as label_key, - l.label_value as label_value, - fi.created_at, - dr.issues, - dr.abnormal_avg_duration, - dr.normal_avg_duration, - dr.abnormal_succ_rate, - dr.normal_succ_rate, - dr.abnormal_p99, - dr.normal_p99`). - Joins("LEFT JOIN tasks t ON t.id = fi.task_id"). - Joins("LEFT JOIN fault_injection_labels fil ON fil.fault_injection_id = fi.id"). - Joins("LEFT JOIN labels l ON fil.label_id = l.id"). - Group("fi.id, fi.name, fi.fault_type, fi.engine_config, fi.created_at, l.label_key, l.label_value, dr.issues, dr.abnormal_avg_duration, dr.normal_avg_duration, dr.abnormal_succ_rate, dr.normal_succ_rate, dr.abnormal_p99, dr.normal_p99"), - ).Where("dr.issues != '{}' AND dr.issues IS NOT NULL") - if err = DB.Migrator().CreateView("fault_injection_with_issues", gorm.ViewOption{Query: withIssuesQuery}); err != nil { - logrus.Errorf("failed to create fault_injection_with_issues view: %v", err) - } -} diff --git a/src/docs/docs_test.go b/src/docs/docs_test.go new file mode 100644 index 00000000..e496af26 --- /dev/null +++ b/src/docs/docs_test.go @@ -0,0 +1,172 @@ +package docs_test + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +func TestGeneratedAPIDocsContainCorePaths(t *testing.T) { + baseDir := "." + + checkJSONContains(t, filepath.Join(baseDir, "openapi2", "swagger.json"), []string{ + `"/api/v2/auth/login"`, + `"/api/v2/users"`, + `"/api/v2/projects"`, + }) + checkJSONContains(t, filepath.Join(baseDir, "openapi3", "openapi.json"), []string{ + `"/api/v2/auth/login"`, + `"/api/v2/projects"`, + `"/api/v2/users"`, + }) + checkJSONContains(t, filepath.Join(baseDir, "converted", "sdk.json"), []string{ + `"/api/v2/auth/api-key/token"`, + `"/api/v2/sdk/evaluations"`, + }) + checkJSONContains(t, filepath.Join(baseDir, "converted", "runtime.json"), []string{ + `"/api/v2/executions/{execution_id}/detector_results"`, + `"/api/v2/executions/{execution_id}/granularity_results"`, + }) +} + +func TestAudienceFilteredDocsMatchOpenAPI3Extensions(t *testing.T) { + openapi := readJSON(t, filepath.Join(".", "openapi3", "openapi.json")) + + checkAudienceMatches(t, openapi, filepath.Join(".", "converted", "sdk.json"), "sdk") + checkAudienceMatches(t, openapi, filepath.Join(".", "converted", "runtime.json"), "runtime") + checkAudienceMatches(t, openapi, filepath.Join(".", "converted", "portal.json"), "portal") + checkAudienceMatches(t, openapi, filepath.Join(".", "converted", "admin.json"), "admin") +} + +func checkJSONContains(t *testing.T, path string, fragments []string) { + t.Helper() + + data := readJSONBytes(t, path) + text := string(data) + for _, fragment := range fragments { + if !strings.Contains(text, fragment) { + t.Fatalf("expected %s to contain %s", path, fragment) + } + } +} + +func checkAudienceMatches(t *testing.T, openapi map[string]any, filteredPath string, audience string) { + t.Helper() + checkAudienceMatchesAny(t, openapi, filteredPath, []string{audience}) +} + +func checkAudienceMatchesAny(t *testing.T, openapi map[string]any, filteredPath string, audiences []string) { + t.Helper() + + filtered := readJSON(t, filteredPath) + want := collectAudienceOperations(t, openapi, audiences) + got := collectOperationsFromDoc(t, filtered) + + if len(want) != len(got) { + t.Fatalf("expected %s to have %d operations, got %d", filteredPath, len(want), len(got)) + } + if strings.Join(want, "\n") != strings.Join(got, "\n") { + t.Fatalf("unexpected operations in %s\nwant:\n%s\n\ngot:\n%s", filteredPath, strings.Join(want, "\n"), strings.Join(got, "\n")) + } +} + +func collectAudienceOperations(t *testing.T, doc map[string]any, audiences []string) []string { + t.Helper() + + paths, ok := doc["paths"].(map[string]any) + if !ok { + t.Fatalf("paths is not an object") + } + + audienceSet := make(map[string]struct{}, len(audiences)) + for _, audience := range audiences { + audienceSet[audience] = struct{}{} + } + + var operations []string + for path, opsValue := range paths { + ops, ok := opsValue.(map[string]any) + if !ok { + continue + } + for method, specValue := range ops { + spec, ok := specValue.(map[string]any) + if !ok { + continue + } + xAPIType, _ := spec["x-api-type"].(map[string]any) + for audience := range audienceSet { + if isAudienceEnabled(xAPIType, audience) { + operations = append(operations, strings.ToUpper(method)+" "+path) + break + } + } + } + } + + sort.Strings(operations) + return operations +} + +func isAudienceEnabled(xAPIType map[string]any, audience string) bool { + value, ok := xAPIType[audience] + if !ok { + return false + } + + switch typed := value.(type) { + case string: + return strings.EqualFold(strings.TrimSpace(typed), "true") + case bool: + return typed + default: + return false + } +} + +func collectOperationsFromDoc(t *testing.T, doc map[string]any) []string { + t.Helper() + + paths, ok := doc["paths"].(map[string]any) + if !ok { + t.Fatalf("paths is not an object") + } + + var operations []string + for path, opsValue := range paths { + ops, ok := opsValue.(map[string]any) + if !ok { + continue + } + for method := range ops { + operations = append(operations, strings.ToUpper(method)+" "+path) + } + } + + sort.Strings(operations) + return operations +} + +func readJSON(t *testing.T, path string) map[string]any { + t.Helper() + + data := readJSONBytes(t, path) + var body map[string]any + if err := json.Unmarshal(data, &body); err != nil { + t.Fatalf("unmarshal %s: %v", path, err) + } + return body +} + +func readJSONBytes(t *testing.T, path string) []byte { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return data +} diff --git a/src/dto/analyzer.go b/src/dto/analyzer.go deleted file mode 100644 index 850b8b0b..00000000 --- a/src/dto/analyzer.go +++ /dev/null @@ -1,64 +0,0 @@ -package dto - -import ( - "fmt" - - "aegis/consts" -) - -var ValidFirstTaskTypes = map[consts.TaskType]struct{}{ - consts.TaskTypeBuildContainer: {}, - consts.TaskTypeRestartPedestal: {}, - consts.TaskTypeBuildDatapack: {}, - consts.TaskTypeRunAlgorithm: {}, -} - -type AnalyzeTracesReq struct { - FirstTaskType *consts.TaskType `form:"first_task_type" binding:"omitempty"` - - TimeRangeQuery -} - -func (req *AnalyzeTracesReq) Validate() error { - if req.FirstTaskType != nil { - if _, exists := ValidFirstTaskTypes[*req.FirstTaskType]; !exists { - return fmt.Errorf("invalid event name: %d", req.FirstTaskType) - } - } - - return req.TimeRangeQuery.Validate() -} - -type PairStats struct { - Name string - InDegree int - OutDegree int -} - -type ServiceCoverageItem struct { - Num int - NotCovered []string - Coverage float64 -} - -type AttributeCoverageItem struct { - Num int - Coverage float64 -} - -type InjectionDiversity struct { - FaultDistribution map[string]int `json:"fault_distribution"` - ServiceDistribution map[string]int `json:"service_distribution"` - PairDistribution []PairStats `json:"pair_distribution"` - ServiceCoverages map[string]ServiceCoverageItem `json:"fault_service_coverages"` - AttributeCoverages map[string]map[string]AttributeCoverageItem `json:"attribute_coverages"` -} - -type InjectionStats struct { - Diversity InjectionDiversity `json:"diversity"` -} - -type AnalyzeInjectionsResp struct { - Efficiency string `json:"efficiency"` - Stats map[string]InjectionStats `json:"stats"` -} diff --git a/src/dto/audit.go b/src/dto/audit.go deleted file mode 100644 index b27450cc..00000000 --- a/src/dto/audit.go +++ /dev/null @@ -1,133 +0,0 @@ -package dto - -import ( - "aegis/consts" - "aegis/database" - "fmt" - "time" -) - -type ListAuditLogFilters struct { - Action string - IpAddress string - UserID int - ResourceID int - State *consts.AuditLogState - Status *consts.StatusType - StartTime *time.Time - EndTime *time.Time -} - -type ListAuditLogReq struct { - PaginationReq - - Action string `form:"action" binding:"omitempty"` - IPAddress string `form:"ip_address" binding:"omitempty"` - UserID int `form:"user_id" binding:"omitempty"` - ResourceID int `form:"resource_id" binding:"omitempty"` - State *consts.AuditLogState `form:"state" binding:"omitempty"` - Status *consts.StatusType `form:"status" binding:"omitempty"` - StartDate string `form:"start_date" binding:"omitempty"` - EndDate string `form:"end_date" binding:"omitempty"` -} - -func (req *ListAuditLogReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - if req.StartDate != "" { - if err := validateTimeField(req.StartDate, time.DateOnly); err != nil { - return fmt.Errorf("invalid start_time: %w", err) - } - } - if req.EndDate != "" { - if err := validateTimeField(req.EndDate, time.DateOnly); err != nil { - return fmt.Errorf("invalid end_time: %w", err) - } - } - - if _, exists := consts.ValidAuditLogStates[*req.State]; !exists { - return fmt.Errorf("invalid state: %d", *req.State) - } - - return validateStatusField(req.Status, false) -} - -func (req *ListAuditLogReq) ToFilterOptions() *ListAuditLogFilters { - var startTimePtr, endTimePtr *time.Time - - if req.StartDate != "" { - startTime, _ := time.Parse(time.DateTime, req.StartDate) - startTimePtr = &startTime - } - - if req.EndDate != "" { - endTime, _ := time.Parse(time.DateTime, req.EndDate) - endTimePtr = &endTime - } - - return &ListAuditLogFilters{ - Action: req.Action, - IpAddress: req.IPAddress, - UserID: req.UserID, - ResourceID: req.ResourceID, - State: req.State, - Status: req.Status, - StartTime: startTimePtr, - EndTime: endTimePtr, - } -} - -// AuditLogResp represents a summarized view of an audit log -type AuditLogResp struct { - ID int `json:"id"` - Action string `json:"action"` - IPAddress string `json:"ip_address"` - Duration int `json:"duration"` - UserAgent string `json:"user_agent"` - UserID int `json:"user_id,omitempty"` - Username string `json:"username,omitempty"` - ResourceID int `json:"resource_id,omitempty"` - Resource consts.ResourceName `json:"resource,omitempty"` - State string `json:"state"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` -} - -func NewAuditLogResp(log *database.AuditLog) *AuditLogResp { - resp := &AuditLogResp{ - ID: log.ID, - Action: log.Action, - IPAddress: log.IPAddress, - Duration: log.Duration, - UserAgent: log.UserAgent, - UserID: log.UserID, - ResourceID: log.ResourceID, - State: consts.GetAuditLogStateName(log.State), - Status: consts.GetStatusTypeName(log.Status), - CreatedAt: log.CreatedAt, - } - - if log.User != nil { - resp.Username = log.User.Username - } - if log.Resource != nil { - resp.Resource = log.Resource.Name - } - return resp -} - -// AuditLogDetailResp extends AuditLogResp with Details and ErrorMsg -type AuditLogDetailResp struct { - AuditLogResp - Details string `json:"details"` - ErrorMsg string `json:"error_msg,omitempty"` -} - -func NewAuditLogDetailResp(log *database.AuditLog) *AuditLogDetailResp { - return &AuditLogDetailResp{ - AuditLogResp: *NewAuditLogResp(log), - Details: log.Details, - ErrorMsg: log.ErrorMsg, - } -} diff --git a/src/dto/auth.go b/src/dto/auth.go deleted file mode 100644 index b9b3718f..00000000 --- a/src/dto/auth.go +++ /dev/null @@ -1,119 +0,0 @@ -package dto - -import ( - "fmt" - "regexp" - "time" - - "aegis/database" -) - -const ( - usernamePattern = `^[a-zA-Z0-9_]{3,20}$` -) - -// RegisterReq represents user registration request -type RegisterReq struct { - Username string `json:"username" binding:"required" example:"newuser"` - Email string `json:"email" binding:"required,email" example:"user@example.com"` - Password string `json:"password" binding:"required,min=8" example:"password123"` -} - -// Validate validates the registration request -func (req *RegisterReq) Validate() error { - // Username validation - usernameRegex := regexp.MustCompile(usernamePattern) - if !usernameRegex.MatchString(req.Username) { - return fmt.Errorf("username must be 3-20 characters and contain only letters, numbers, and underscores") - } - - // Password validation - if len(req.Password) == 0 { - return fmt.Errorf("password is required") - } - if len(req.Password) < 8 { - return fmt.Errorf("password must be at least 8 characters long") - } - - return nil -} - -// LoginReq represents user login request -type LoginReq struct { - Username string `json:"username" binding:"required" example:"admin"` - Password string `json:"password" binding:"required" example:"password123"` -} - -func (req *LoginReq) Validate() error { - usernameRegex := regexp.MustCompile(usernamePattern) - if !usernameRegex.MatchString(req.Username) { - return fmt.Errorf("invalid username or password") - } - if req.Password == "" { - return fmt.Errorf("invalid username or password") - } - return nil -} - -// TokenRefreshReq represents token refresh request -type TokenRefreshReq struct { - Token string `json:"token" binding:"required" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."` -} - -func (req *TokenRefreshReq) Validate() error { - if req.Token == "" { - return fmt.Errorf("invalid token") - } - return nil -} - -// ChangePasswordReq represents password change request -type ChangePasswordReq struct { - OldPassword string `json:"old_password" binding:"required" example:"oldpassword123"` - NewPassword string `json:"new_password" binding:"required,min=8" example:"newpassword123"` -} - -func (req *ChangePasswordReq) Validate() error { - if req.OldPassword == "" { - return fmt.Errorf("old_password is required") - } - if len(req.OldPassword) < 8 { - return fmt.Errorf("old_password must be at least 8 characters long") - } - if req.NewPassword == "" { - return fmt.Errorf("new_password is required") - } - if len(req.NewPassword) < 8 { - return fmt.Errorf("new_password must be at least 8 characters long") - } - return nil -} - -// LoginResp represents user login response -type LoginResp struct { - Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."` - ExpiresAt time.Time `json:"expires_at" example:"2024-12-31T23:59:59Z"` - User UserInfo `json:"user"` -} - -// TokenRefreshResp represents token refresh response -type TokenRefreshResp struct { - Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."` - ExpiresAt time.Time `json:"expires_at" example:"2024-12-31T23:59:59Z"` -} - -// UserInfo represents basic user information -type UserInfo struct { - ID int `json:"id" example:"1"` - Username string `json:"username" example:"admin"` - Avatar string `json:"avatar,omitempty"` - Role string `json:"role,omitempty"` -} - -func NewUserInfo(user *database.User) *UserInfo { - return &UserInfo{ - ID: user.ID, - Username: user.Username, - Avatar: user.Avatar, - } -} diff --git a/src/dto/common.go b/src/dto/common.go index a5ecfa5e..3eb5541f 100644 --- a/src/dto/common.go +++ b/src/dto/common.go @@ -3,8 +3,6 @@ package dto import ( "aegis/consts" "fmt" - "strings" - "time" ) // PaginationInfo represents pagination information in responses @@ -69,74 +67,3 @@ type SortField struct { Field string `json:"field" binding:"required" example:"created_at"` Order string `json:"order" binding:"required,oneof=asc desc" example:"desc"` } - -// validateLabelItemsFiled validates a list of LabelItem structs -func validateLabelItemsFiled(labelItems []LabelItem) error { - for i, label := range labelItems { - if strings.TrimSpace(label.Key) == "" { - return fmt.Errorf("empty label key at index %d", i) - } - if strings.TrimSpace(label.Value) == "" { - return fmt.Errorf("empty label value at index %d", i) - } - } - return nil -} - -// validateLabelField validates a list of label strings in "key:value" format -func validateLabelsField(labelStrs []string) error { - for _, labelStr := range labelStrs { - if strings.TrimSpace(labelStr) == "" { - return fmt.Errorf("labels must not contain empty strings") - } - - parts := strings.SplitN(labelStr, ":", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid label format '%s'. Must be in 'key:value' format", labelStr) - } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - - if key == "" { - return fmt.Errorf("label key in '%s' cannot be empty", labelStr) - } - - if value == "" { - return fmt.Errorf("label value for key '%s' cannot be empty", key) - } - } - - return nil -} - -// validateStatusField validates a status field pointer -func validateStatusField(statusPtr *consts.StatusType, isMutation bool) error { - if statusPtr == nil { - return nil - } - - status := *statusPtr - - if _, exists := consts.ValidStatuses[status]; !exists { - return fmt.Errorf("invalid status value: %d", status) - } - - if isMutation && status == consts.CommonDeleted { - return fmt.Errorf("status value cannot be set to deleted (%d) directly through this update/create operation", consts.CommonDeleted) - } - - return nil -} - -// validateTimeField checks if the provided time string is in the specific format -func validateTimeField(timeStr, timeFormat string) error { - if timeStr == "" { - return nil - } - _, err := time.Parse(timeFormat, timeStr) - if err != nil { - return fmt.Errorf("invalid time format: %s", timeStr) - } - return nil -} diff --git a/src/dto/container.go b/src/dto/container.go index ecbe0760..302fa957 100644 --- a/src/dto/container.go +++ b/src/dto/container.go @@ -2,13 +2,10 @@ package dto import ( "fmt" - "net/url" "path/filepath" "strings" - "time" - "aegis/consts" - "aegis/database" + "aegis/model" "aegis/utils" ) @@ -23,10 +20,6 @@ type ParameterItem struct { TemplateString string `json:"template_string,omitempty"` } -// ===================================================================== -// Container Service DTOs -// ===================================================================== - type HelmConfigItem struct { Version string `json:"version"` RepoURL string `json:"repo_url"` @@ -37,7 +30,7 @@ type HelmConfigItem struct { DynamicValues []ParameterItem `json:"values,omitempty"` } -func NewHelmConfigItem(cfg *database.HelmConfig) *HelmConfigItem { +func NewHelmConfigItem(cfg *model.HelmConfig) *HelmConfigItem { return &HelmConfigItem{ Version: cfg.Version, RepoURL: cfg.RepoURL, @@ -48,38 +41,31 @@ func NewHelmConfigItem(cfg *database.HelmConfig) *HelmConfigItem { } } -// GetValuesMap constructs a nested map of Helm values by merging +// GetValuesMap constructs a nested map of Helm values by merging file and dynamic values. func (hci *HelmConfigItem) GetValuesMap() map[string]any { root := make(map[string]any) - // Load values from ValueFile if it exists if hci.ValueFile != "" { if fileValues, err := utils.LoadYAMLFile(hci.ValueFile); err == nil { root = fileValues } } - // Merge dynamic values (override file values) for _, item := range hci.DynamicValues { value := item.Value - keys := utils.ParseHelmKey(item.Key) cur := root for i, k := range keys { if i == len(keys)-1 { - // Last key - set the value if k.IsArray { - // Handle array index if arr, ok := cur[k.Key].([]any); ok { - // Extend array if needed for len(arr) <= k.Index { arr = append(arr, make(map[string]any)) } arr[k.Index] = value cur[k.Key] = arr } else { - // Create new array arr := make([]any, k.Index+1) for j := 0; j < k.Index; j++ { arr[j] = make(map[string]any) @@ -93,11 +79,8 @@ func (hci *HelmConfigItem) GetValuesMap() map[string]any { break } - // Handle intermediate keys if k.IsArray { - // Current key is an array if _, exists := cur[k.Key]; !exists { - // Create new array arr := make([]any, k.Index+1) for j := 0; j <= k.Index; j++ { arr[j] = make(map[string]any) @@ -106,7 +89,6 @@ func (hci *HelmConfigItem) GetValuesMap() map[string]any { } if arr, ok := cur[k.Key].([]any); ok { - // Extend array if needed for len(arr) <= k.Index { arr = append(arr, make(map[string]any)) } @@ -115,21 +97,18 @@ func (hci *HelmConfigItem) GetValuesMap() map[string]any { if nextMap, ok := arr[k.Index].(map[string]any); ok { cur = nextMap } else { - // Create new map at this index newMap := make(map[string]any) arr[k.Index] = newMap cur = newMap } } } else { - // Regular key if _, exists := cur[k.Key]; !exists { cur[k.Key] = make(map[string]any) } if nextMap, ok := cur[k.Key].(map[string]any); ok { cur = nextMap } else { - // If the path exists but is not a map, replace it with a map newMap := make(map[string]any) cur[k.Key] = newMap cur = newMap @@ -154,7 +133,7 @@ type ContainerVersionItem struct { Extra *HelmConfigItem `json:"extra,omitempty"` } -func NewContainerVersionItem(version *database.ContainerVersion) ContainerVersionItem { +func NewContainerVersionItem(version *model.ContainerVersion) ContainerVersionItem { item := ContainerVersionItem{ ID: version.ID, Name: version.Name, @@ -190,7 +169,7 @@ func (ref *ContainerRef) Validate() error { type ContainerSpec struct { ContainerRef EnvVars []ParameterSpec `json:"env_vars" binding:"omitempty"` - Payload map[string]any `json:"payload,omitempty" swaggertype:"object"` // Additional payload data + Payload map[string]any `json:"payload,omitempty" swaggertype:"object"` } func (item *ContainerSpec) Validate() error { @@ -205,347 +184,11 @@ func (item *ContainerSpec) Validate() error { return nil } -// ===================================================================== -// Container CRUD DTOs -// ===================================================================== - -type CreateContainerReq struct { - Name string `json:"name" binding:"required"` - Type *consts.ContainerType `json:"type"` - README string `json:"readme" binding:"omitempty"` - IsPublic *bool `json:"is_public"` - - VersionReq *CreateContainerVersionReq `json:"version" binding:"omitempty"` -} - -func (req *CreateContainerReq) Validate() error { - req.Name = strings.TrimSpace(req.Name) - - if req.Name == "" { - return fmt.Errorf("container name cannot be empty") - } - if req.IsPublic == nil { - req.IsPublic = utils.BoolPtr(true) - } - - if req.Type == nil { - return fmt.Errorf("container type is required") - } - if err := validateContainerType(req.Type); err != nil { - return err - } - - if req.VersionReq != nil { - if err := req.VersionReq.Validate(); err != nil { - return fmt.Errorf("invalid container version request: %v", err) - } - } - - return nil -} - -func (req *CreateContainerReq) ConvertToContainer() *database.Container { - container := &database.Container{ - Name: req.Name, - Type: *req.Type, - README: req.README, - IsPublic: *req.IsPublic, - Status: consts.CommonEnabled, - } - - if req.VersionReq != nil { - container.Versions = []database.ContainerVersion{ - *req.VersionReq.ConvertToContainerVersion(), - } - } - - return container -} - type ParameterSpec struct { Key string `json:"key"` Value any `json:"value,omitempty"` } -type CreateContainerVersionReq struct { - Name string `json:"name" binding:"required"` - GithubLink string `json:"github_link" binding:"omitempty"` - ImageRef string `json:"image_ref" binding:"required"` - Command string `json:"command" binding:"omitempty"` - EnvVarRequests []CreateParameterConfigReq `json:"env_vars" binding:"omitempty"` - HelmConfigRequest *CreateHelmConfigReq `json:"helm_config" binding:"omitempty"` -} - -func (req *CreateContainerVersionReq) Validate() error { - req.Name = strings.TrimSpace(req.Name) - req.ImageRef = strings.TrimSpace(req.ImageRef) - - if req.Name == "" { - return fmt.Errorf("name cannot be empty") - } - if req.ImageRef == "" { - return fmt.Errorf("docker image reference cannot be empty") - } - - if req.GithubLink != "" { - req.GithubLink = strings.TrimSpace(req.GithubLink) - if err := utils.IsValidGitHubLink(req.GithubLink); err != nil { - return fmt.Errorf("invalid github link: %s, %v", req.GithubLink, err) - } - } - if _, _, _, err := utils.ParseSemanticVersion(req.Name); err != nil { - return fmt.Errorf("invalid semantic version: %s, %v", req.Name, err) - } - if _, _, _, _, err := utils.ParseFullImageRefernce(req.ImageRef); err != nil { - return fmt.Errorf("invalid docker image reference: %s, %v", req.ImageRef, err) - } - - for idx, envVarReq := range req.EnvVarRequests { - if err := envVarReq.Validate(); err != nil { - return fmt.Errorf("invalid env var at index %d: %v", idx, err) - } - } - - if req.HelmConfigRequest != nil { - if err := req.HelmConfigRequest.Validate(); err != nil { - return fmt.Errorf("invalid helm config: %v", err) - } - } - - return nil -} - -func (req *CreateContainerVersionReq) ConvertToContainerVersion() *database.ContainerVersion { - version := &database.ContainerVersion{ - Name: req.Name, - ImageRef: req.ImageRef, - Command: req.Command, - Status: consts.CommonEnabled, - } - - if len(req.EnvVarRequests) > 0 { - params := make([]database.ParameterConfig, 0, len(req.EnvVarRequests)) - for _, envVarReq := range req.EnvVarRequests { - params = append(params, *envVarReq.ConvertToParameterConfig()) - } - version.EnvVars = params - } - - if req.HelmConfigRequest != nil { - version.HelmConfig = req.HelmConfigRequest.ConvertToHelmConfig() - } - - return version -} - -type CreateHelmConfigReq struct { - Version string `json:"version" binding:"required"` - ChartName string `json:"chart_name" binding:"required"` - RepoName string `json:"repo_name" binding:"required"` - RepoURL string `json:"repo_url" binding:"required"` - DynamicValues []CreateParameterConfigReq `json:"dynamic_values" binding:"omitempty" swaggertype:"object"` -} - -func (req *CreateHelmConfigReq) Validate() error { - req.Version = strings.TrimSpace(req.Version) - req.ChartName = strings.TrimSpace(req.ChartName) - req.RepoName = strings.TrimSpace(req.RepoName) - req.RepoURL = strings.TrimSpace(req.RepoURL) - - if req.Version == "" { - if _, _, _, err := utils.ParseSemanticVersion(req.Version); err != nil { - return fmt.Errorf("invalid semantic version: %s, %v", req.Version, err) - } - } - if req.ChartName == "" { - return fmt.Errorf("chart name cannot be empty") - } - if req.RepoName == "" { - return fmt.Errorf("repository name cannot be empty") - } - if req.RepoURL == "" { - return fmt.Errorf("repository URL cannot be empty") - } - - if _, err := url.ParseRequestURI(req.RepoURL); err != nil { - return fmt.Errorf("invalid repository URL: %s, %w", req.RepoURL, err) - } - - for i, val := range req.DynamicValues { - if err := val.Validate(); err != nil { - return fmt.Errorf("invalid parameter config at index %d: %w", i, err) - } - } - - return nil -} - -func (req *CreateHelmConfigReq) ConvertToHelmConfig() *database.HelmConfig { - cfg := &database.HelmConfig{ - Version: req.Version, - ChartName: req.ChartName, - RepoName: req.RepoName, - RepoURL: req.RepoURL, - } - - if len(req.DynamicValues) > 0 { - params := make([]database.ParameterConfig, 0, len(req.DynamicValues)) - for _, val := range req.DynamicValues { - params = append(params, *val.ConvertToParameterConfig()) - } - cfg.DynamicValues = params - } - - return cfg -} - -type CreateParameterConfigReq struct { - Key string `json:"key" binding:"required"` - Type consts.ParameterType `json:"type" binding:"required"` - Category consts.ParameterCategory `json:"category" binding:"required"` - ValueType consts.ValueDataType `json:"value_type" binding:"omitempty"` - Description string `json:"description" binding:"omitempty"` - DefaultValue *string `json:"default_value" binding:"omitempty"` - TemplateString *string `json:"template_string" binding:"omitempty"` - Required bool `json:"required"` - Overridable *bool `json:"overridable" binding:"omitempty"` -} - -func (req *CreateParameterConfigReq) Validate() error { - if req.Key == "" { - return fmt.Errorf("parameter key cannot be empty") - } - - if _, exists := consts.ValidParameterTypes[req.Type]; !exists { - return fmt.Errorf("invalid parameter type: %v", req.Type) - } - if _, exists := consts.ValidParameterCategories[req.Category]; !exists { - return fmt.Errorf("invalid parameter category: %v", req.Category) - } - - if req.Type == consts.ParameterTypeFixed && req.Required && req.DefaultValue == nil { - return fmt.Errorf("default value is required for fixed parameter type when marked as required") - } - - if req.Type == consts.ParameterTypeDynamic && req.TemplateString == nil { - return fmt.Errorf("template string is required for dynamic parameter type") - } - - return nil -} - -func (req *CreateParameterConfigReq) ConvertToParameterConfig() *database.ParameterConfig { - config := &database.ParameterConfig{ - Key: req.Key, - Type: req.Type, - Category: req.Category, - ValueType: req.ValueType, - Description: req.Description, - DefaultValue: req.DefaultValue, - TemplateString: req.TemplateString, - Required: req.Required, - Overridable: true, // default to true - } - - // If overridable is explicitly set, use that value - if req.Overridable != nil { - config.Overridable = *req.Overridable - } - - return config -} - -// ListContainerReq represents container list query parameters -type ListContainerReq struct { - PaginationReq - Type *consts.ContainerType `form:"type"` - IsPublic *bool `form:"is_public"` - Status *consts.StatusType `form:"status"` -} - -func (req *ListContainerReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - if err := validateContainerType(req.Type); err != nil { - return err - } - return validateStatusField(req.Status, false) -} - -// ListContainerVersionReq represents container version list query parameters -type ListContainerVersionReq struct { - PaginationReq - Status *consts.StatusType `json:"status" binding:"omitempty"` -} - -func (req *ListContainerVersionReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - return validateStatusField(req.Status, false) -} - -// SearchContainerReq represents container search request -type SearchContainerReq struct { - AdvancedSearchReq[string] - - // Container-specific filters - Name *string `json:"name,omitempty"` - Image *string `json:"image,omitempty"` - Tag *string `json:"tag,omitempty"` - Type *string `json:"type,omitempty"` - Command *string `json:"command,omitempty"` - Status *int `json:"status,omitempty"` -} - -// ConvertToSearchRequest converts SearchContainerReq to SearchRequest -func (csr *SearchContainerReq) ConvertToSearchRequest() *SearchReq[string] { - sr := csr.ConvertAdvancedToSearch() - - // Add container-specific filters - if csr.Name != nil { - sr.AddFilter("name", OpLike, *csr.Name) - } - if csr.Image != nil { - sr.AddFilter("image", OpLike, *csr.Image) - } - if csr.Tag != nil { - sr.AddFilter("tag", OpEqual, *csr.Tag) - } - if csr.Type != nil { - sr.AddFilter("type", OpEqual, *csr.Type) - } - if csr.Command != nil { - sr.AddFilter("command", OpLike, *csr.Command) - } - - return sr -} - -// UpdateContainerReq represents the request for updating a container -type UpdateContainerReq struct { - README *string `json:"readme" binding:"omitempty"` - IsPublic *bool `json:"is_public" binding:"omitempty"` - Status *consts.StatusType `json:"status" binding:"omitempty"` -} - -func (req *UpdateContainerReq) Validate() error { - return validateStatusField(req.Status, true) -} - -func (req *UpdateContainerReq) PatchContainerModel(target *database.Container) { - if req.README != nil { - target.README = *req.README - } - if req.IsPublic != nil { - target.IsPublic = *req.IsPublic - } - if req.Status != nil { - target.Status = *req.Status - } -} - type BuildOptions struct { ContextDir string `json:"context_dir" binding:"omitempty" default:"."` DockerfilePath string `json:"dockerfile_path" binding:"omitempty" default:"Dockerfile"` @@ -598,434 +241,3 @@ func (opts *BuildOptions) ValidateRequiredFiles(sourcePath string) error { return nil } - -// SubmitBuildContainerReq represents the request for building a container into platform registry -type SubmitBuildContainerReq struct { - // Container Meta - ImageName string `json:"image_name" binding:"required"` - Tag string `json:"tag" binding:"omitempty"` - - // GitHub repository information - GithubRepository string `json:"github_repository" binding:"required"` - GithubBranch string `json:"github_branch" binding:"omitempty"` - GithubCommit string `json:"github_commit" binding:"omitempty"` - GithubToken string `json:"github_token" binding:"omitempty"` - SubPath string `json:"sub_path" binding:"omitempty"` - - Options *BuildOptions `json:"build_options" binding:"omitempty"` -} - -func (req *SubmitBuildContainerReq) Validate() error { - req.ImageName = strings.TrimSpace(req.ImageName) - req.GithubRepository = strings.TrimSpace(req.GithubRepository) - - if req.ImageName == "" { - return fmt.Errorf("container image name cannot be empty") - } - if req.Tag != "" { - req.Tag = strings.TrimSpace(req.Tag) - } - parts := strings.Split(req.GithubRepository, "/") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return fmt.Errorf("invalid repository format, expected 'owner/repo'") - } - if req.GithubBranch != "" { - req.GithubBranch = strings.TrimSpace(req.GithubBranch) - if err := utils.IsValidGitHubBranch(req.GithubBranch); err != nil { - return err - } - } - if req.GithubCommit != "" { - req.GithubCommit = strings.TrimSpace(req.GithubCommit) - if err := utils.IsValidGitHubCommit(req.GithubCommit); err != nil { - return err - } - } - if req.GithubToken != "" { - req.GithubToken = strings.TrimSpace(req.GithubToken) - if err := utils.IsValidGitHubToken(req.GithubToken); err != nil { - return err - } - } - - if req.Tag == "" { - req.Tag = "latest" - } - if req.GithubBranch == "" { - req.GithubBranch = "main" - } - if req.SubPath == "" { - req.SubPath = "." - } - - return req.Options.Validate() -} - -func (req *SubmitBuildContainerReq) ValidateInfoContent(sourcePath string) error { - if req.ImageName == "" { - tomlPath := filepath.Join(sourcePath, InfoFileName) - content, err := utils.ReadTomlFile(tomlPath) - if err != nil { - return err - } - - if name, ok := content[InfoNameField].(string); ok && name != "" { - req.ImageName = name - } else { - return fmt.Errorf("%s does not contain a valid name field", InfoFileName) - } - } - - return nil -} - -// UpdateContainerVersionReq represents the request for updating a container version -type UpdateContainerVersionReq struct { - GithubLink *string `json:"github_link" binding:"omitempty"` - Command *string `json:"command" binding:"omitempty"` - Status *consts.StatusType `json:"status" binding:"omitempty"` - HelmConfigRequest *UpdateHelmConfigReq `json:"helm_config" binding:"omitempty"` -} - -func (req *UpdateContainerVersionReq) Validate() error { - if req.GithubLink != nil { - trimmedLink := strings.TrimSpace(*req.GithubLink) - *req.GithubLink = trimmedLink - - if trimmedLink != "" { - if err := utils.IsValidGitHubLink(trimmedLink); err != nil { - return fmt.Errorf("invalid GitHub link '%s': %v", trimmedLink, err) - } - } - } - if req.Command != nil { - *req.Command = strings.TrimSpace(*req.Command) - } - if req.Status != nil { - if err := validateStatusField(req.Status, true); err != nil { - return err - } - } - - if req.HelmConfigRequest != nil { - if err := req.HelmConfigRequest.Validate(); err != nil { - return fmt.Errorf("invalid helm config: %v", err) - } - } - - return nil -} - -func (req *UpdateContainerVersionReq) PatchContainerVersionModel(target *database.ContainerVersion) { - if req.GithubLink != nil { - target.GithubLink = *req.GithubLink - } - if req.Command != nil { - target.Command = *req.Command - } - if req.Status != nil { - target.Status = *req.Status - } -} - -type UpdateHelmConfigReq struct { - RepoURL *string `json:"repo_url" binding:"omitempty"` - RepoName *string `json:"repo_name" binding:"omitempty"` - ChartName *string `json:"chart_name" binding:"omitempty"` - DynamicValues *map[string]any `json:"dynamic_values" binding:"omitempty" swaggertype:"object"` -} - -func (req *UpdateHelmConfigReq) Validate() error { - if req.RepoURL != nil { - trimmedURL := strings.TrimSpace(*req.RepoURL) - *req.RepoURL = trimmedURL - - if trimmedURL == "" { - return fmt.Errorf("repository URL cannot be empty if provided") - } - if _, err := url.Parse(trimmedURL); err != nil { - return fmt.Errorf("invalid repository URL format: %s. Error: %v", trimmedURL, err) - } - } - if req.RepoName != nil { - *req.RepoName = strings.TrimSpace(*req.RepoName) - } - if req.ChartName != nil { - *req.ChartName = strings.TrimSpace(*req.ChartName) - } - return nil -} - -func (req *UpdateHelmConfigReq) PatchHelmConfigModel(target *database.HelmConfig) error { - if req.RepoURL != nil { - target.RepoURL = *req.RepoURL - } - if req.RepoName != nil { - target.RepoName = *req.RepoName - } - if req.ChartName != nil { - target.ChartName = *req.ChartName - } - return nil -} - -// ContainerResp is basic container info used -type ContainerResp struct { - ID int `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - IsPublic bool `json:"is_public"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - Labels []LabelItem `json:"labels,omitempty"` -} - -func NewContainerResp(container *database.Container) *ContainerResp { - resp := &ContainerResp{ - ID: container.ID, - Name: container.Name, - Type: consts.GetContainerTypeName(container.Type), - IsPublic: container.IsPublic, - Status: consts.GetStatusTypeName(container.Status), - CreatedAt: container.CreatedAt, - UpdatedAt: container.UpdatedAt, - } - - if len(container.Labels) > 0 { - resp.Labels = make([]LabelItem, 0, len(container.Labels)) - for _, l := range container.Labels { - resp.Labels = append(resp.Labels, LabelItem{ - Key: l.Key, - Value: l.Value, - }) - } - } - return resp -} - -// ContainerDetailResp is used for single resource retrieval. -type ContainerDetailResp struct { - ContainerResp - - README string `json:"readme"` - - Versions []ContainerVersionResp `json:"versions"` -} - -func NewContainerDetailResp(container *database.Container) *ContainerDetailResp { - return &ContainerDetailResp{ - ContainerResp: *NewContainerResp(container), - README: container.README, - } -} - -type ContainerVersionResp struct { - ID int `json:"id"` - Name string `json:"name"` - ImageRef string `json:"image_ref"` - Usage int `json:"usage"` - UpdatedAt time.Time `json:"updated_at"` -} - -func NewContainerVersionResp(version *database.ContainerVersion) *ContainerVersionResp { - return &ContainerVersionResp{ - ID: version.ID, - Name: version.Name, - ImageRef: version.ImageRef, - Usage: version.Usage, - UpdatedAt: version.UpdatedAt, - } -} - -type ContainerVersionDetailResp struct { - ContainerVersionResp - - GithubLink string `json:"github_link"` - Command string `json:"command"` - EnvVars string `json:"env_vars"` - - HelmConfig *HelmConfigDetailResp `json:"helm_config,omitempty"` -} - -func NewContainerVersionDetailResp(version *database.ContainerVersion) *ContainerVersionDetailResp { - return &ContainerVersionDetailResp{ - ContainerVersionResp: *NewContainerVersionResp(version), - GithubLink: version.GithubLink, - Command: version.Command, - } -} - -type ListContainerVersionResp struct { - Items []ContainerResp `json:"items"` - Pagination PaginationInfo `json:"pagination"` -} - -type HelmConfigDetailResp struct { - ID int `json:"id"` - Version string `json:"version"` - ChartName string `json:"chart_name"` - RepoName string `json:"repo_name"` - RepoURL string `json:"repo_url"` - LocalPath string `json:"local_path,omitempty"` - ValueFile string `json:"value_file,omitempty"` - Values map[string]any `json:"values"` -} - -func NewHelmConfigDetailResp(cfg *database.HelmConfig) (*HelmConfigDetailResp, error) { - resp := &HelmConfigDetailResp{ - ID: cfg.ID, - Version: cfg.Version, - ChartName: cfg.ChartName, - RepoName: cfg.RepoName, - RepoURL: cfg.RepoURL, - LocalPath: cfg.LocalPath, - ValueFile: cfg.ValueFile, - } - - return resp, nil -} - -// UploadHelmValueFileResp represents the response for uploading a Helm values file -type UploadHelmValueFileResp struct { - FilePath string `json:"file_path"` // Saved file path - FileName string `json:"file_name"` // Original file name -} - -type UploadHelmChartResp struct { - FilePath string `json:"file_path"` // Saved chart path - FileName string `json:"file_name"` // Original chart file name - Checksum string `json:"checksum"` // SHA256 checksum of the chart -} - -type SubmitContainerBuildResp struct { - GroupID string `json:"group_id"` - TraceID string `json:"trace_id"` - TaskID string `json:"task_id"` -} - -// ---------------------- Pedestal Helm DTOs ------------------ - -// PedestalHelmConfigResp represents a full helm_configs row for CLI/API consumers. -type PedestalHelmConfigResp struct { - ID int `json:"id"` - ContainerVersionID int `json:"container_version_id"` - ChartName string `json:"chart_name"` - Version string `json:"version"` - RepoURL string `json:"repo_url"` - RepoName string `json:"repo_name"` - ValueFile string `json:"value_file"` - LocalPath string `json:"local_path"` - Checksum string `json:"checksum"` -} - -// UpsertPedestalHelmConfigReq is the body for PUT /api/v2/pedestal/helm/:container_version_id -type UpsertPedestalHelmConfigReq struct { - ChartName string `json:"chart_name" binding:"required"` - Version string `json:"version" binding:"required"` - RepoURL string `json:"repo_url" binding:"required"` - RepoName string `json:"repo_name" binding:"required"` - ValueFile string `json:"value_file"` - LocalPath string `json:"local_path"` -} - -// PedestalHelmVerifyCheck is a single step in the verify pipeline. -type PedestalHelmVerifyCheck struct { - Name string `json:"name"` - OK bool `json:"ok"` - Detail string `json:"detail,omitempty"` -} - -// PedestalHelmVerifyResp is the aggregated verify response. -type PedestalHelmVerifyResp struct { - OK bool `json:"ok"` - Checks []PedestalHelmVerifyCheck `json:"checks"` -} - -// ---------------------- Container Label DTOs ------------------ - -// ManageContainerLabelReq represents the request for managing container labels -type ManageContainerLabelReq struct { - AddLabels []LabelItem `json:"add_labels" binding:"omitempty"` // List of labels to add - RemoveLabels []string `json:"remove_labels" binding:"omitempty"` // List of label keys to remove -} - -func (req *ManageContainerLabelReq) Validate() error { - if len(req.AddLabels) == 0 && len(req.RemoveLabels) == 0 { - return fmt.Errorf("at least one of add_labels or remove_labels must be provided") - } - - if err := validateLabelItemsFiled(req.AddLabels); err != nil { - return err - } - - for i, key := range req.RemoveLabels { - if strings.TrimSpace(key) == "" { - return fmt.Errorf("empty label key at index %d in remove_labels", i) - } - } - - return nil -} - -// validateContainerType checks if the provided container type is valid -func validateContainerType(containerType *consts.ContainerType) error { - if containerType != nil { - if _, exists := consts.ValidContainerTypes[*containerType]; !exists { - return fmt.Errorf("invalid container type: %d", *containerType) - } - } - return nil -} - -// SetContainerVersionImageReq is the request body for -// PATCH /api/v2/container-versions/:id/image. It rewrites the four image -// reference columns on a container_versions row. The registry defaults to -// "docker.io" if empty; repository and tag are required. -type SetContainerVersionImageReq struct { - Registry string `json:"registry"` - Namespace string `json:"namespace"` - Repository string `json:"repository" binding:"required"` - Tag string `json:"tag" binding:"required"` -} - -func (req *SetContainerVersionImageReq) Validate() error { - req.Registry = strings.TrimSpace(req.Registry) - req.Namespace = strings.TrimSpace(req.Namespace) - req.Repository = strings.TrimSpace(req.Repository) - req.Tag = strings.TrimSpace(req.Tag) - if req.Registry == "" { - req.Registry = "docker.io" - } - if req.Repository == "" { - return fmt.Errorf("repository is required") - } - if req.Tag == "" { - return fmt.Errorf("tag is required") - } - return nil -} - -// SetContainerVersionImageResp is returned after a successful image rewrite. -type SetContainerVersionImageResp struct { - ID int `json:"id"` - Name string `json:"name"` - Registry string `json:"registry"` - Namespace string `json:"namespace"` - Repository string `json:"repository"` - Tag string `json:"tag"` - ImageRef string `json:"image_ref"` -} - -func NewSetContainerVersionImageResp(version *database.ContainerVersion) *SetContainerVersionImageResp { - return &SetContainerVersionImageResp{ - ID: version.ID, - Name: version.Name, - Registry: version.Registry, - Namespace: version.Namespace, - Repository: version.Repository, - Tag: version.Tag, - ImageRef: version.ImageRef, - } -} diff --git a/src/dto/dataset.go b/src/dto/dataset.go index 5c44da48..2eb32224 100644 --- a/src/dto/dataset.go +++ b/src/dto/dataset.go @@ -2,18 +2,11 @@ package dto import ( "fmt" - "strings" - "time" - "aegis/consts" - "aegis/database" "aegis/utils" ) -// ===================================================================== -// Dataset Service DTOs -// ===================================================================== - +// DatasetRef is the shared dataset reference used across modules and tasks. type DatasetRef struct { Name string `json:"name" binding:"required"` Version string `json:"version" binding:"omitempty"` @@ -30,322 +23,3 @@ func (ref *DatasetRef) Validate() error { } return nil } - -// ===================== Dataset CRUD DTOs ===================== - -type CreateDatasetReq struct { - Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required"` - Description string `json:"description" binding:"omitempty"` - IsPublic *bool `json:"is_public" binding:"omitempty"` - - VersionReq *CreateDatasetVersionReq `json:"version" binding:"omitempty"` -} - -func (req *CreateDatasetReq) Validate() error { - req.Name = strings.TrimSpace(req.Name) - req.Type = strings.TrimSpace(req.Type) - - if req.Name == "" { - return fmt.Errorf("dataset name cannot be empty") - } - if req.Type == "" { - return fmt.Errorf("dataset type cannot be empty") - } - if req.IsPublic == nil { - req.IsPublic = utils.BoolPtr(true) - } - - if req.VersionReq != nil { - if err := req.VersionReq.Validate(); err != nil { - return fmt.Errorf("invalid dataset version request: %v", err) - } - } - - return nil -} - -func (req *CreateDatasetReq) ConvertToDataset() *database.Dataset { - return &database.Dataset{ - Name: req.Name, - Type: req.Type, - Description: req.Description, - IsPublic: *req.IsPublic, - Status: consts.CommonEnabled, - } -} - -type ListDatasetReq struct { - PaginationReq - Type string `form:"type" binding:"omitempty"` - IsPublic *bool `form:"is_public" binding:"omitempty"` - Status *consts.StatusType `form:"status" binding:"omitempty"` -} - -func (req *ListDatasetReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - return validateStatusField(req.Status, false) -} - -type SearchDatasetReq struct { - AdvancedSearchReq[consts.DatasetField] - - NamePattern string `json:"name_pattern" binding:"omitempty"` - IncludeVersions bool `json:"include_versions" binding:"omitempty"` -} - -func (req *SearchDatasetReq) Validate() error { - if err := req.AdvancedSearchReq.Validate(); err != nil { - return err - } - for i, sortField := range req.Sort { - if _, valid := consts.DatasetAllowedFields[sortField.Field]; !valid { - return fmt.Errorf("invalid sort_by field at index %d: %s", i, sortField.Field) - } - } - for i, field := range req.GroupBy { - if _, valid := consts.DatasetAllowedFields[field]; !valid { - return fmt.Errorf("invalid group_by field at index %d: %s", i, field) - } - } - return nil -} - -func (req *SearchDatasetReq) ConvertToSearchReq() *SearchReq[consts.DatasetField] { - sr := req.ConvertAdvancedToSearch() - - if req.NamePattern != "" { - sr.AddFilter("name", OpLike, req.NamePattern) - } - - if req.IncludeVersions { - sr.AddInclude("Versions") - } - - return sr -} - -type UpdateDatasetReq struct { - Description *string `json:"description" binding:"omitempty"` - IsPublic *bool `json:"is_public" binding:"omitempty"` - Status *consts.StatusType `json:"status" binding:"omitempty"` -} - -func (req *UpdateDatasetReq) Validate() error { - return validateStatusField(req.Status, true) -} - -func (req *UpdateDatasetReq) PatchDatasetModel(target *database.Dataset) { - if req.Description != nil { - target.Description = *req.Description - } - if req.IsPublic != nil { - target.IsPublic = *req.IsPublic - } - if req.Status != nil { - target.Status = *req.Status - } -} - -type ManageDatasetLabelReq struct { - AddLabels []LabelItem `json:"add_labels" binding:"omitempty"` // List of labels to add - RemoveLabels []string `json:"remove_labels" binding:"omitempty"` // List of label keys to remove -} - -func (req *ManageDatasetLabelReq) Validate() error { - if len(req.AddLabels) == 0 && len(req.RemoveLabels) == 0 { - return fmt.Errorf("at least one of add_labels or remove_labels must be provided") - } - - if err := validateLabelItemsFiled(req.AddLabels); err != nil { - return err - } - - for i, key := range req.RemoveLabels { - if strings.TrimSpace(key) == "" { - return fmt.Errorf("empty label key at index %d in remove_labels", i) - } - } - - return nil -} - -type ManageDatasetVersionInjectionReq struct { - AddDatapacks []string `json:"add_datapacks" binding:"omitempty"` - RemoveDatapacks []string `json:"remove_datapacks" binding:"omitempty"` -} - -func (req *ManageDatasetVersionInjectionReq) Validate() error { - if len(req.AddDatapacks) == 0 && len(req.RemoveDatapacks) == 0 { - return fmt.Errorf("at least one of add_injections or remove_injections must be provided") - } - - for i, datapack := range req.AddDatapacks { - if strings.TrimSpace(datapack) == "" { - return fmt.Errorf("empty datapack name at index %d in add_datapacks", i) - } - } - for i, datapack := range req.RemoveDatapacks { - if strings.TrimSpace(datapack) == "" { - return fmt.Errorf("empty datapack name at index %d in add_datapacks", i) - } - } - - return nil -} - -type DatasetResp struct { - ID int `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - IsPublic bool `json:"is_public"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - Labels []LabelItem `json:"labels,omitempty"` -} - -func NewDatasetResp(dataset *database.Dataset) *DatasetResp { - resp := &DatasetResp{ - ID: dataset.ID, - Name: dataset.Name, - Type: dataset.Type, - IsPublic: dataset.IsPublic, - Status: consts.GetStatusTypeName(dataset.Status), - CreatedAt: dataset.CreatedAt, - UpdatedAt: dataset.UpdatedAt, - } - - if len(dataset.Labels) > 0 { - resp.Labels = make([]LabelItem, 0, len(dataset.Labels)) - for _, l := range dataset.Labels { - resp.Labels = append(resp.Labels, LabelItem{ - Key: l.Key, - Value: l.Value, - }) - } - } - return resp -} - -type DatasetDetailResp struct { - DatasetResp - - Description string `json:"description"` - - Versions []DatasetVersionResp `json:"versions"` -} - -func NewDatasetDetailResp(dataset *database.Dataset) *DatasetDetailResp { - return &DatasetDetailResp{ - DatasetResp: *NewDatasetResp(dataset), - Description: dataset.Description, - } -} - -// ===================== Dataset Version CRUD DTOs ===================== - -type CreateDatasetVersionReq struct { - Name string `json:"name" binding:"required"` - Datapacks []string `json:"datapacks" binding:"omitempty"` -} - -func (req *CreateDatasetVersionReq) Validate() error { - req.Name = strings.TrimSpace(req.Name) - - if req.Name == "" { - return fmt.Errorf("name cannot be empty") - } - - if _, _, _, err := utils.ParseSemanticVersion(req.Name); err != nil { - return fmt.Errorf("invalid semantic version: %s, %v", req.Name, err) - } - - if len(req.Datapacks) > 0 { - for i, dp := range req.Datapacks { - if strings.TrimSpace(dp) == "" { - return fmt.Errorf("empty datapack name at index %d", i) - } - } - } - - return nil -} - -func (req *CreateDatasetVersionReq) ConvertToDatasetVersion() *database.DatasetVersion { - version := &database.DatasetVersion{ - Name: req.Name, - Status: consts.CommonEnabled, - } - - return version -} - -// ListDatasetVersionReq represents dataset version list query parameters -type ListDatasetVersionReq struct { - PaginationReq - Status *consts.StatusType `json:"status" binding:"omitempty"` -} - -func (req *ListDatasetVersionReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - return validateStatusField(req.Status, false) -} - -type UpdateDatasetVersionReq struct { - Status *consts.StatusType `json:"status" binding:"omitempty"` -} - -func (req *UpdateDatasetVersionReq) Validate() error { - return validateStatusField(req.Status, true) -} - -func (req *UpdateDatasetVersionReq) PatchDatasetVersionModel(target *database.DatasetVersion) { - if req.Status != nil { - target.Status = *req.Status - } -} - -type DatasetVersionResp struct { - ID int `json:"id"` - Name string `json:"name"` - Checksum string `json:"checksum"` - FileCount int `json:"file_count"` - UpdatedAt time.Time `json:"updated_at"` -} - -func NewDatasetVersionResp(version *database.DatasetVersion) *DatasetVersionResp { - return &DatasetVersionResp{ - ID: version.ID, - Name: version.Name, - Checksum: version.Checksum, - FileCount: version.FileCount, - UpdatedAt: version.UpdatedAt, - } -} - -type DatasetVersionDetailResp struct { - DatasetVersionResp - - Datapacks []InjectionResp `json:"datapacks,omitempty"` -} - -func NewDatasetVersionDetailResp(version *database.DatasetVersion) *DatasetVersionDetailResp { - resp := &DatasetVersionDetailResp{ - DatasetVersionResp: *NewDatasetVersionResp(version), - } - - if len(version.Datapacks) > 0 { - resp.Datapacks = make([]InjectionResp, 0, len(version.Datapacks)) - for _, inj := range version.Datapacks { - resp.Datapacks = append(resp.Datapacks, *NewInjectionResp(&inj)) - } - } - - return resp -} diff --git a/src/dto/debug.go b/src/dto/debug.go deleted file mode 100644 index c18d3475..00000000 --- a/src/dto/debug.go +++ /dev/null @@ -1,10 +0,0 @@ -package dto - -type DebugGetReq struct { - Name string `form:"name" binding:"required"` -} - -type DebugSetReq struct { - Name string `json:"name" binding:"required"` - Value any `json:"value"` -} diff --git a/src/dto/dynamic_config.go b/src/dto/dynamic_config.go index 81b7d35d..56046723 100644 --- a/src/dto/dynamic_config.go +++ b/src/dto/dynamic_config.go @@ -5,271 +5,9 @@ import ( "fmt" "time" - "aegis/consts" - "aegis/database" "aegis/utils" ) -// ===================================================================== -// Configuration DTOs -// ===================================================================== - -// ListConfigReq represents config list query parameters -type ListConfigReq struct { - PaginationReq - ValueType *consts.ConfigValueType `form:"value_type" binding:"omitempty"` - Category *string `form:"category" binding:"omitempty"` - IsSecret *bool `form:"is_secret" binding:"omitempty"` - UpdatedBy *int `form:"updated_by" binding:"omitempty,min_ptr=1"` -} - -func (req *ListConfigReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - if err := validateValuteType(req.ValueType); err != nil { - return err - } - return nil -} - -// RollbackConfigReq represents a request to rollback a configuration -type RollbackConfigReq struct { - HistoryID int `json:"history_id" binding:"required,min=1"` - Reason string `json:"reason" binding:"required"` -} - -// UpdateConfigValueReq represents a request to update a configuration value (runtime config) -type UpdateConfigValueReq struct { - Value string `json:"value" binding:"required"` - Reason string `json:"reason" binding:"required"` -} - -// UpdateConfigMetadataReq represents a request to update configuration metadata -type UpdateConfigMetadataReq struct { - // Metadata fields - only ONE field should be provided per request - DefaultValue *string `json:"default_value" binding:"omitempty"` - Description *string `json:"description" binding:"omitempty"` - MinValue *float64 `json:"min_value" binding:"omitempty"` - MaxValue *float64 `json:"max_value" binding:"omitempty"` - Pattern *string `json:"pattern" binding:"omitempty"` - Options *string `json:"options" binding:"omitempty"` - - // Audit trail - Reason string `json:"reason" binding:"required"` -} - -func (req *UpdateConfigMetadataReq) Validate() error { - // Count how many fields are being updated - fieldCount := 0 - if req.DefaultValue != nil { - fieldCount++ - } - if req.Description != nil { - fieldCount++ - } - if req.MinValue != nil { - fieldCount++ - } - if req.MaxValue != nil { - fieldCount++ - } - if req.Pattern != nil { - fieldCount++ - } - if req.Options != nil { - fieldCount++ - } - - if fieldCount == 0 { - return fmt.Errorf("at least one metadata field must be provided for update") - } - if fieldCount > 1 { - return fmt.Errorf("can only update one metadata field at a time") - } - - return nil -} - -func (req *UpdateConfigMetadataReq) PatchConfigModel(target *database.DynamicConfig) (string, string) { - var oldValue string - var newValue string - - if req.DefaultValue != nil { - oldValue = target.DefaultValue - newValue = *req.DefaultValue - target.DefaultValue = *req.DefaultValue - } - if req.Description != nil { - oldValue = target.Description - newValue = *req.Description - target.Description = *req.Description - } - if req.MinValue != nil { - oldValue = fmt.Sprintf("%v", target.MinValue) - newValue = fmt.Sprintf("%v", req.MinValue) - target.MinValue = req.MinValue - } - if req.MaxValue != nil { - oldValue = fmt.Sprintf("%v", target.MaxValue) - newValue = fmt.Sprintf("%v", req.MaxValue) - target.MaxValue = req.MaxValue - } - if req.Pattern != nil { - oldValue = target.Pattern - newValue = *req.Pattern - target.Pattern = *req.Pattern - } - if req.Options != nil { - oldValue = target.Options - newValue = *req.Options - target.Options = *req.Options - } - - return oldValue, newValue -} - -// GetChangeField returns the specific metadata field being changed -func (req *UpdateConfigMetadataReq) GetChangeField() consts.ConfigHistoryChangeField { - if req.DefaultValue != nil { - return consts.ChangeFieldDefaultValue - } - if req.Description != nil { - return consts.ChangeFieldDescription - } - if req.MinValue != nil { - return consts.ChangeFieldMinValue - } - if req.MaxValue != nil { - return consts.ChangeFieldMaxValue - } - if req.Pattern != nil { - return consts.ChangeFieldPattern - } - if req.Options != nil { - return consts.ChangeFieldOptions - } - return consts.ChangeFieldValue -} - -type ListConfigHistoryReq struct { - PaginationReq - ChangeType *consts.ConfigHistoryChangeType `form:"change_type" binding:"omitempty"` - OperatorID *int `form:"operator_id" binding:"omitempty,min_ptr=1"` -} - -func (req *ListConfigHistoryReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - if req.ChangeType != nil { - if _, ok := consts.ValidConfigHistoryChanteTypes[*req.ChangeType]; !ok { - return fmt.Errorf("invalid change type: %v", req.ChangeType) - } - } - return nil -} - -// ConfigResp represents a configuration item response -type ConfigResp struct { - ID int `json:"id"` - Key string `json:"key"` - ValueType string `json:"value_type"` - Category string `json:"category"` - UpdatedAt time.Time `json:"updated_at"` - UpdatedByID int `json:"updated_by_id"` - UpdatedByName string `json:"updated_by_name"` -} - -// NewConfigResp converts a DynamicConfig entity to ConfigResp DTO -func NewConfigResp(config *database.DynamicConfig) *ConfigResp { - resp := &ConfigResp{ - ID: config.ID, - Key: config.Key, - ValueType: consts.GetDynamicConfigTypeName(config.ValueType), - Category: config.Category, - UpdatedAt: config.UpdatedAt, - } - - if config.UpdatedByUser != nil { - resp.UpdatedByName = config.UpdatedByUser.Username - } - - return resp -} - -type ConfigDetailResp struct { - ConfigResp - - DefaultValue string `json:"default_value"` - Description string `json:"description"` - MinValue *float64 `json:"min_value,omitempty"` - MaxValue *float64 `json:"max_value,omitempty"` - Pattern string `json:"pattern,omitempty"` - Options string `json:"options,omitempty"` - Histories []ConfigHistoryResp `json:"histories,omitempty"` -} - -func NewConfigDetailResp(config *database.DynamicConfig) *ConfigDetailResp { - return &ConfigDetailResp{ - ConfigResp: *NewConfigResp(config), - DefaultValue: config.DefaultValue, - Description: config.Description, - MinValue: config.MinValue, - MaxValue: config.MaxValue, - Pattern: config.Pattern, - Options: config.Options, - } -} - -// ConfigHistoryResp represents a configuration change history entry response -type ConfigHistoryResp struct { - ID int `json:"id"` - ChangeType string `json:"change_type"` - OldValue string `json:"old_value"` - NewValue string `json:"new_value"` - Reason string `json:"reason"` - ConfigID int `json:"config_id"` - OperatorID *int `json:"operator_id"` - OperatorName string `json:"operator_name,omitempty"` - IPAddress string `json:"ip_address,omitempty"` - UserAgent string `json:"user_agent,omitempty"` - RolledBackFromID *int `json:"rolled_back_from_id,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -func NewConfigHistoryResp(history *database.ConfigHistory) *ConfigHistoryResp { - resp := &ConfigHistoryResp{ - ID: history.ID, - ChangeType: consts.GetConfigHistoryChangeTypeName(history.ChangeType), - ConfigID: history.ConfigID, - OldValue: history.OldValue, - NewValue: history.NewValue, - Reason: history.Reason, - OperatorID: history.OperatorID, - IPAddress: history.IPAddress, - UserAgent: history.UserAgent, - RolledBackFromID: history.RolledBackFromID, - CreatedAt: history.CreatedAt, - } - - if history.Operator != nil { - resp.OperatorName = history.Operator.Username - } - return resp -} - -// ConfigStatsResp represents statistics about the configuration system -type ConfigStatsResp struct { - TotalConfigs int `json:"total_configs"` - DynamicConfigs int `json:"dynamic_configs"` - StaticConfigs int `json:"static_configs"` - TotalChanges int `json:"total_changes"` - ChangesLast24h int `json:"changes_last_24h"` - Categories []string `json:"categories"` - LastUpdate time.Time `json:"last_update"` -} - // ConfigUpdateResponse represents the response to a configuration update event type ConfigUpdateResponse struct { ID string `json:"id"` @@ -307,13 +45,3 @@ func (r *ConfigUpdateResponse) ToMap() (map[string]any, error) { return m, nil } - -// validateValuteType checks if the provided config value type is valid -func validateValuteType(valueType *consts.ConfigValueType) error { - if valueType != nil { - if _, ok := consts.ValidDynamicConfigTypes[*valueType]; !ok { - return fmt.Errorf("invalid value type: %v", valueType) - } - } - return nil -} diff --git a/src/dto/group.go b/src/dto/group.go deleted file mode 100644 index d90a4fa2..00000000 --- a/src/dto/group.go +++ /dev/null @@ -1,47 +0,0 @@ -package dto - -import ( - "aegis/consts" - "fmt" - "strings" -) - -// ===================== Group Stream DTO ===================== - -// GroupStreamEvent represents a lightweight event pushed to group-level Redis stream -// when a trace reaches a terminal state (Completed/Failed). -type GroupStreamEvent struct { - TraceID string `json:"trace_id"` - State consts.TraceState `json:"state"` - LastEvent consts.EventType `json:"last_event"` -} - -// ToRedisStream converts GroupStreamEvent to Redis stream field-value pairs -func (e *GroupStreamEvent) ToRedisStream() map[string]any { - return map[string]any{ - consts.RdbEventTraceID: e.TraceID, - consts.RdbEventTraceState: e.State, - consts.RdbEventTraceLastEvent: e.LastEvent, - } -} - -// GetGroupStreamReq represents the request to subscribe to a group stream -type GetGroupStreamReq struct { - LastID string `form:"last_id" binding:"omitempty"` -} - -func (req *GetGroupStreamReq) Validate() error { - if req.LastID == "" { - req.LastID = "0" - } - - if req.LastID == "0" { - return nil - } - - if strings.Count(req.LastID, "-") != 1 { - return fmt.Errorf("invalid last_id format: must be '0' or a valid stream ID (e.g., 1678886400000-0)") - } - - return nil -} diff --git a/src/dto/injection.go b/src/dto/injection.go index 4ff2a692..ae83259a 100644 --- a/src/dto/injection.go +++ b/src/dto/injection.go @@ -1,20 +1,12 @@ package dto import ( - "encoding/json" - "fmt" - "strings" "time" - "aegis/config" - "aegis/consts" - "aegis/database" - "aegis/utils" - - chaos "github.com/OperationsPAI/chaos-experiment/handler" - "github.com/OperationsPAI/chaos-experiment/pkg/guidedcli" + "aegis/model" ) +// InjectionItem is the shared runtime datapack payload carried across tasks/consumers. type InjectionItem struct { ID int `json:"id"` Name string `json:"name"` @@ -23,13 +15,11 @@ type InjectionItem struct { EndTime time.Time `json:"end_time,omitempty"` } -func NewInjectionItem(injection *database.FaultInjection) InjectionItem { +func NewInjectionItem(injection *model.FaultInjection) InjectionItem { item := InjectionItem{ ID: injection.ID, Name: injection.Name, PreDuration: injection.PreDuration, - StartTime: *injection.StartTime, - EndTime: *injection.EndTime, } if injection.StartTime != nil { @@ -41,999 +31,3 @@ func NewInjectionItem(injection *database.FaultInjection) InjectionItem { return item } - -// BatchDeleteInjectionReq represents the request to batch delete injections -type BatchDeleteInjectionReq struct { - IDs []int `json:"ids,omitempty"` // List of injection IDs for deletion - Labels []LabelItem `json:"labels,omitempty"` // List of label keys to match for deletion -} - -func (req *BatchDeleteInjectionReq) Validate() error { - hasIDs := len(req.IDs) > 0 - hasLabels := len(req.Labels) > 0 - - criteriaCount := 0 - if hasIDs { - criteriaCount++ - } - if hasLabels { - criteriaCount++ - } - - if criteriaCount == 0 { - return fmt.Errorf("must provide one of: ids, labels, or tags") - } - if criteriaCount > 1 { - return fmt.Errorf("can only specify one deletion criteria (ids, labels, or tags)") - } - - if hasIDs { - for i, id := range req.IDs { - if id <= 0 { - return fmt.Errorf("invalid id at index %d: %d", i, id) - } - } - } - - if hasLabels { - for i, label := range req.Labels { - if strings.TrimSpace(label.Key) == "" { - return fmt.Errorf("empty label key at index %d", i) - } - if strings.TrimSpace(label.Value) == "" { - return fmt.Errorf("empty label value at index %d", i) - } - } - } - - return nil -} - -// CloneInjectionReq represents the request to clone an injection -type CloneInjectionReq struct { - Name string `json:"name" binding:"required"` // New name for cloned injection - Labels []LabelItem `json:"labels" binding:"omitempty"` // Optional labels for cloned injection -} - -// InjectionLogsResp represents the response for injection logs -type InjectionLogsResp struct { - InjectionID int `json:"injection_id"` - TaskID string `json:"task_id,omitempty"` - Logs []string `json:"logs"` -} - -// TriggerDatasetBuildItemResponse represents the response for a single injection in batch trigger -type TriggerDatasetBuildItemResponse struct { - TaskID string `json:"task_id"` - TraceID string `json:"trace_id"` - InjectionName string `json:"injection_name"` - Benchmark string `json:"benchmark"` - Namespace string `json:"namespace"` - Message string `json:"message"` -} - -// TriggerDatasetBuildError represents an error during dataset build trigger -type TriggerDatasetBuildError struct { - InjectionName string `json:"injection_name"` - Error string `json:"error"` -} - -// TriggerFailedDatapackRebuildRequest represents the request for triggering rebuild of failed datapacks -type TriggerFailedDatapackRebuildRequest struct { - Namespace string `json:"namespace,omitempty"` // Optional namespace, defaults to "ts" - Days *int `json:"days,omitempty"` // Number of days to look back, defaults to 3 -} - -// TriggerFailedDatapackRebuildResponse represents the response for triggering rebuild of failed datapacks -type TriggerFailedDatapackRebuildResponse struct { - SuccessCount int `json:"success_count"` - SuccessItems []TriggerDatasetBuildItemResponse `json:"success_items"` - FailedCount int `json:"failed_count"` - FailedItems []TriggerDatasetBuildError `json:"failed_items,omitempty"` - TotalFound int `json:"total_found"` // Total number of failed datapacks found - DaysSearched int `json:"days_searched"` // Number of days searched - SearchCutoff string `json:"search_cutoff"` // ISO timestamp of search cutoff - Message string `json:"message"` -} - -// TriggerFailedDatapackRebuildProgressEvent represents a single progress event for SSE -type TriggerFailedDatapackRebuildProgressEvent struct { - Type string `json:"type"` // "start", "progress", "item_success", "item_error", "complete", "error" - Message string `json:"message"` // Human readable message - TotalFound int `json:"total_found"` // Total number of failed datapacks found - CurrentIndex int `json:"current_index"` // Current processing index (0-based) - Progress float64 `json:"progress"` // Progress percentage (0-100) - SuccessCount int `json:"success_count"` // Number of successful triggers so far - FailedCount int `json:"failed_count"` // Number of failed triggers so far - CurrentItem *TriggerDatasetBuildItemResponse `json:"current_item,omitempty"` // Current successful item - CurrentError *TriggerDatasetBuildError `json:"current_error,omitempty"` // Current error item - EstimatedTime *time.Duration `json:"estimated_time,omitempty"` // Estimated remaining time - FinalResponse *TriggerFailedDatapackRebuildResponse `json:"final_response,omitempty"` // Final response (only for "complete" type) -} - -type InjectionFieldMappingResp struct { - StatusMap map[int]string `json:"status" swaggertype:"object"` - FaultTypeMap map[chaos.ChaosType]string `json:"fault_type" swaggertype:"object"` - FaultResourceMap map[string]chaos.ChaosResourceMapping `json:"fault_resource" swaggertype:"object"` -} - -type ListInjectionFilters struct { - FaultType *chaos.ChaosType - Category *chaos.SystemType - Benchmark string - State *consts.DatapackState - Status *consts.StatusType - LabelConditions []map[string]string -} - -// ListInjectionReq represents the request to list injections with various filters -type ListInjectionReq struct { - PaginationReq - Type *chaos.ChaosType `form:"fault_type" binding:"omitempty"` - Category *chaos.SystemType `form:"category" binding:"omitempty"` - Benchmark string `form:"benchmark" binding:"omitempty"` - State *consts.DatapackState `form:"state" binding:"omitempty"` - Status *consts.StatusType `form:"status" binding:"omitempty"` - Labels []string `form:"labels" binding:"omitempty"` -} - -func (req *ListInjectionReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - if err := validateChaosType(req.Type); err != nil { - return err - } - // Only validate category if it's provided (not nil) - if req.Category != nil && !req.Category.IsValid() { - return fmt.Errorf("invalid category: %s", *req.Category) - } - if err := validateDatapackState(req.State); err != nil { - return err - } - if err := validateStatusField(req.Status, false); err != nil { - return err - } - if err := validateLabelsField(req.Labels); err != nil { - return err - } - - return nil -} - -func (req *ListInjectionReq) ToFilterOptions() *ListInjectionFilters { - labelConditions := make([]map[string]string, 0, len(req.Labels)) - for _, item := range req.Labels { - parts := strings.SplitN(item, ":", 2) - labelConditions = append(labelConditions, map[string]string{ - "key": parts[0], - "value": parts[1], - }) - } - - return &ListInjectionFilters{ - FaultType: req.Type, - Benchmark: req.Benchmark, - State: req.State, - Status: req.Status, - LabelConditions: labelConditions, - } -} - -// SearchInjectionReq represents the request to search fault injections with advanced filters -type SearchInjectionReq struct { - AdvancedSearchReq[consts.InjectionField] - TaskIDs []string `json:"task_ids" binding:"omitempty"` - Names []string `json:"names" binding:"omitempty"` - NamePattern string `json:"name_pattern" binding:"omitempty"` - FaultTypes []chaos.ChaosType `json:"fault_types" binding:"omitempty"` - Categories []chaos.SystemType `json:"categories" binding:"omitempty"` - States []consts.DatapackState `json:"states" binding:"omitempty"` - Benchmarks []string `json:"benchmarks" binding:"omitempty"` - Labels []LabelItem `json:"labels" binding:"omitempty"` // Custom labels to filter by - StartTime *DateRange `json:"start_time" binding:"omitempty"` - EndTime *DateRange `json:"end_time" binding:"omitempty"` - IncludeLabels bool `json:"include_labels" binding:"omitempty"` // Whether to include labels in the response - IncludeTask bool `json:"include_task" binding:"omitempty"` // Whether to include task details in the response -} - -func (req *SearchInjectionReq) Validate() error { - if err := req.AdvancedSearchReq.Validate(); err != nil { - return err - } - - for i, id := range req.TaskIDs { - if strings.TrimSpace(id) == "" { - return fmt.Errorf("empty task ID at index %d", i) - } - if !utils.IsValidUUID(id) { - return fmt.Errorf("invalid task ID format at index %d: %s", i, id) - } - } - - if len(req.Names) > 0 && req.NamePattern != "" { - return fmt.Errorf("can only specify one of names or name_pattern for filtering") - } - - for i, name := range req.Names { - if strings.TrimSpace(name) == "" { - return fmt.Errorf("empty injection name at index %d", i) - } - } - - if err := validateLabelItemsFiled(req.Labels); err != nil { - return err - } - - if req.StartTime != nil { - if err := req.StartTime.Validate(); err != nil { - return fmt.Errorf("invalid start_time: %w", err) - } - } - if req.EndTime != nil { - if err := req.EndTime.Validate(); err != nil { - return fmt.Errorf("invalid end_time: %w", err) - } - } - - for i, sortField := range req.Sort { - if _, valid := consts.InjectionAllowedFields[sortField.Field]; !valid { - return fmt.Errorf("invalid sort_by field at index %d: %s", i, sortField.Field) - } - } - - for i, field := range req.GroupBy { - if _, valid := consts.InjectionAllowedFields[field]; !valid { - return fmt.Errorf("invalid group_by field at index %d: %s", i, field) - } - } - - return nil -} - -func (req *SearchInjectionReq) ConvertToSearchReq() *SearchReq[consts.InjectionField] { - sr := req.ConvertAdvancedToSearch() - - if len(req.TaskIDs) > 0 { - sr.AddFilter("task_id", OpIn, req.TaskIDs) - } - if len(req.Names) > 0 { - sr.AddFilter("name", OpIn, req.Names) - } - if req.NamePattern != "" { - sr.AddFilter("name", OpLike, req.NamePattern) - } - if len(req.Benchmarks) > 0 { - sr.AddFilter("benchmark", OpIn, req.Benchmarks) - } - - if len(req.FaultTypes) > 0 { - faultTypeValues := make([]string, len(req.FaultTypes)) - for i, ft := range req.FaultTypes { - faultTypeValues[i] = fmt.Sprintf("%d", ft) - } - sr.AddFilter("fault_type", OpIn, faultTypeValues) - } - if len(req.Categories) > 0 { - categoryValues := make([]string, len(req.Categories)) - for i, ct := range req.Categories { - categoryValues[i] = ct.String() - } - sr.AddFilter("category", OpIn, categoryValues) - } - - if len(req.States) > 0 { - stateValues := make([]string, len(req.States)) - for i, st := range req.States { - stateValues[i] = fmt.Sprintf("%d", st) - } - sr.AddFilter("state", OpIn, stateValues) - } - - if req.StartTime != nil { - if req.StartTime.From != nil && req.StartTime.To != nil { - sr.AddFilter("created_at", OpDateBetween, []any{req.StartTime.From, req.StartTime.To}) - } else if req.StartTime.From != nil { - sr.AddFilter("created_at", OpDateAfter, req.StartTime.From) - } else if req.StartTime.To != nil { - sr.AddFilter("created_at", OpDateBefore, req.StartTime.To) - } - } - if req.EndTime != nil { - if req.EndTime.From != nil && req.EndTime.To != nil { - sr.AddFilter("created_at", OpDateBetween, []any{req.EndTime.From, req.EndTime.To}) - } else if req.EndTime.From != nil { - sr.AddFilter("created_at", OpDateAfter, req.EndTime.From) - } else if req.EndTime.To != nil { - sr.AddFilter("created_at", OpDateBefore, req.EndTime.To) - } - } - - if req.IncludeLabels { - sr.AddInclude("Labels") - } - if req.IncludeTask { - sr.AddInclude("Task") - } - - return sr -} - -// FriendlyFaultSpec is a human-readable fault specification format used by CLI tools. -// It is automatically converted to chaos.Node DSL on the server side. -type FriendlyFaultSpec struct { - Type string `json:"type"` // Fault type name (e.g., "CPUStress", "MemoryStress") - Namespace string `json:"namespace"` // Namespace prefix (e.g., "exp") - Target string `json:"target"` // Target container/app name or numeric index - Duration string `json:"duration"` // Duration as Go duration string (e.g., "60s", "5m") or integer minutes - Params map[string]any `json:"params,omitempty"` // Additional spec-specific parameters (e.g., cpu_load, cpu_worker) -} - -// SubmitInjectionReq represents a request to submit fault injection tasks with parallel fault support -// Each element in Specs represents a batch of faults to be injected in parallel within a single experiment. -// Specs accepts BOTH chaos.Node DSL (numeric tree) and FriendlyFaultSpec (human-readable YAML) formats. -// Mixed formats within a single request are supported — each element is auto-detected. -type SubmitInjectionReq struct { - ProjectName string `json:"project_name" binding:"omitempty"` // Project name - Pedestal *ContainerSpec `json:"pedestal" binding:"required"` // Pedestal (workload) configuration - Benchmark *ContainerSpec `json:"benchmark" binding:"required"` // Benchmark (detector) configuration - Interval int `json:"interval" binding:"required,min=1"` // Total experiment interval in minutes - PreDuration int `json:"pre_duration" binding:"required,min=1"` // Normal data collection duration before fault injection - Specs [][]json.RawMessage `json:"specs" binding:"required"` // Fault injection specs - accepts both chaos.Node DSL and FriendlyFaultSpec formats - Algorithms []ContainerSpec `json:"algorithms" binding:"omitempty"` // RCA algorithms to execute (optional) - Labels []LabelItem `json:"labels" binding:"omitempty"` // Labels to attach to the injection - - // ResolvedSpecs holds the converted [][]chaos.Node after calling ResolveSpecs(). - // Not serialized — populated server-side only. - // Mutually exclusive with ResolvedGuidedConfigs; legacy Node/Friendly path only. - ResolvedSpecs [][]chaos.Node `json:"-"` - - // ResolvedGuidedConfigs holds the parsed GuidedConfig specs when the request - // carries chaos-experiment guided configs (detected by top-level chaos_type). - // Mutually exclusive with ResolvedSpecs; when non-empty the producer skips - // the legacy Node/Friendly conversion and forwards GuidedConfigs to the - // consumer, which calls guidedcli.BuildInjection to obtain InjectionConf. - ResolvedGuidedConfigs [][]guidedcli.GuidedConfig `json:"-"` -} - -// ResolveSpecs auto-detects the format of each spec element and routes it to -// one of three handlers: -// 1. Top-level "chaos_type" string present → guidedcli.GuidedConfig (PR 2). -// 2. "value" + "children" present → chaos.Node DSL (legacy). -// 3. Otherwise "type" present → FriendlyFaultSpec, converted via the -// converter callback. -// -// A single request batches must be homogeneous in shape: if any spec in the -// request is a GuidedConfig, all specs must be GuidedConfigs (mixing with -// legacy Node/Friendly is rejected). The two populated fields are mutually -// exclusive: either req.ResolvedSpecs or req.ResolvedGuidedConfigs, never both. -func (req *SubmitInjectionReq) ResolveSpecs(converter func(*FriendlyFaultSpec) (chaos.Node, error)) error { - // First pass: detect whether the request is guided-only. - guidedCount := 0 - legacyCount := 0 - for i, batch := range req.Specs { - for j, raw := range batch { - var probe map[string]json.RawMessage - if err := json.Unmarshal(raw, &probe); err != nil { - return fmt.Errorf("specs[%d][%d]: invalid JSON: %w", i, j, err) - } - if _, hasChaosType := probe["chaos_type"]; hasChaosType { - guidedCount++ - } else { - legacyCount++ - } - } - } - if guidedCount > 0 && legacyCount > 0 { - return fmt.Errorf("specs mix guided (chaos_type) and legacy (type/value) entries; please submit them in separate requests") - } - - if guidedCount > 0 { - result := make([][]guidedcli.GuidedConfig, len(req.Specs)) - for i, batch := range req.Specs { - cfgs := make([]guidedcli.GuidedConfig, len(batch)) - for j, raw := range batch { - var cfg guidedcli.GuidedConfig - if err := json.Unmarshal(raw, &cfg); err != nil { - return fmt.Errorf("specs[%d][%d]: failed to parse guided config: %w", i, j, err) - } - cfgs[j] = cfg - } - result[i] = cfgs - } - req.ResolvedGuidedConfigs = result - req.ResolvedSpecs = nil - return nil - } - - // Legacy path: friendly spec (string "type") or chaos.Node DSL (value+children). - result := make([][]chaos.Node, len(req.Specs)) - for i, batch := range req.Specs { - nodes := make([]chaos.Node, len(batch)) - for j, raw := range batch { - var probe map[string]json.RawMessage - if err := json.Unmarshal(raw, &probe); err != nil { - return fmt.Errorf("specs[%d][%d]: invalid JSON: %w", i, j, err) - } - - if typeRaw, hasType := probe["type"]; hasType { - // Check if "type" is a string (friendly format) vs something else - var typeStr string - if err := json.Unmarshal(typeRaw, &typeStr); err == nil { - // Friendly format detected - var friendly FriendlyFaultSpec - if err := json.Unmarshal(raw, &friendly); err != nil { - return fmt.Errorf("specs[%d][%d]: failed to parse friendly spec: %w", i, j, err) - } - node, err := converter(&friendly) - if err != nil { - return fmt.Errorf("specs[%d][%d]: failed to convert friendly spec: %w", i, j, err) - } - nodes[j] = node - continue - } - } - - // chaos.Node DSL format - var node chaos.Node - if err := json.Unmarshal(raw, &node); err != nil { - return fmt.Errorf("specs[%d][%d]: failed to parse node spec: %w", i, j, err) - } - nodes[j] = node - } - result[i] = nodes - } - req.ResolvedSpecs = result - req.ResolvedGuidedConfigs = nil - return nil -} - -func (req *SubmitInjectionReq) Validate() error { - if req.Pedestal == nil { - return fmt.Errorf("pedestal must not be nil") - } else { - if err := req.Pedestal.Validate(); err != nil { - return fmt.Errorf("invalid pedestal: %w", err) - } - } - - if req.Benchmark == nil { - return fmt.Errorf("benchmark must not be nil") - } - if req.Interval <= req.PreDuration { - return fmt.Errorf("interval must be greater than pre_duration") - } - if len(req.Specs) == 0 { - return fmt.Errorf("specs must not be empty") - } - - if req.Algorithms != nil { - for idx, algorithm := range req.Algorithms { - if err := algorithm.Validate(); err != nil { - return fmt.Errorf("invalid algorithm at index %d: %w", idx, err) - } - if algorithm.Name == config.GetDetectorName() { - return fmt.Errorf("algorithm name %s is reserved and cannot be used", config.GetDetectorName()) - } - } - } - - if req.Labels == nil { - req.Labels = make([]LabelItem, 0) - } - - return nil -} - -type UpdateGroundtruthReq struct { - Groundtruths []database.Groundtruth `json:"ground_truths" binding:"required"` -} - -func (req *UpdateGroundtruthReq) Validate() error { - if len(req.Groundtruths) == 0 { - return fmt.Errorf("at least one ground truth entry is required") - } - return nil -} - -type InjectionResp struct { - ID int `json:"id"` - Name string `json:"name"` - Source string `json:"source"` - FaultType string `json:"fault_type"` - Category string `json:"category"` - DisplayConfig map[string]any `json:"display_config,omitempty" swaggertype:"object"` - PreDuration int `json:"pre_duration"` - StartTime *time.Time `json:"start_time,omitempty"` - EndTime *time.Time `json:"end_time,omitempty"` - State consts.DatapackState `json:"state" swaggertype:"string"` - Status string `json:"status"` - GroundtruthSource string `json:"groundtruth_source"` - BenchmarkID *int `json:"benchmark_id"` - BenchmarkName string `json:"benchmark_name"` - PedestalID *int `json:"pedestal_id"` - PedestalName string `json:"pedestal_name"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - Labels []LabelItem `json:"labels,omitempty"` -} - -func NewInjectionResp(injection *database.FaultInjection) *InjectionResp { - resp := &InjectionResp{ - ID: injection.ID, - Name: injection.Name, - Source: string(injection.Source), - Category: injection.Category.String(), - PreDuration: injection.PreDuration, - StartTime: injection.StartTime, - EndTime: injection.EndTime, - State: injection.State, - Status: consts.GetStatusTypeName(injection.Status), - GroundtruthSource: injection.GroundtruthSource, - BenchmarkID: injection.BenchmarkID, - PedestalID: injection.PedestalID, - CreatedAt: injection.CreatedAt, - UpdatedAt: injection.UpdatedAt, - } - - if injection.FaultType == consts.Hybrid { - resp.FaultType = "hybrid" - } else { - resp.FaultType = chaos.ChaosTypeMap[injection.FaultType] - } - - if injection.DisplayConfig != nil { - var displayConfigData map[string]any - _ = json.Unmarshal([]byte(*injection.DisplayConfig), &displayConfigData) - resp.DisplayConfig = displayConfigData - } - - if injection.Benchmark != nil { - if injection.Benchmark.Container != nil { - resp.BenchmarkName = injection.Benchmark.Container.Name - } - } - if injection.Pedestal != nil { - if injection.Pedestal.Container != nil { - resp.PedestalName = injection.Pedestal.Container.Name - } - } - - // Get labels from associated Task instead of directly from injection - if len(injection.Labels) > 0 { - resp.Labels = make([]LabelItem, 0, len(injection.Labels)) - for _, l := range injection.Labels { - resp.Labels = append(resp.Labels, LabelItem{ - Key: l.Key, - Value: l.Value, - IsSystem: l.IsSystem, - }) - } - } - return resp -} - -type InjectionDetailResp struct { - InjectionResp - - TaskID string `json:"task_id"` - TraceID string `json:"trace_id"` - Source string `json:"source"` - - Description string `json:"description,omitempty"` - EngineConfig []map[string]any `json:"engine_config" swaggertype:"array,object"` - Groundtruths []chaos.Groundtruth `json:"ground_truth,omitempty"` - GroundtruthSource string `json:"groundtruth_source"` -} - -func NewInjectionDetailResp(injection *database.FaultInjection) *InjectionDetailResp { - injectionResp := NewInjectionResp(injection) - resp := &InjectionDetailResp{ - InjectionResp: *injectionResp, - Source: string(injection.Source), - Description: injection.Description, - GroundtruthSource: injection.GroundtruthSource, - } - - if injection.Task != nil { - resp.TaskID = injection.Task.ID - if injection.Task.Trace != nil { - resp.TraceID = injection.Task.Trace.ID - } - } - - if injection.EngineConfig != "" { - var engineConfigData []map[string]any - _ = json.Unmarshal([]byte(injection.EngineConfig), &engineConfigData) - resp.EngineConfig = engineConfigData - } - - resp.Groundtruths = make([]chaos.Groundtruth, 0, len(injection.Groundtruths)) - if len(injection.Groundtruths) > 0 { - for _, gt := range injection.Groundtruths { - resp.Groundtruths = append(resp.Groundtruths, *gt.ConvertToChaosGroundtruth()) - } - } - - return resp -} - -// InjectionMetadataResp represents the metadata response for injections -type InjectionMetadataResp struct { - Config *chaos.Node `json:"config"` - FaultTypeMap map[chaos.ChaosType]string `json:"fault_type_map"` - FaultResourceMap map[string]chaos.ChaosResourceMapping `json:"fault_resource_map"` - SystemResource chaos.SystemResource `json:"ns_resources"` - SystemMap map[string]int `json:"system_map"` - FaultTypeReverseMap map[string]int `json:"fault_type_reverse_map"` - FaultFieldDescriptions map[string][]utils.FieldDescription `json:"fault_field_descriptions"` -} - -// SystemDetail represents a named system with its index. -type SystemDetail struct { - Name string `json:"name"` - Index int `json:"index"` -} - -// SystemMappingResp is the response for the system mapping endpoint. -type SystemMappingResp struct { - Systems map[string]int `json:"systems"` - SystemDetails []SystemDetail `json:"system_details"` -} - -// FaultSpecInput represents a human-readable fault specification for translation. -type FaultSpecInput struct { - Type string `json:"type"` - Namespace string `json:"namespace"` - Target string `json:"target"` - Duration string `json:"duration"` - Extra map[string]any `json:"extra,omitempty"` -} - -// TranslateFaultSpecsReq is the request body for the translate endpoint. -type TranslateFaultSpecsReq struct { - Specs [][]FaultSpecInput `json:"specs" binding:"required"` -} - -// TranslateFaultSpecsResp is the response for the translate endpoint. -type TranslateFaultSpecsResp struct { - Nodes [][]chaos.Node `json:"nodes"` - Warnings []string `json:"warnings"` -} - -type SubmitInjectionItem struct { - Index int `json:"index"` // Index of the batch this injection belongs to - TraceID string `json:"trace_id"` - TaskID string `json:"task_id"` -} - -// Structured warnings about duplications and conflicts -type InjectionWarnings struct { - DuplicateServicesInBatch []string `json:"duplicate_services_in_batch,omitempty"` // Warnings about duplicate service injections within the same batch - DuplicateBatchesInRequest []int `json:"duplicate_batches_in_request,omitempty"` // Batch indices that have duplicate configurations within this request - BatchesExistInDatabase []int `json:"batches_exist_in_database,omitempty"` // Batch indices that already exist in database -} - -type SubmitInjectionResp struct { - GroupID string `json:"group_id"` - Items []SubmitInjectionItem `json:"items"` - OriginalCount int `json:"original_count"` - Warnings *InjectionWarnings `json:"warnings,omitempty"` -} - -type SubmitDatapackBuildingReq struct { - ProjectName string `json:"project_name" binding:"omitempty"` - Specs []BuildingSpec `json:"specs" binding:"required"` - Labels []LabelItem `json:"labels" binding:"omitempty"` -} - -func (req *SubmitDatapackBuildingReq) Validate() error { - if len(req.Specs) == 0 { - return fmt.Errorf("at least one datapack spec is required") - } - - for _, spec := range req.Specs { - if err := spec.Validate(); err != nil { - return fmt.Errorf("invalid datapack spec: %w", err) - } - } - - return validateLabelItemsFiled(req.Labels) -} - -// ManageInjectionLabelReq Represents the request to manage labels for an injection -type ManageInjectionLabelReq struct { - AddLabels []LabelItem `json:"add_labels"` // List of labels to add - RemoveLabels []string `json:"remove_labels"` // List of label keys to remove -} - -func (req *ManageInjectionLabelReq) Validate() error { - if len(req.AddLabels) == 0 && len(req.RemoveLabels) == 0 { - return fmt.Errorf("at least one of add_labels or remove_labels must be provided") - } - - if err := validateLabelItemsFiled(req.AddLabels); err != nil { - return err - } - - for i, key := range req.RemoveLabels { - if strings.TrimSpace(key) == "" { - return fmt.Errorf("empty label key at index %d in remove_labels", i) - } - } - - return nil -} - -// InjectionLabelOperation represents label operations for a single injection -type InjectionLabelOperation struct { - InjectionID int `json:"injection_id" binding:"required"` // Injection ID to manage - AddLabels []LabelItem `json:"add_labels,omitempty"` // Labels to add to this injection - RemoveLabels []LabelItem `json:"remove_labels,omitempty"` // Labels to remove from this injection -} - -// BatchManageInjectionLabelReq represents the request to batch manage injection labels -// Each injection can have its own set of label operations -type BatchManageInjectionLabelReq struct { - Items []InjectionLabelOperation `json:"items" binding:"required,min=1,dive"` // List of label operations per injection -} - -func (req *BatchManageInjectionLabelReq) Validate() error { - if len(req.Items) == 0 { - return fmt.Errorf("items list cannot be empty") - } - - seenIDs := make(map[int]struct{}, len(req.Items)) - for i, item := range req.Items { - if _, exists := seenIDs[item.InjectionID]; exists { - return fmt.Errorf("duplicate injection_id at index %d: %d", i, item.InjectionID) - } - seenIDs[item.InjectionID] = struct{}{} - - if item.InjectionID <= 0 { - return fmt.Errorf("invalid injection_id at index %d: %d", i, item.InjectionID) - } - - if len(item.AddLabels) == 0 && len(item.RemoveLabels) == 0 { - return fmt.Errorf("at least one of add_labels or remove_labels must be provided for injection_id %d at index %d", item.InjectionID, i) - } - - if err := validateLabelItemsFiled(item.AddLabels); err != nil { - return fmt.Errorf("invalid add_labels for injection_id %d at index %d: %w", item.InjectionID, i, err) - } - if err := validateLabelItemsFiled(item.RemoveLabels); err != nil { - return fmt.Errorf("invalid remove_labels for injection_id %d at index %d: %w", item.InjectionID, i, err) - } - } - - return nil -} - -// BatchManageInjectionLabelResp represents the response for batch injection label management -type BatchManageInjectionLabelResp struct { - FailedCount int `json:"failed_count"` - FailedItems []string `json:"failed_items"` - SuccessCount int `json:"success_count"` - SuccessItems []InjectionResp `json:"success_items"` -} - -// analysis -type ListInjectionNoIssuesReq struct { - Labels []string `form:"labels" binding:"omitempty"` - TimeRangeQuery -} - -func (req *ListInjectionNoIssuesReq) Validate() error { - if err := validateLabelsField(req.Labels); err != nil { - return err - } - return req.TimeRangeQuery.Validate() -} - -type ListInjectionWithIssuesReq struct { - Labels []string `form:"labels" binding:"omitempty"` - TimeRangeQuery -} - -func (req *ListInjectionWithIssuesReq) Validate() error { - if err := validateLabelsField(req.Labels); err != nil { - return err - } - return req.TimeRangeQuery.Validate() -} - -type InjectionNoIssuesResp struct { - ID int `json:"datapack_id"` - Name string `json:"datapack_name"` - FaultType string `json:"fault_type"` - Category string `json:"category"` - EngineConfig *chaos.Node `json:"engine_config"` -} - -func NewInjectionNoIssuesResp(entity database.FaultInjectionNoIssues) (*InjectionNoIssuesResp, error) { - var engineConfig *chaos.Node - err := json.Unmarshal([]byte(entity.EngineConfig), engineConfig) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal engine config: %w", err) - } - - return &InjectionNoIssuesResp{ - ID: entity.ID, - Name: entity.Name, - FaultType: chaos.ChaosTypeMap[entity.FaultType], - Category: entity.Category.String(), - EngineConfig: engineConfig, - }, nil -} - -// InjectionWithIssuesResp represents the response for fault injections with issues -type InjectionWithIssuesResp struct { - ID int `json:"datapack_id"` - Name string `json:"datapack_name"` - FaultType string `json:"fault_type"` - Category string `json:"category"` - EngineConfig chaos.Node `json:"engine_config"` - Issues string `json:"issues"` - AbnormalAvgDuration float64 `json:"abnormal_avg_duration"` - NormalAvgDuration float64 `json:"normal_avg_duration"` - AbnormalSuccRate float64 `json:"abnormal_succ_rate"` - NormalSuccRate float64 `json:"normal_succ_rate"` - AbnormalP99 float64 `json:"abnormal_p99"` - NormalP99 float64 `json:"normal_p99"` -} - -func NewInjectionWithIssuesResp(entity database.FaultInjectionWithIssues) (*InjectionWithIssuesResp, error) { - var engineConfig chaos.Node - err := json.Unmarshal([]byte(entity.EngineConfig), &engineConfig) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal engine config: %w", err) - } - return &InjectionWithIssuesResp{ - ID: entity.ID, - Name: entity.Name, - FaultType: chaos.ChaosTypeMap[entity.FaultType], - Category: entity.Category.String(), - EngineConfig: engineConfig, - Issues: entity.Issues, - AbnormalAvgDuration: entity.AbnormalAvgDuration, - NormalAvgDuration: entity.NormalAvgDuration, - AbnormalSuccRate: entity.AbnormalSuccRate, - NormalSuccRate: entity.NormalSuccRate, - AbnormalP99: entity.AbnormalP99, - NormalP99: entity.NormalP99, - }, nil -} - -// datapack -type BuildingSpec struct { - Benchmark ContainerSpec `json:"benchmark" binding:"required"` - Datapack *string `json:"datapack" binding:"omitempty"` - Dataset *DatasetRef `json:"dataset" binding:"omitempty"` - PreDuration *int `json:"pre_duration" binding:"omitempty"` -} - -func (spec *BuildingSpec) Validate() error { - hasDatapack := spec.Datapack != nil - hasDataset := spec.Dataset != nil - - if !hasDatapack && !hasDataset { - return fmt.Errorf("either datapack or dataset must be specified") - } - if hasDatapack && hasDataset { - return fmt.Errorf("cannot specify both datapack and dataset") - } - - if hasDatapack { - if *spec.Datapack == "" { - return fmt.Errorf("datapack name cannot be empty") - } - } - - if hasDataset { - if err := spec.Dataset.Validate(); err != nil { - return fmt.Errorf("invalid dataset: %w", err) - } - } - - if spec.PreDuration != nil && *spec.PreDuration <= 0 { - return fmt.Errorf("pre_duration must be greater than 0") - } - - return nil -} - -type SubmitBuildingItem struct { - Index int `json:"index"` - TraceID string `json:"trace_id"` - TaskID string `json:"task_id"` -} - -// SubmitDatapackResp represents the response for submitting datapack building tasks -type SubmitDatapackBuildingResp struct { - GroupID string `json:"group_id"` - Items []SubmitBuildingItem `json:"items"` -} - -// DatapackFileItem represents a file or directory in the datapack -type DatapackFileItem struct { - Name string `json:"name"` // File or directory name - Path string `json:"path"` // Relative path from datapack root - Size string `json:"size"` // File size in KB/MB format or directory info - ModTime *time.Time `json:"modified_at,omitempty"` // Last modification time (only for files) - Children []DatapackFileItem `json:"children,omitempty"` // Child items (only for directories) -} - -// DatapackFilesResp represents the response for listing datapack files -type DatapackFilesResp struct { - Files []DatapackFileItem `json:"files"` - FileCount int `json:"file_count"` // Number of files (excluding directories) - DirCount int `json:"dir_count"` // Number of directories -} - -// validateChaosType checks if the provided chaos type is valid -func validateChaosType(faultType *chaos.ChaosType) error { - if faultType != nil { - if _, exists := chaos.ChaosTypeMap[*faultType]; !exists { - return fmt.Errorf("invalid fault type: %d", faultType) - } - } - return nil -} - -// validateDatapackState checks if the provided datapack state is valid -func validateDatapackState(state *consts.DatapackState) error { - if state != nil { - if *state < 0 { - return fmt.Errorf("state must be a non-negative integer") - } - if _, exists := consts.ValidDatapackStates[consts.DatapackState(*state)]; !exists { - return fmt.Errorf("invalid state: %d", *state) - } - } - return nil -} - -// UploadDatapackReq represents the request to upload a manual datapack -type UploadDatapackReq struct { - Name string `form:"name" binding:"required"` - Description string `form:"description"` - Category string `form:"category"` - Labels string `form:"labels"` // JSON-encoded []LabelItem - Groundtruths string `form:"ground_truths"` // JSON-encoded []Groundtruth -} - -func (req *UploadDatapackReq) Validate() error { - if strings.TrimSpace(req.Name) == "" { - return fmt.Errorf("name is required") - } - return nil -} - -func (req *UploadDatapackReq) ParseLabels() ([]LabelItem, error) { - if req.Labels == "" { - return nil, nil - } - var labels []LabelItem - if err := json.Unmarshal([]byte(req.Labels), &labels); err != nil { - return nil, fmt.Errorf("invalid labels JSON: %w", err) - } - return labels, nil -} - -func (req *UploadDatapackReq) ParseGroundtruths() ([]database.Groundtruth, error) { - if req.Groundtruths == "" { - return nil, nil - } - var gts []database.Groundtruth - if err := json.Unmarshal([]byte(req.Groundtruths), >s); err != nil { - return nil, fmt.Errorf("invalid ground_truths JSON: %w", err) - } - return gts, nil -} - -// UploadDatapackResp represents the response for uploading a manual datapack -type UploadDatapackResp struct { - ID int `json:"id"` - Name string `json:"name"` -} diff --git a/src/dto/label.go b/src/dto/label.go index ba999a3c..9e9f1ee0 100644 --- a/src/dto/label.go +++ b/src/dto/label.go @@ -1,14 +1,5 @@ package dto -import ( - "fmt" - "time" - - "aegis/consts" - "aegis/database" - "aegis/utils" -) - type LabelItem struct { Key string `json:"key"` Value string `json:"value"` @@ -31,206 +22,3 @@ func ConvertLabelItemsToConditions(labelItems []LabelItem) []map[string]string { return labelConditions } - -// ===================================================================== -// Label DTOs -// ===================================================================== - -// BatchDeleteLabelReq represents the request to batch delete labels -type BatchDeleteLabelReq struct { - IDs []int `json:"ids" binding:"omitempty"` // List of injection IDs for deletion -} - -func (req *BatchDeleteLabelReq) Validate() error { - if len(req.IDs) == 0 { - return fmt.Errorf("ids cannot be empty") - } - for i, id := range req.IDs { - if id <= 0 { - return fmt.Errorf("invalid id at index %d: %d", i, id) - } - } - return nil -} - -// CreateLabelReq represents label creation request -type CreateLabelReq struct { - Key string `json:"key" binding:"required"` - Value string `json:"value" binding:"required"` - Category consts.LabelCategory `json:"category" bindging:"required"` - Description string `json:"description" binding:"omitempty"` - Color *string `json:"color" binding:"omitempty"` -} - -func (req *CreateLabelReq) Validate() error { - if err := validateKeyAndValue(req.Key, req.Value); err != nil { - return err - } - if err := validateLabelCategory(&req.Category); err != nil { - return err - } - if err := validateColor(req.Color); err != nil { - return err - } - return nil -} - -func (req *CreateLabelReq) ConvertToLabel() *database.Label { - return &database.Label{ - Key: req.Key, - Value: req.Value, - Category: req.Category, - Description: req.Description, - Color: utils.GetStringValue(req.Color, "#1890ff"), - IsSystem: false, - Usage: consts.DefaultLabelUsage, - } -} - -type ListLabelFilters struct { - Key string - Value string - Category *consts.LabelCategory - IsSystem *bool - Status *consts.StatusType -} - -type ListLabelReq struct { - PaginationReq - - Key string `form:"key" binding:"omitempty"` - Value string `form:"value" binding:"omitempty"` - Category *consts.LabelCategory `form:"category" binding:"omitempty"` - IsSystem *bool `form:"is_system" binding:"omitempty"` - Status *consts.StatusType `form:"status" binding:"omitempty"` -} - -func (req *ListLabelReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - if err := validateKeyAndValue(req.Key, req.Value); err != nil { - return err - } - if err := validateLabelCategory(req.Category); err != nil { - return err - } - return validateStatusField(req.Status, false) -} - -type UpdateLabelReq struct { - Description *string `json:"description" binding:"omitempty"` - Color *string `json:"color" binding:"omitempty"` - Status *consts.StatusType `json:"status,omitempty"` -} - -func (req *UpdateLabelReq) Validate() error { - if err := validateColor(req.Color); err != nil { - return err - } - return validateStatusField(req.Status, true) -} - -func (req *UpdateLabelReq) PatchLabelModel(target *database.Label) { - if req.Description != nil { - target.Description = *req.Description - } - if req.Color != nil { - target.Color = *req.Color - } - if req.Status != nil { - target.Status = *req.Status - } -} - -func (req *ListLabelReq) ToFilterOptions() *ListLabelFilters { - return &ListLabelFilters{ - Key: req.Key, - Value: req.Value, - Category: req.Category, - IsSystem: req.IsSystem, - Status: req.Status, - } -} - -type LabelResp struct { - ID int `json:"id"` - Key string `json:"key"` - Value string `json:"value"` - Category string `json:"category"` - Color string `json:"color"` - Usage int `json:"usage"` - IsSystem bool `json:"is_system"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func NewLabelResp(label *database.Label) *LabelResp { - return &LabelResp{ - ID: label.ID, - Key: label.Key, - Value: label.Value, - Category: consts.GetLabelCategoryName(label.Category), - Color: label.Color, - Usage: label.Usage, - IsSystem: label.IsSystem, - Status: consts.GetStatusTypeName(label.Status), - CreatedAt: label.CreatedAt, - UpdatedAt: label.UpdatedAt, - } -} - -type LabelDetailResp struct { - LabelResp - - Description string `json:"description"` -} - -func NewLabelDetailResp(label *database.Label) *LabelDetailResp { - return &LabelDetailResp{ - LabelResp: *NewLabelResp(label), - Description: label.Description, - } -} - -// ===================================================================== -// Validation Helpers -// ===================================================================== - -// validateColor checks if the provided color is a valid hex color -func validateColor(color *string) error { - if color == nil { - return nil - } - if !utils.IsValidHexColor(*color) { - return fmt.Errorf("invalid color format: %s", *color) - } - return nil -} - -// validateKeyAndValue checks label key and value consistency. -// Both empty is allowed (list all labels). If one is provided, the other must also be provided. -func validateKeyAndValue(key, value string) error { - if key == "" && value == "" { - return nil - } - if key == "" { - return fmt.Errorf("label key cannot be empty when value is provided") - } - if value == "" { - return fmt.Errorf("label value cannot be empty when key is provided") - } - return nil -} - -// validateLabelCategory validates if the provided category is valid -func validateLabelCategory(category *consts.LabelCategory) error { - if category != nil { - if _, exists := consts.ValidLabelCategories[*category]; !exists { - return fmt.Errorf("invalid label category: %d", category) - } - return nil - } - return nil -} diff --git a/src/dto/log.go b/src/dto/log.go index a977d509..dba067e5 100644 --- a/src/dto/log.go +++ b/src/dto/log.go @@ -15,11 +15,3 @@ type LogEntry struct { TraceID string `json:"trace_id,omitempty"` // Trace ID Level consts.LogLevel `json:"level,omitempty"` // Log level } - -// WSLogMessage represents the WebSocket message format for log streaming -type WSLogMessage struct { - Type consts.WSLogType `json:"type"` - Logs []LogEntry `json:"logs,omitempty"` // Log entries - Message string `json:"message,omitempty"` // Error message or end reason - Total int `json:"total,omitempty"` // Total history log count -} diff --git a/src/dto/metrics.go b/src/dto/metrics.go deleted file mode 100644 index cec83c70..00000000 --- a/src/dto/metrics.go +++ /dev/null @@ -1,67 +0,0 @@ -package dto - -import ( - "fmt" - "time" -) - -// GetMetricsReq represents the request to get metrics with time range and filters -type GetMetricsReq struct { - StartTime *time.Time `form:"start_time" binding:"omitempty"` - EndTime *time.Time `form:"end_time" binding:"omitempty"` - FaultType *string `form:"fault_type" binding:"omitempty"` - AlgorithmID *int `form:"algorithm_id" binding:"omitempty"` -} - -func (req *GetMetricsReq) Validate() error { - if req.StartTime != nil && req.EndTime != nil { - if req.EndTime.Before(*req.StartTime) { - return fmt.Errorf("end_time must be after start_time") - } - } - if req.AlgorithmID != nil && *req.AlgorithmID <= 0 { - return fmt.Errorf("algorithm_id must be positive") - } - return nil -} - -// InjectionMetrics represents aggregated metrics for injections -type InjectionMetrics struct { - TotalCount int `json:"total_count"` - SuccessCount int `json:"success_count"` - FailedCount int `json:"failed_count"` - SuccessRate float64 `json:"success_rate"` - AvgDuration float64 `json:"avg_duration"` - MinDuration float64 `json:"min_duration"` - MaxDuration float64 `json:"max_duration"` - StateDistrib map[string]int `json:"state_distribution" swaggertype:"object"` - FaultTypeDistrib map[string]int `json:"fault_type_distribution" swaggertype:"object"` -} - -// ExecutionMetrics represents aggregated metrics for algorithm executions -type ExecutionMetrics struct { - TotalCount int `json:"total_count"` - SuccessCount int `json:"success_count"` - FailedCount int `json:"failed_count"` - SuccessRate float64 `json:"success_rate"` - AvgDuration float64 `json:"avg_duration"` - MinDuration float64 `json:"min_duration"` - MaxDuration float64 `json:"max_duration"` - StateDistrib map[string]int `json:"state_distribution" swaggertype:"object"` -} - -// AlgorithmMetrics represents comparative metrics across different algorithms -type AlgorithmMetrics struct { - Algorithms []AlgorithmMetricItem `json:"algorithms"` -} - -// AlgorithmMetricItem represents metrics for a single algorithm -type AlgorithmMetricItem struct { - AlgorithmID int `json:"algorithm_id"` - AlgorithmName string `json:"algorithm_name"` - ExecutionCount int `json:"execution_count"` - SuccessCount int `json:"success_count"` - FailedCount int `json:"failed_count"` - SuccessRate float64 `json:"success_rate"` - AvgDuration float64 `json:"avg_duration"` -} diff --git a/src/dto/permission.go b/src/dto/permission.go index 1b5d98cd..0ef8d8ca 100644 --- a/src/dto/permission.go +++ b/src/dto/permission.go @@ -2,11 +2,8 @@ package dto import ( "fmt" - "strings" - "time" "aegis/consts" - "aegis/database" ) // CheckPermissionParams represents permission check parameters @@ -33,243 +30,3 @@ func (req *CheckPermissionParams) Validate() error { } return nil } - -// CreatePermissionReq represents permission creation request -type CreatePermissionReq struct { - DisplayName string `json:"display_name" binding:"omitempty"` - Description string `json:"description" binding:"omitempty"` - Action consts.ActionName `json:"action" binding:"required"` - ResourceID int `json:"resource_id" binding:"required,min=1"` -} - -func (req *CreatePermissionReq) Validate() error { - if req.Action == "" { - return fmt.Errorf("action cannot be empty") - } - if _, ok := consts.ValidActions[consts.ActionName(req.Action)]; !ok { - return fmt.Errorf("invalid action: %s", req.Action) - } - return nil -} - -func (req *CreatePermissionReq) ConvertToPermission() *database.Permission { - return &database.Permission{ - Description: req.Description, - Action: req.Action, - IsSystem: false, - Status: consts.CommonEnabled, - } -} - -// ListPermissionReq represents permission list query parameters -type ListPermissionReq struct { - PaginationReq - Action consts.ActionName `form:"action" binding:"omitempty"` - IsSystem *bool `form:"is_system" binding:"omitempty"` - Status *consts.StatusType `form:"status" binding:"omitempty"` -} - -func (req *ListPermissionReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - if req.Action != "" { - if _, exists := consts.ValidActions[req.Action]; !exists { - return fmt.Errorf("invalid action: %s", req.Action) - } - } - if req.Status != nil { - return validateStatusField(req.Status, false) - } - return nil -} - -// SearchPermissionReq represents advanced permission search with complex filtering -type SearchPermissionReq struct { - AdvancedSearchReq[string] - - // Permission-specific filter shortcuts - NamePattern string `json:"name_pattern,omitempty"` // Fuzzy match for permission name - DisplayNamePattern string `json:"display_name_pattern,omitempty"` // Fuzzy match for display name - DescriptionPattern string `json:"description_pattern,omitempty"` // Fuzzy match for description - Actions []string `json:"actions,omitempty"` // Action filter - ResourceIDs []int `json:"resource_ids,omitempty"` // Resource ID filter - ResourceNames []string `json:"resource_names,omitempty"` // Resource name filter - IsSystem *bool `json:"is_system,omitempty"` // Is system permission - RoleIDs []int `json:"role_ids,omitempty"` // Role IDs that have this permission -} - -// ConvertToSearchRequest converts PermissionSearchReq to SearchRequest with permission-specific filters -func (psr *SearchPermissionReq) ConvertToSearchRequest() *SearchReq[string] { - sr := psr.ConvertAdvancedToSearch() - - // Add permission-specific filters - if psr.NamePattern != "" { - sr.AddFilter("name", OpLike, psr.NamePattern) - } - - if psr.DisplayNamePattern != "" { - sr.AddFilter("display_name", OpLike, psr.DisplayNamePattern) - } - - if psr.DescriptionPattern != "" { - sr.AddFilter("description", OpLike, psr.DescriptionPattern) - } - - if len(psr.Actions) > 0 { - values := make([]string, len(psr.Actions)) - for i, v := range psr.Actions { - values[i] = fmt.Sprintf("%v", v) - } - sr.Filters = append(sr.Filters, SearchFilter{ - Field: "action", - Operator: OpIn, - Values: values, - }) - } - - if len(psr.ResourceIDs) > 0 { - values := make([]string, len(psr.ResourceIDs)) - for i, v := range psr.ResourceIDs { - values[i] = fmt.Sprintf("%v", v) - } - sr.Filters = append(sr.Filters, SearchFilter{ - Field: "resource_id", - Operator: OpIn, - Values: values, - }) - } - - if len(psr.ResourceNames) > 0 { - values := make([]string, len(psr.ResourceNames)) - for i, v := range psr.ResourceNames { - values[i] = fmt.Sprintf("%v", v) - } - sr.Filters = append(sr.Filters, SearchFilter{ - Field: "resource_name", - Operator: OpIn, - Values: values, - }) - } - - if psr.IsSystem != nil { - sr.AddFilter("is_system", OpEqual, *psr.IsSystem) - } - - if len(psr.RoleIDs) > 0 { - values := make([]string, len(psr.RoleIDs)) - for i, v := range psr.RoleIDs { - values[i] = fmt.Sprintf("%v", v) - } - sr.Filters = append(sr.Filters, SearchFilter{ - Field: "role_id", - Operator: OpIn, - Values: values, - }) - } - - return sr -} - -// UpdatePermissionReq represents permission update request -type UpdatePermissionReq struct { - DisplayName *string `json:"display_name" binding:"omitempty"` - Description *string `json:"description" binding:"omitempty"` - Action *consts.ActionName `json:"action" binding:"omitempty"` - ResourceID *int `json:"resource_id" binding:"omitempty,min_ptr=1"` - Status *consts.StatusType `json:"status" binding:"omitempty"` -} - -func (req *UpdatePermissionReq) Validate() error { - if req.DisplayName != nil { - if *req.DisplayName != "" { - *req.DisplayName = strings.TrimSpace(*req.DisplayName) - } - } - - if req.Action != nil { - if *req.Action == "" { - return fmt.Errorf("action cannot be empty") - } - if _, ok := consts.ValidActions[consts.ActionName(*req.Action)]; !ok { - return fmt.Errorf("invalid action: %s", *req.Action) - } - } - - return validateStatusField(req.Status, true) -} - -func (req *UpdatePermissionReq) PatchPermissionModel(target *database.Permission) { - if req.DisplayName != nil { - target.DisplayName = *req.DisplayName - } - if req.Description != nil { - target.Description = *req.Description - } - if req.Action != nil { - target.Action = *req.Action - } - if req.Status != nil { - target.Status = *req.Status - } -} - -// PermissionBaseResp contains common fields for permission responses -type PermissionBaseResp struct { - ID int `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Action consts.ActionName `json:"action"` - Scope consts.ResourceScope `json:"scope"` - IsSystem bool `json:"is_system"` - Status string `json:"status"` - UpdatedAt time.Time `json:"updated_at"` -} - -func NewPermissionBaseResp(perm *database.Permission) *PermissionBaseResp { - return &PermissionBaseResp{ - ID: perm.ID, - Name: perm.Name, - DisplayName: perm.DisplayName, - Action: perm.Action, - Scope: perm.Scope, - IsSystem: perm.IsSystem, - Status: consts.GetStatusTypeName(perm.Status), - UpdatedAt: perm.UpdatedAt, - } -} - -// PermissionResp represents permission summary information -type PermissionResp struct { - PermissionBaseResp - Resource string `json:"resource_name"` // Simple string for list view -} - -func NewPermissionResp(perm *database.Permission) *PermissionResp { - resp := &PermissionResp{ - PermissionBaseResp: *NewPermissionBaseResp(perm), - } - if perm.Resource != nil { - resp.Resource = perm.Resource.Name.String() - } - return resp -} - -type PermissionDetailResp struct { - PermissionBaseResp - Description string `json:"description"` - Resource *ResourceResp `json:"resource,omitempty"` // Detailed object for detail view - CreatedAt time.Time `json:"created_at"` -} - -func NewPermissionDetailResp(perm *database.Permission) *PermissionDetailResp { - resp := &PermissionDetailResp{ - PermissionBaseResp: *NewPermissionBaseResp(perm), - Description: perm.Description, - CreatedAt: perm.CreatedAt, - } - if perm.Resource != nil { - resp.Resource = NewResourceResp(perm.Resource) - } - return resp -} diff --git a/src/dto/project.go b/src/dto/project.go index bf541306..a135edc4 100644 --- a/src/dto/project.go +++ b/src/dto/project.go @@ -1,123 +1,6 @@ package dto -import ( - "fmt" - "strings" - "time" - - "aegis/consts" - "aegis/database" -) - -// ===================== Project CRUD DTOs ===================== - -// CreateProjectReq represents project creation request -type CreateProjectReq struct { - Name string `json:"name" binding:"required"` - Description string `json:"description" binding:"omitempty"` - IsPublic *bool `json:"is_public" binding:"omitempty"` -} - -func (req *CreateProjectReq) Validate() error { - req.Name = strings.TrimSpace(req.Name) - if req.Name == "" { - return fmt.Errorf("project name cannot be empty") - } - if req.IsPublic == nil { - defaultPublic := true - req.IsPublic = &defaultPublic - } - return nil -} - -func (req *CreateProjectReq) ConvertToProject() *database.Project { - return &database.Project{ - Name: req.Name, - Description: req.Description, - IsPublic: *req.IsPublic, - Status: consts.CommonEnabled, - } -} - -// ListProjectReq represents project list query parameters -type ListProjectReq struct { - PaginationReq - IsPublic *bool `form:"is_public" binding:"omitempty"` - Status *consts.StatusType `form:"status" binding:"omitempty"` -} - -func (req *ListProjectReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - return validateStatusField(req.Status, false) -} - -// SearchProjectReq represents advanced project search -type SearchProjectReq struct { - AdvancedSearchReq[string] - - NamePattern string `json:"name_pattern,omitempty"` - DescriptionPattern string `json:"description_pattern,omitempty"` - IsPublic *bool `json:"is_public,omitempty"` -} - -func (req *SearchProjectReq) ConvertToSearchRequest() *SearchReq[string] { - sr := req.ConvertAdvancedToSearch() - - if req.NamePattern != "" { - sr.AddFilter("name", OpLike, req.NamePattern) - } - if req.DescriptionPattern != "" { - sr.AddFilter("description", OpLike, req.DescriptionPattern) - } - if req.IsPublic != nil { - sr.AddFilter("is_public", OpEqual, *req.IsPublic) - } - - return sr -} - -// UpdateProjectReq represents project update request -type UpdateProjectReq struct { - Description *string `json:"description,omitempty"` - IsPublic *bool `json:"is_public,omitempty"` - Status *consts.StatusType `json:"status,omitempty"` -} - -func (req *UpdateProjectReq) Validate() error { - return validateStatusField(req.Status, true) -} - -func (req *UpdateProjectReq) PatchProjectModel(target *database.Project) { - if req.Description != nil { - target.Description = *req.Description - } - if req.IsPublic != nil { - target.IsPublic = *req.IsPublic - } - if req.Status != nil { - target.Status = *req.Status - } -} - -// ProjectResp represents basic project response -type ProjectResp struct { - ID int `json:"id"` - Name string `json:"name"` - IsPublic bool `json:"is_public"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - // Statistics - LastInjectionAt *time.Time `json:"last_injection_at,omitempty"` // Last successful injection time - LastExecutionAt *time.Time `json:"last_execution_at,omitempty"` // Last successful execution time - InjectionCount int `json:"injection_count"` // Total injection count - ExecutionCount int `json:"execution_count"` // Total execution count - - Labels []LabelItem `json:"labels,omitempty"` -} +import "time" // ProjectStatistics holds statistics for a project type ProjectStatistics struct { @@ -126,80 +9,3 @@ type ProjectStatistics struct { LastInjectionAt *time.Time LastExecutionAt *time.Time } - -func NewProjectResp(project *database.Project, stats *ProjectStatistics) *ProjectResp { - resp := &ProjectResp{ - ID: project.ID, - Name: project.Name, - IsPublic: project.IsPublic, - Status: consts.GetStatusTypeName(project.Status), - CreatedAt: project.CreatedAt, - UpdatedAt: project.UpdatedAt, - } - - // Fill statistics if provided - if stats != nil { - resp.LastInjectionAt = stats.LastInjectionAt - resp.LastExecutionAt = stats.LastExecutionAt - resp.InjectionCount = stats.InjectionCount - resp.ExecutionCount = stats.ExecutionCount - } - - if project.Labels != nil { - resp.Labels = make([]LabelItem, len(project.Labels)) - for i, label := range project.Labels { - resp.Labels[i] = LabelItem{ - Key: label.Key, - Value: label.Value, - } - } - } - return resp -} - -// ProjectDetailResp represents detailed project response -type ProjectDetailResp struct { - ProjectResp - - Containers []ContainerResp `json:"containers,omitempty"` - Datapacks []InjectionResp `json:"datapacks,omitempty"` - Datasets []DatasetResp `json:"datasets,omitempty"` - UserCount int `json:"user_count"` -} - -func NewProjectDetailResp(project *database.Project, stats *ProjectStatistics) *ProjectDetailResp { - return &ProjectDetailResp{ - ProjectResp: *NewProjectResp(project, stats), - } -} - -// ===================== Project-Label DTOs ===================== - -// ManageProjectLabelReq represents project label management request -type ManageProjectLabelReq struct { - AddLabels []LabelItem `json:"add_labels" binding:"omitempty"` // List of labels to add - RemoveLabels []string `json:"remove_labels" binding:"omitempty"` // List of label keys to remove -} - -func (req *ManageProjectLabelReq) Validate() error { - if len(req.AddLabels) == 0 && len(req.RemoveLabels) == 0 { - return fmt.Errorf("at least one of add_labels or remove_labels must be provided") - } - - for i, label := range req.AddLabels { - if strings.TrimSpace(label.Key) == "" { - return fmt.Errorf("empty label key at index %d in add_labels", i) - } - if strings.TrimSpace(label.Value) == "" { - return fmt.Errorf("empty label value at index %d in add_labels", i) - } - } - - for i, key := range req.RemoveLabels { - if strings.TrimSpace(key) == "" { - return fmt.Errorf("empty label key at index %d in remove_labels", i) - } - } - - return nil -} diff --git a/src/dto/redis.go b/src/dto/redis.go deleted file mode 100644 index 8787c16c..00000000 --- a/src/dto/redis.go +++ /dev/null @@ -1,10 +0,0 @@ -package dto - -import "aegis/consts" - -type RdbMsg struct { - Status string `json:"status"` - Error string `json:"error"` - TaskID string `json:"task_id"` - Type consts.TaskType `json:"task_type"` -} diff --git a/src/dto/resource.go b/src/dto/resource.go deleted file mode 100644 index f9b72513..00000000 --- a/src/dto/resource.go +++ /dev/null @@ -1,65 +0,0 @@ -package dto - -import ( - "aegis/consts" - "aegis/database" - "fmt" -) - -// ListResourceReq represents request for listing resources -type ListResourceReq struct { - PaginationReq - - Type *consts.ResourceType `form:"type" binding:"omitempty"` - Category *consts.ResourceCategory `form:"category" binding:"omitempty"` -} - -func (req *ListResourceReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - if req.Type != nil { - if _, exists := consts.ValidResourceTypes[*req.Type]; !exists { - return fmt.Errorf("invalid resource type: %d", *req.Type) - } - } - if req.Category != nil { - if _, exists := consts.ValidResourceCategories[*req.Category]; !exists { - return fmt.Errorf("invalid resource category: %d", *req.Category) - } - } - return nil -} - -type ResourceResp struct { - ID int `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Type string `json:"type"` - Category string `json:"category"` - ParentID *int `json:"parent_id,omitempty"` -} - -func NewResourceResp(resource *database.Resource) *ResourceResp { - return &ResourceResp{ - ID: resource.ID, - Name: resource.Name.String(), - DisplayName: resource.DisplayName, - Type: consts.GetResourceTypeName(resource.Type), - Category: consts.GetResourceCategoryName(resource.Category), - ParentID: resource.ParentID, - } -} - -type ResourceDetailResp struct { - ResourceResp - - Description string `json:"description,omitempty"` -} - -func NewResourceDetailResp(resource *database.Resource) *ResourceDetailResp { - return &ResourceDetailResp{ - ResourceResp: *NewResourceResp(resource), - Description: resource.Description, - } -} diff --git a/src/dto/role.go b/src/dto/role.go deleted file mode 100644 index 21079bb0..00000000 --- a/src/dto/role.go +++ /dev/null @@ -1,173 +0,0 @@ -package dto - -import ( - "fmt" - "strings" - "time" - - "aegis/consts" - "aegis/database" -) - -// CreateRoleReq represents role creation request -type CreateRoleReq struct { - Name string `json:"name" binding:"required"` - DisplayName string `json:"display_name" binding:"required"` - Description string `json:"description,omitempty" binding:"omitempty"` -} - -// ConvertToRole converts CreateRoleReq to database Role model -func (req *CreateRoleReq) ConvertToRole() *database.Role { - return &database.Role{ - Name: req.Name, - DisplayName: req.DisplayName, - Description: req.Description, - IsSystem: false, - Status: consts.CommonEnabled, - } -} - -// ListRoleReq represents role list query parameters -type ListRoleReq struct { - PaginationReq - IsSystem *bool `form:"is_system" binding:"omitempty"` - Status *consts.StatusType `form:"status" binding:"omitempty"` -} - -func (req *ListRoleReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - return validateStatusField(req.Status, false) -} - -// SearchRoleReq represents advanced role search with complex filtering -type SearchRoleReq struct { - AdvancedSearchReq[string] - - // Role-specific filter shortcuts - NamePattern string `json:"name_pattern" binding:"omitempty"` // Role name fuzzy match - DisplayNamePattern string `json:"display_name_pattern" binding:"omitempty"` // Display name fuzzy match - DescriptionPattern string `json:"description_pattern" binding:"omitempty"` // Description fuzzy match - IsSystem *bool `json:"is_system" binding:"omitempty"` // Whether system role - PermissionIDs []int `json:"permission_ids" binding:"omitempty"` // Permission ID filter - UserCount *NumberRange `json:"user_count" binding:"omitempty"` // User count range -} - -// ConvertToSearchRequest converts RoleSearchReq to SearchRequest with role-specific filters -func (rsr *SearchRoleReq) ConvertToSearchRequest() *SearchReq[string] { - sr := rsr.ConvertAdvancedToSearch() - - if rsr.NamePattern != "" { - sr.AddFilter("name", OpLike, rsr.NamePattern) - } - if rsr.DisplayNamePattern != "" { - sr.AddFilter("display_name", OpLike, rsr.DisplayNamePattern) - } - if rsr.DescriptionPattern != "" { - sr.AddFilter("description", OpLike, rsr.DescriptionPattern) - } - - if rsr.IsSystem != nil { - sr.AddFilter("is_system", OpEqual, *rsr.IsSystem) - } - if len(rsr.PermissionIDs) > 0 { - values := make([]string, len(rsr.PermissionIDs)) - for i, v := range rsr.PermissionIDs { - values[i] = fmt.Sprintf("%v", v) - } - sr.Filters = append(sr.Filters, SearchFilter{ - Field: "permission_id", - Operator: OpIn, - Values: values, - }) - } - - return sr -} - -// UpdateRoleReq represents role update request -type UpdateRoleReq struct { - DisplayName *string `json:"display_name" binding:"omitempty"` - Description *string `json:"description" binding:"omitempty"` - Status *consts.StatusType `json:"status" binding:"omitempty"` -} - -func (req *UpdateRoleReq) Validate() error { - if req.DisplayName != nil { - if *req.DisplayName != "" { - *req.DisplayName = strings.TrimSpace(*req.DisplayName) - } - } - return validateStatusField(req.Status, true) -} - -func (req *UpdateRoleReq) PatchRoleModel(target *database.Role) { - if req.DisplayName != nil { - target.DisplayName = *req.DisplayName - } - if req.Description != nil { - target.Description = *req.Description - } - if req.Status != nil { - target.Status = *req.Status - } -} - -// AssignRolePermissionReq represents request to assign permissions to a role -type AssignRolePermissionReq struct { - PermissionIDs []int `json:"permission_ids" binding:"required,min=1,non_zero_int_slice"` -} - -// RemoveRolePermissionReq represents request to remove permissions from a role -type RemoveRolePermissionReq struct { - PermissionIDs []int `json:"permission_ids" binding:"required,min=1,non_zero_int_slice"` -} - -// RoleResp represents role response -type RoleResp struct { - ID int `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Type string `json:"type"` - IsSystem bool `json:"is_system"` - Status string `json:"status"` - UpdatedAt time.Time `json:"updated_at"` -} - -// NewRoleResp converts database Role to RoleResp DTO -func NewRoleResp(role *database.Role) *RoleResp { - return &RoleResp{ - ID: role.ID, - Name: role.Name, - DisplayName: role.DisplayName, - IsSystem: role.IsSystem, - Status: consts.GetStatusTypeName(role.Status), - UpdatedAt: role.UpdatedAt, - } -} - -type RoleDetailResp struct { - RoleResp - - Description string `json:"description"` - CreatedAt time.Time `json:"created_at"` - UserCount int64 `json:"user_count"` - - Permissions []PermissionResp `json:"permissions"` -} - -func NewRoleDetailResp(role *database.Role) *RoleDetailResp { - resp := &RoleDetailResp{ - RoleResp: *NewRoleResp(role), - Description: role.Description, - CreatedAt: role.CreatedAt, - } - return resp -} - -// ListRoleResp represents paginated list of roles -type ListRoleResp struct { - Items []RoleResp `json:"items"` - Pagination PaginationInfo `json:"pagination"` -} diff --git a/src/dto/system.go b/src/dto/system.go deleted file mode 100644 index 2bdadd90..00000000 --- a/src/dto/system.go +++ /dev/null @@ -1,76 +0,0 @@ -package dto - -import ( - "time" -) - -// HealthCheckResp represents system health check response -type HealthCheckResp struct { - Status string `json:"status"` - Timestamp time.Time `json:"timestamp"` - Version string `json:"version"` - Uptime string `json:"uptime"` - Services map[string]ServiceInfo `json:"services" swaggertype:"object"` -} - -// ServiceInfo represents individual service health information -type ServiceInfo struct { - Status string `json:"status"` - LastChecked time.Time `json:"last_checked"` - ResponseTime string `json:"response_time"` - Error string `json:"error,omitempty"` - Details any `json:"details,omitempty"` -} - -type NsMonitorItem struct { - LockedBy string `json:"locked_by"` - EndTime time.Time `json:"end_time"` - Status string `json:"status"` -} - -type ListNamespaceLockResp struct { - Items map[string]NsMonitorItem `json:"items" swaggertype:"object"` -} - -// SystemInfo represents system information -type SystemInfo struct { - CPUUsage float64 `json:"cpu_usage"` - MemoryUsage float64 `json:"memory_usage"` - DiskUsage float64 `json:"disk_usage"` - LoadAverage string `json:"load_average"` -} - -// MonitoringQueryReq represents monitoring query request -type MonitoringQueryReq struct { - Query string `json:"query" binding:"required"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` - Step string `json:"step,omitempty"` -} - -// MetricValue represents a single metric value -type MetricValue struct { - Value float64 `json:"value"` - Timestamp time.Time `json:"timestamp"` - Unit string `json:"unit,omitempty"` -} - -// MonitoringMetricsResp represents monitoring metrics response -type MonitoringMetricsResp struct { - Timestamp time.Time `json:"timestamp"` - Metrics map[string]MetricValue `json:"metrics"` - Labels map[string]string `json:"labels,omitempty"` -} - -// SystemMetricsResp represents current system metrics -type SystemMetricsResp struct { - CPU MetricValue `json:"cpu"` - Memory MetricValue `json:"memory"` - Disk MetricValue `json:"disk"` -} - -// SystemMetricsHistoryResp represents historical system metrics -type SystemMetricsHistoryResp struct { - CPU []MetricValue `json:"cpu"` - Memory []MetricValue `json:"memory"` -} diff --git a/src/dto/task.go b/src/dto/task.go index 9a530d83..66511f0e 100644 --- a/src/dto/task.go +++ b/src/dto/task.go @@ -5,12 +5,10 @@ import ( "encoding/json" "fmt" "strconv" - "strings" "time" "aegis/consts" - "aegis/database" - "aegis/utils" + "aegis/model" "github.com/sirupsen/logrus" "go.opentelemetry.io/otel" @@ -46,13 +44,13 @@ type UnifiedTask struct { Extra map[consts.TaskExtra]any `json:"extra,omitempty"` // Additional metadata } -func (t *UnifiedTask) ConvertToTask() (*database.Task, error) { +func (t *UnifiedTask) ConvertToTask() (*model.Task, error) { jsonPayload, err := json.Marshal(t.Payload) if err != nil { return nil, fmt.Errorf("failed to marshal task payload: %w", err) } - task := &database.Task{ + task := &model.Task{ ID: t.TaskID, Type: t.Type, Immediate: t.Immediate, @@ -68,7 +66,7 @@ func (t *UnifiedTask) ConvertToTask() (*database.Task, error) { return task, nil } -func (t *UnifiedTask) ConvertToTrace(withAlgorithms bool, leafNum int) (*database.Trace, error) { +func (t *UnifiedTask) ConvertToTrace(withAlgorithms bool, leafNum int) (*model.Trace, error) { var traceType consts.TraceType switch t.Type { case consts.TaskTypeRestartPedestal: @@ -85,7 +83,7 @@ func (t *UnifiedTask) ConvertToTrace(withAlgorithms bool, leafNum int) (*databas return nil, fmt.Errorf("unsupported task type for trace conversion: %s", consts.GetTaskTypeName(t.Type)) } - trace := &database.Trace{ + trace := &model.Trace{ ID: t.TraceID, Type: traceType, StartTime: time.Now(), @@ -187,170 +185,3 @@ func (t *UnifiedTask) SetGroupCtx(ctx context.Context) { otel.GetTextMapPropagator().Inject(ctx, t.GroupCarrier) } - -// BatchDeleteTaskReq represents the request to batch delete tasks -type BatchDeleteTaskReq struct { - IDs []string `json:"ids" binding:"required"` // List of task IDs for deletion -} - -func (req *BatchDeleteTaskReq) Validate() error { - for i, id := range req.IDs { - if strings.TrimSpace(id) == "" { - return fmt.Errorf("empty id at index %d", i) - } - - if !utils.IsValidUUID(id) { - return fmt.Errorf("invalid UUID format for id at index %d: %s", i, id) - } - } - return nil -} - -// ListTaskFilters represents the filters for listing tasks -type ListTaskFilters struct { - TaskType *consts.TaskType - Immediate *bool - TraceID string - GroupID string - ProjectID int - State *consts.TaskState - Status *consts.StatusType -} - -// ListTaskReq represents the request to list tasks -type ListTaskReq struct { - PaginationReq - TaskType *consts.TaskType `form:"task_type" binding:"omitempty"` - Immediate *bool `form:"immediate" binding:"omitempty"` - TraceID string `form:"trace_id" binding:"omitempty"` - GroupID string `form:"group_id" binding:"omitempty"` - ProjectID int `form:"project_id" binding:"omitempty"` - State *consts.TaskState `form:"state" binding:"omitempty"` - Status *consts.StatusType `form:"status" binding:"omitempty"` -} - -func (req *ListTaskReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - if err := validateTaskType(req.TaskType); err != nil { - return err - } - if err := validateUUID(req.TraceID); err != nil { - return err - } - if err := validateUUID(req.GroupID); err != nil { - return err - } - - // Only validate project ID if it's provided (> 0) - if req.ProjectID < 0 { - return fmt.Errorf("invalid project ID: %d", req.ProjectID) - } - - if err := validateState(req.State); err != nil { - return err - } - return validateStatusField(req.Status, true) -} - -func (req *ListTaskReq) ToFilterOptions() *ListTaskFilters { - return &ListTaskFilters{ - Immediate: req.Immediate, - TaskType: req.TaskType, - TraceID: req.TraceID, - GroupID: req.GroupID, - ProjectID: req.ProjectID, - State: req.State, - Status: req.Status, - } -} - -// TaskResp represents the response for a task -type TaskResp struct { - ID string `json:"id"` - Type string `json:"type"` - Immediate bool `json:"immediate"` - ExecuteTime int64 `json:"execute_time"` - CronExpr string `json:"cron_expr,omitempty"` - TraceID string `json:"trace_id"` - GroupID string `json:"group_id"` - - State string `json:"state"` - Status string `json:"status"` - ProjectID int `json:"project_id,omitempty"` - ProjectName string `json:"project_name,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func NewTaskResp(task *database.Task) *TaskResp { - return &TaskResp{ - ID: task.ID, - Type: consts.GetTaskTypeName(task.Type), - Immediate: task.Immediate, - ExecuteTime: task.ExecuteTime, - CronExpr: task.CronExpr, - TraceID: task.TraceID, - State: consts.GetTaskStateName(task.State), - Status: consts.GetStatusTypeName(task.Status), - CreatedAt: task.CreatedAt, - UpdatedAt: task.UpdatedAt, - } -} - -type TaskDetailResp struct { - TaskResp - - Payload map[string]any `json:"payload,omitempty" swaggertype:"object"` - Logs []string `json:"logs"` -} - -func NewTaskDetailResp(task *database.Task, logs []string) *TaskDetailResp { - resp := &TaskDetailResp{ - TaskResp: *NewTaskResp(task), - Logs: logs, - } - - if task.Payload != "" { - var payload map[string]any - if err := json.Unmarshal([]byte(task.Payload), &payload); err == nil { - resp.Payload = payload - } - } - return resp -} - -// QueuedTasksResp represents the response for queued tasks -type QueuedTasksResp struct { - ReadyTasks []TaskResp `json:"ready_tasks"` - DelayedTasks []TaskResp `json:"delayed_tasks"` -} - -func validateState(state *consts.TaskState) error { - if state != nil { - if _, exists := consts.ValidTaskStates[*state]; !exists { - return fmt.Errorf("invalid task state: %d", *state) - } - } - return nil -} - -func validateTaskType(taskType *consts.TaskType) error { - if taskType != nil { - if _, exists := consts.ValidTaskTypes[*taskType]; !exists { - return fmt.Errorf("invalid task type: %d", *taskType) - } - } - return nil -} - -func validateUUID(id string) error { - if id == "" { - return nil // Empty is valid for optional fields - } - if !utils.IsValidUUID(id) { - return fmt.Errorf("invalid UUID format: %s", id) - } - return nil -} diff --git a/src/dto/trace.go b/src/dto/trace.go index ae49779e..7a68c59a 100644 --- a/src/dto/trace.go +++ b/src/dto/trace.go @@ -2,12 +2,7 @@ package dto import ( "aegis/consts" - "aegis/database" - "aegis/utils" "encoding/json" - "fmt" - "strings" - "time" ) type TraceStreamEvent struct { @@ -95,220 +90,3 @@ type JobMessage struct { Namespace string `json:"namespace"` LogFile string `json:"log_file,omitempty"` } - -type TraceQuery struct { - TraceID string `json:"trace_id"` - FirstTaskType consts.TaskType `json:"first_task_type"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` -} - -type GetTraceStreamReq struct { - LastID string `form:"last_id" binding:"omitempty"` -} - -func (req *GetTraceStreamReq) Validate() error { - if req.LastID == "" { - req.LastID = "0" - } - - if req.LastID == "0" { - return nil - } - - if strings.Count(req.LastID, "-") != 1 { - return fmt.Errorf("invalid last_id format: must be '0' or a valid stream ID (e.g., 1678886400000-0)") - } - - return nil -} - -// GetGroupStatsReq represents the request to get group stats -type GetGroupStatsReq struct { - GroupID string `form:"group_id" binding:"required"` // Group ID to query -} - -func (req *GetGroupStatsReq) Validate() error { - if !utils.IsValidUUID(req.GroupID) { - return fmt.Errorf("invalid group_id: must be a valid UUID") - } - return nil -} - -// TraceStatsItem represents the stat of a trace -type TraceStatsItem struct { - TraceID string `json:"trace_id"` - Type string `json:"type"` - State string `json:"state"` - StartTime time.Time `json:"start_time"` - EndTime *time.Time `json:"end_time,omitempty"` - CurrentEvent string `json:"current_event"` - CurrentTask string `json:"current_task"` - TaskTypeDurations map[string]float64 `json:"task_type_durations,omitempty" swaggertype:"object"` // Average durations per task type in seconds -} - -func NewTraceStats(trace *database.Trace) *TraceStatsItem { - detail := &TraceStatsItem{ - TraceID: trace.ID, - Type: consts.GetTraceTypeName(trace.Type), - State: consts.GetTraceStateName(trace.State), - StartTime: trace.StartTime, - EndTime: trace.EndTime, - CurrentEvent: trace.LastEvent.String(), - } - - if len(trace.Tasks) > 0 { - detail.CurrentTask = trace.Tasks[0].ID - - taskTypeMap := make(map[string][]database.Task) - for _, task := range trace.Tasks { - if task.State == consts.TaskCompleted || task.State == consts.TaskError { - taskTypeName := consts.GetTaskTypeName(task.Type) - if _, exists := taskTypeMap[taskTypeName]; !exists { - taskTypeMap[taskTypeName] = []database.Task{} - } - taskTypeMap[taskTypeName] = append(taskTypeMap[taskTypeName], task) - } - } - - detail.TaskTypeDurations = make(map[string]float64) - for taskTypeName, tasks := range taskTypeMap { - totalDuration := 0.0 - for _, task := range tasks { - duration := task.UpdatedAt.Sub(task.CreatedAt).Seconds() - totalDuration += duration - } - detail.TaskTypeDurations[taskTypeName] = totalDuration / float64(len(tasks)) - } - } - - return detail -} - -// GroupStats represents the response for group stats -type GroupStats struct { - TotalTraces int `json:"total_traces"` - AvgDuration float64 `json:"avg_duration"` - MinDuration float64 `json:"min_duration"` - MaxDuration float64 `json:"max_duration"` - TraceStateMap map[string][]TraceStatsItem `json:"trace_state_map"` -} - -func NewDefaultGroupStats() *GroupStats { - return &GroupStats{ - TotalTraces: 0, - AvgDuration: 0.0, - MinDuration: 0.0, - MaxDuration: 0.0, - } -} - -// ===================== Trace CRUD DTOs ===================== - -// TraceResp represents the response for a trace in list views -type TraceResp struct { - ID string `json:"id"` - Type string `json:"type"` - LastEvent string `json:"last_event"` - StartTime time.Time `json:"start_time"` - EndTime *time.Time `json:"end_time,omitempty"` - GroupID string `json:"group_id"` - ProjectID int `json:"project_id,omitempty"` - ProjectName string `json:"project_name,omitempty"` - LeafNum int `json:"leaf_num"` - State string `json:"state"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func NewTraceResp(trace *database.Trace) *TraceResp { - resp := &TraceResp{ - ID: trace.ID, - Type: consts.GetTraceTypeName(trace.Type), - LastEvent: trace.LastEvent.String(), - StartTime: trace.StartTime, - EndTime: trace.EndTime, - GroupID: trace.GroupID, - ProjectID: trace.ProjectID, - LeafNum: trace.LeafNum, - State: consts.GetTraceStateName(trace.State), - Status: consts.GetStatusTypeName(trace.Status), - CreatedAt: trace.CreatedAt, - UpdatedAt: trace.UpdatedAt, - } - if trace.Project != nil { - resp.ProjectName = trace.Project.Name - } - return resp -} - -// TraceDetailResp represents the detailed response for a single trace -type TraceDetailResp struct { - TraceResp - - Tasks []TaskResp `json:"tasks"` -} - -func NewTraceDetailResp(trace *database.Trace) *TraceDetailResp { - resp := &TraceDetailResp{ - TraceResp: *NewTraceResp(trace), - Tasks: make([]TaskResp, 0, len(trace.Tasks)), - } - for i := range trace.Tasks { - resp.Tasks = append(resp.Tasks, *NewTaskResp(&trace.Tasks[i])) - } - return resp -} - -// ListTraceFilters represents the filters for listing traces -type ListTraceFilters struct { - TraceType *consts.TraceType - GroupID string - ProjectID int - State *consts.TraceState - Status *consts.StatusType -} - -// ListTraceReq represents the request to list traces -type ListTraceReq struct { - PaginationReq - TraceType *consts.TraceType `form:"trace_type" binding:"omitempty"` - GroupID string `form:"group_id" binding:"omitempty"` - ProjectID int `form:"project_id" binding:"omitempty"` - State *consts.TraceState `form:"state" binding:"omitempty"` - Status *consts.StatusType `form:"status" binding:"omitempty"` -} - -func (req *ListTraceReq) Validate() error { - if err := req.PaginationReq.Validate(); err != nil { - return err - } - if req.TraceType != nil { - if _, exists := consts.ValidTraceTypes[*req.TraceType]; !exists { - return fmt.Errorf("invalid trace type: %d", *req.TraceType) - } - } - if err := validateUUID(req.GroupID); err != nil { - return err - } - if req.ProjectID < 0 { - return fmt.Errorf("invalid project ID: %d", req.ProjectID) - } - if req.State != nil { - if _, exists := consts.ValidTraceStates[*req.State]; !exists { - return fmt.Errorf("invalid trace state: %d", *req.State) - } - } - return validateStatusField(req.Status, true) -} - -func (req *ListTraceReq) ToFilterOptions() *ListTraceFilters { - return &ListTraceFilters{ - TraceType: req.TraceType, - GroupID: req.GroupID, - ProjectID: req.ProjectID, - State: req.State, - Status: req.Status, - } -} diff --git a/src/go.mod b/src/go.mod index d37025db..1085f9db 100644 --- a/src/go.mod +++ b/src/go.mod @@ -6,8 +6,9 @@ toolchain go1.24.12 require ( github.com/BurntSushi/toml v1.4.0 + github.com/ClickHouse/clickhouse-go/v2 v2.34.0 + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/OperationsPAI/chaos-experiment v0.1.0 - github.com/alicebob/miniredis/v2 v2.37.0 github.com/antonfisher/nested-logrus-formatter v1.3.1 github.com/apache/arrow-go/v18 v18.4.1 github.com/distribution/reference v0.6.0 @@ -18,6 +19,7 @@ require ( github.com/gin-gonic/gin v1.10.0 github.com/go-logr/stdr v1.2.2 github.com/go-playground/validator/v10 v10.24.0 + github.com/go-sql-driver/mysql v1.8.1 github.com/goharbor/go-client v0.213.1 github.com/golang-jwt/jwt/v5 v5.2.3 github.com/google/uuid v1.6.0 @@ -43,12 +45,12 @@ require ( go.opentelemetry.io/otel/sdk v1.37.0 go.opentelemetry.io/otel/trace v1.37.0 go.opentelemetry.io/proto/otlp v1.5.0 - golang.org/x/crypto v0.42.0 + go.uber.org/fx v1.24.0 golang.org/x/sync v0.17.0 + google.golang.org/grpc v1.75.0 google.golang.org/protobuf v1.36.8 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.5.7 - gorm.io/driver/sqlite v1.5.0 gorm.io/gorm v1.25.12 gorm.io/plugin/opentelemetry v0.1.13 helm.sh/helm/v3 v3.17.3 @@ -60,17 +62,12 @@ require ( sigs.k8s.io/yaml v1.4.0 ) -replace github.com/OperationsPAI/chaos-experiment => ../../chaos-experiment - -replace github.com/chaos-mesh/chaos-mesh/api => github.com/OperationsPAI/chaos-mesh/api v0.0.0-20260124102507-517f3df45e54 - require ( dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/ClickHouse/ch-go v0.65.1 // indirect - github.com/ClickHouse/clickhouse-go/v2 v2.34.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect @@ -151,7 +148,6 @@ require ( github.com/go-openapi/validate v0.20.3 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.5 // indirect @@ -198,7 +194,6 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect @@ -261,7 +256,6 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xlab/treeprint v1.2.0 // indirect - github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.etcd.io/etcd/api/v3 v3.6.7 // indirect @@ -274,9 +268,11 @@ require ( go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.uber.org/dig v1.19.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.12.0 // indirect + golang.org/x/crypto v0.42.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/net v0.45.0 // indirect @@ -291,7 +287,6 @@ require ( google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect - google.golang.org/grpc v1.75.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect @@ -314,6 +309,10 @@ require ( sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect ) +replace github.com/OperationsPAI/chaos-experiment => ../../chaos-experiment + +replace github.com/chaos-mesh/chaos-mesh/api => github.com/OperationsPAI/chaos-mesh/api v0.0.0-20260124102507-517f3df45e54 + replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.0.0-20240518090000-14441aefdf88 replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.3.0 diff --git a/src/go.sum b/src/go.sum index 2f2bd1f9..49146123 100644 --- a/src/go.sum +++ b/src/go.sum @@ -46,8 +46,6 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= -github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= -github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 h1:aM1rlcoLz8y5B2r4tTLMiVTrMtpfY0O8EScKJxaSaEc= github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= @@ -525,6 +523,7 @@ github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0Lh github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= @@ -579,7 +578,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= @@ -840,8 +838,6 @@ github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7Jul github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= -github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI= @@ -899,6 +895,10 @@ go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= +go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -1116,7 +1116,6 @@ gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/driver/sqlite v1.5.0 h1:zKYbzRCpBrT1bNijRnxLDJWPjVfImGEn0lSnUY5gZ+c= gorm.io/driver/sqlite v1.5.0/go.mod h1:kDMDfntV9u/vuMmz8APHtHF0b4nyBB7sfCieC6G8k8I= -gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/src/handlers/common_test.go b/src/handlers/common_test.go deleted file mode 100644 index 50218d04..00000000 --- a/src/handlers/common_test.go +++ /dev/null @@ -1,220 +0,0 @@ -package handlers - -import ( - "aegis/consts" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" -) - -type errorResp struct { - Code int `json:"code"` - Message string `json:"message"` -} - -func init() { - gin.SetMode(gin.TestMode) -} - -func setupTestContext() (*gin.Context, *httptest.ResponseRecorder) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodGet, "/test", nil) - return c, w -} - -func parseErrorResp(t *testing.T, w *httptest.ResponseRecorder) errorResp { - t.Helper() - var resp errorResp - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(t, err, "failed to parse response body") - return resp -} - -// --------------------------------------------------------------------------- -// HandleServiceError tests -// --------------------------------------------------------------------------- - -func TestHandleServiceError_AuthenticationFailed(t *testing.T) { - c, w := setupTestContext() - wrapped := fmt.Errorf("login failed: %w", consts.ErrAuthenticationFailed) - - handled := HandleServiceError(c, wrapped) - - assert.True(t, handled) - assert.Equal(t, http.StatusUnauthorized, w.Code) - resp := parseErrorResp(t, w) - assert.Equal(t, http.StatusUnauthorized, resp.Code) -} - -func TestHandleServiceError_BadRequest(t *testing.T) { - c, w := setupTestContext() - wrapped := fmt.Errorf("invalid input: %w", consts.ErrBadRequest) - - handled := HandleServiceError(c, wrapped) - - assert.True(t, handled) - assert.Equal(t, http.StatusBadRequest, w.Code) - resp := parseErrorResp(t, w) - assert.Equal(t, http.StatusBadRequest, resp.Code) -} - -func TestHandleServiceError_NotFound(t *testing.T) { - c, w := setupTestContext() - wrapped := fmt.Errorf("project not found: %w", consts.ErrNotFound) - - handled := HandleServiceError(c, wrapped) - - assert.True(t, handled) - assert.Equal(t, http.StatusNotFound, w.Code) - resp := parseErrorResp(t, w) - assert.Equal(t, http.StatusNotFound, resp.Code) -} - -func TestHandleServiceError_AlreadyExists(t *testing.T) { - c, w := setupTestContext() - wrapped := fmt.Errorf("duplicate entry: %w", consts.ErrAlreadyExists) - - handled := HandleServiceError(c, wrapped) - - assert.True(t, handled) - assert.Equal(t, http.StatusConflict, w.Code) - resp := parseErrorResp(t, w) - assert.Equal(t, http.StatusConflict, resp.Code) -} - -func TestHandleServiceError_PermissionDenied(t *testing.T) { - c, w := setupTestContext() - wrapped := fmt.Errorf("access denied: %w", consts.ErrPermissionDenied) - - handled := HandleServiceError(c, wrapped) - - assert.True(t, handled) - assert.Equal(t, http.StatusForbidden, w.Code) - resp := parseErrorResp(t, w) - assert.Equal(t, http.StatusForbidden, resp.Code) -} - -func TestHandleServiceError_Internal_SanitizesMessage(t *testing.T) { - c, w := setupTestContext() - wrapped := fmt.Errorf("db connection pool exhausted: %w", consts.ErrInternal) - - handled := HandleServiceError(c, wrapped) - - assert.True(t, handled) - assert.Equal(t, http.StatusInternalServerError, w.Code) - resp := parseErrorResp(t, w) - assert.Equal(t, http.StatusInternalServerError, resp.Code) - assert.Equal(t, "Internal server error", resp.Message, "internal errors must be sanitized") - assert.NotContains(t, resp.Message, "db connection pool", "internal details must not leak") -} - -func TestHandleServiceError_WrappedInternal_SanitizesMessage(t *testing.T) { - c, w := setupTestContext() - innerErr := errors.New("redis connection refused") - wrapped := fmt.Errorf("%w: %v", consts.ErrInternal, innerErr) - - handled := HandleServiceError(c, wrapped) - - assert.True(t, handled) - assert.Equal(t, http.StatusInternalServerError, w.Code) - resp := parseErrorResp(t, w) - assert.Equal(t, http.StatusInternalServerError, resp.Code) - assert.Equal(t, "Internal server error", resp.Message, "wrapped internal errors must be sanitized") - assert.NotContains(t, resp.Message, "redis", "internal details must not leak through wrapped errors") -} - -func TestHandleServiceError_NilError_ReturnsFalse(t *testing.T) { - c, w := setupTestContext() - - handled := HandleServiceError(c, nil) - - assert.False(t, handled) - assert.Equal(t, http.StatusOK, w.Code, "response should not be written for nil error") -} - -func TestHandleServiceError_UnknownError_Returns500(t *testing.T) { - c, w := setupTestContext() - unknown := errors.New("something completely unexpected") - - handled := HandleServiceError(c, unknown) - - assert.True(t, handled) - assert.Equal(t, http.StatusInternalServerError, w.Code) - resp := parseErrorResp(t, w) - assert.Equal(t, http.StatusInternalServerError, resp.Code) - assert.Equal(t, "An unexpected error occurred", resp.Message, - "unknown errors should return a generic message") -} - -func TestHandleServiceError_WrappedMessage_UsesUserFriendly(t *testing.T) { - c, w := setupTestContext() - // Two-level wrap: outermost -> user-friendly message -> sentinel - wrapped := fmt.Errorf("project 42 not found: %w", consts.ErrNotFound) - - handled := HandleServiceError(c, wrapped) - - assert.True(t, handled) - resp := parseErrorResp(t, w) - assert.Contains(t, resp.Message, "project 42 not found", - "user-friendly wrapper message should be used") -} - -// --------------------------------------------------------------------------- -// ParsePositiveID tests -// --------------------------------------------------------------------------- - -func TestParsePositiveID_InvalidString(t *testing.T) { - c, w := setupTestContext() - - id, ok := ParsePositiveID(c, "abc", "project_id") - - assert.False(t, ok) - assert.Equal(t, 0, id) - assert.Equal(t, http.StatusBadRequest, w.Code) - resp := parseErrorResp(t, w) - assert.Contains(t, resp.Message, "abc", - "error message should include the rejected value") -} - -func TestParsePositiveID_Zero(t *testing.T) { - c, w := setupTestContext() - - id, ok := ParsePositiveID(c, "0", "project_id") - - assert.False(t, ok) - assert.Equal(t, 0, id) - assert.Equal(t, http.StatusBadRequest, w.Code) - resp := parseErrorResp(t, w) - assert.Contains(t, resp.Message, "0", - "error message should include the rejected value") -} - -func TestParsePositiveID_Negative(t *testing.T) { - c, w := setupTestContext() - - id, ok := ParsePositiveID(c, "-1", "project_id") - - assert.False(t, ok) - assert.Equal(t, 0, id) - assert.Equal(t, http.StatusBadRequest, w.Code) - resp := parseErrorResp(t, w) - assert.Contains(t, resp.Message, "-1", - "error message should include the rejected value") -} - -func TestParsePositiveID_Valid(t *testing.T) { - c, w := setupTestContext() - - id, ok := ParsePositiveID(c, "42", "project_id") - - assert.True(t, ok) - assert.Equal(t, 42, id) - assert.Equal(t, http.StatusOK, w.Code, "no error response should be written for valid ID") -} diff --git a/src/handlers/debug.go b/src/handlers/debug.go deleted file mode 100644 index d280d48f..00000000 --- a/src/handlers/debug.go +++ /dev/null @@ -1,45 +0,0 @@ -package handlers - -import ( - "net/http" - - "aegis/client/debug" - "aegis/dto" - - "github.com/gin-gonic/gin" -) - -func GetAllVars(c *gin.Context) { - dto.SuccessResponse[any](c, debug.NewDebugRegistry().GetAll()) -} - -func GetVar(c *gin.Context) { - var req dto.DebugGetReq - if err := c.BindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "invalid JSON format") - return - } - - data, err := debug.NewDebugRegistry().Get(req.Name) - if err != nil { - dto.ErrorResponse(c, http.StatusNotFound, err.Error()) - return - } - - dto.SuccessResponse[any](c, data) -} - -func SetVar(c *gin.Context) { - var req dto.DebugSetReq - if err := c.BindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "invalid JSON format") - return - } - - if err := debug.NewDebugRegistry().Set(req.Name, req.Value); err != nil { - dto.ErrorResponse(c, http.StatusNotFound, err.Error()) - return - } - - dto.SuccessResponse[any](c, nil) -} diff --git a/src/handlers/system/audit.go b/src/handlers/system/audit.go deleted file mode 100644 index 50d7fbcc..00000000 --- a/src/handlers/system/audit.go +++ /dev/null @@ -1,85 +0,0 @@ -package system - -import ( - "net/http" - "strconv" - - "aegis/dto" - "aegis/handlers" - producer "aegis/service/producer" - - "github.com/gin-gonic/gin" -) - -// GetAuditLog handles single audit log retrieval -// -// @Summary Get audit log by ID -// @Description Get a specific audit log entry by ID -// @Tags System -// @Produce json -// @Security BearerAuth -// @Param id path int true "Audit log ID" -// @Success 200 {object} dto.GenericResponse[dto.AuditLogDetailResp] "Audit log retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Audit log not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /system/audit/{id} [get] -func GetAuditLog(c *gin.Context) { - idStr := c.Param("id") - id, err := strconv.Atoi(idStr) - if err != nil || id <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid audit log ID") - return - } - - resp, err := producer.GetAuditLogDetail(id) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// ListAuditLogs handles audit log listing -// -// @Summary List audit logs -// @Description Get paginated list of audit logs with optional filtering -// @Tags System -// @Produce json -// @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param action query string false "Filter by action" -// @Param user_id query int false "Filter by user ID" -// @Param resource_id query int false "Filter by resource ID" -// @Param state query int false "Filter by state" -// @Param status query int false "Filter by status" -// @Param start_date query string false "Filter from date (YYYY-MM-DD)" -// @Param end_date query string false "Filter to date (YYYY-MM-DD)" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.AuditLogResp]] "Audit logs retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format/parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /system/audit [get] -func ListAuditLogs(c *gin.Context) { - var req dto.ListAuditLogReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid query format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid query parameters: "+err.Error()) - return - } - - resp, err := producer.ListAuditLogs(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusOK, "Audit logs retrieved successfully", resp) -} diff --git a/src/handlers/system/configs.go b/src/handlers/system/configs.go deleted file mode 100644 index d093c695..00000000 --- a/src/handlers/system/configs.go +++ /dev/null @@ -1,335 +0,0 @@ -package system - -import ( - "net/http" - "strconv" - - "aegis/consts" - "aegis/dto" - "aegis/handlers" - "aegis/middleware" - producer "aegis/service/producer" - - "github.com/gin-gonic/gin" -) - -// GetConfig retrieves a configuration by ID -// -// @Summary Get configuration -// @Description Get detailed information about a specific configuration -// @Tags Configurations -// @ID get_config_by_id -// @Produce json -// @Security BearerAuth -// @Param config_id path int true "Configuration ID" -// @Success 200 {object} dto.GenericResponse[dto.ConfigResp] "Configuration retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid config ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Config not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /system/configs/{config_id} [get] -func GetConfig(c *gin.Context) { - configIDStr := c.Param(consts.URLPathConfigID) - configID, err := strconv.Atoi(configIDStr) - if err != nil || configID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid config ID") - return - } - - resp, err := producer.GetConfigDetail(configID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// ListConfigs lists configurations with pagination and filtering -// -// @Summary List configurations -// @Description List configurations with pagination and optional filters -// @Tags Configurations -// @ID list_configs -// @Produce json -// @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param page_size query int false "Page size" default(20) -// @Param category query string false "Filter by configuration category" -// @Param value_type query consts.ConfigValueType false "Filter by configuration value type" -// @Param is_secret query bool false "Filter by secret status" -// @Param updated_by query int false "Filter by ID of the user who last updated the config" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.ConfigResp]] "Configurations retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /system/configs [get] -func ListConfigs(c *gin.Context) { - var req dto.ListConfigReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request parameters: "+err.Error()) - return - } - - resp, err := producer.ListConfigs(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// RollbackConfigValue rolls back a configuration value to previous value from history -// -// @Summary Rollback configuration value -// @Description Rollback a configuration value to a previous value from history -// @Tags Configurations -// @ID rollback_config_value -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param config_id path int true "Configuration ID" -// @Param rollback body dto.RollbackConfigReq true "Rollback request with history_id and reason" -// @Success 202 {object} dto.GenericResponse[any] "Configuration value rolled back successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid config ID/request format/history is not a value change" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 404 {object} dto.GenericResponse[any] "Configuration or history not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /system/configs/{config_id}/value/rollback [post] -func RollbackConfigValue(c *gin.Context) { - userID, exists := middleware.GetCurrentUserID(c) - if !exists || userID <= 0 { - dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") - return - } - - configIDStr := c.Param(consts.URLPathConfigID) - configID, err := strconv.Atoi(configIDStr) - if err != nil || configID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid config ID") - return - } - - ctx := c.Request.Context() - - var req dto.RollbackConfigReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - ipAddress := c.ClientIP() - userAgent := c.Request.UserAgent() - - err = producer.RollbackConfigValue(ctx, &req, configID, userID, ipAddress, userAgent) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse[any](c, http.StatusAccepted, "Configuration value rolled back successfully", nil) -} - -// RollbackConfigMetadata rolls back a configuration metadata field to previous value from history -// -// @Summary Rollback configuration metadata -// @Description Rollback a configuration metadata field (e.g., min_value, max_value, pattern) to a previous value from history -// @Tags Configurations -// @ID rollback_config_metadata -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param config_id path int true "Configuration ID" -// @Param rollback body dto.RollbackConfigReq true "Rollback request with history_id and reason" -// @Success 200 {object} dto.GenericResponse[dto.ConfigResp] "Configuration metadata rolled back successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid config ID/request format/history is a value change" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied - admin only" -// @Failure 404 {object} dto.GenericResponse[any] "Configuration or history not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /system/configs/{config_id}/metadata/rollback [post] -func RollbackConfigMetadata(c *gin.Context) { - userID, exists := middleware.GetCurrentUserID(c) - if !exists || userID <= 0 { - dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") - return - } - - configIDStr := c.Param(consts.URLPathConfigID) - configID, err := strconv.Atoi(configIDStr) - if err != nil || configID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid config ID") - return - } - - var req dto.RollbackConfigReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - ipAddress := c.ClientIP() - userAgent := c.Request.UserAgent() - - resp, err := producer.RollbackConfigMetadata(&req, configID, userID, ipAddress, userAgent) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusOK, "Configuration metadata rolled back successfully", resp) -} - -// UpdateConfigValue updates a configuration value (runtime operational change) -// -// @Summary Update configuration value -// @Description Update a configuration value with validation and history tracking. This is for frequent operational adjustments. -// @Tags Configurations -// @ID update_config_value -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param config_id path int true "Configuration ID" -// @Param request body dto.UpdateConfigValueReq true "Configuration value update request" -// @Success 202 {object} dto.GenericResponse[any] "Configuration value updated successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid config ID/request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 404 {object} dto.GenericResponse[any] "Configuration not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /system/configs/{config_id} [patch] -func UpdateConfigValue(c *gin.Context) { - userID, exists := middleware.GetCurrentUserID(c) - if !exists || userID <= 0 { - dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") - return - } - - configIDStr := c.Param(consts.URLPathConfigID) - configID, err := strconv.Atoi(configIDStr) - if err != nil || configID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid config ID") - return - } - - ctx := c.Request.Context() - - var req dto.UpdateConfigValueReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - ipAddress := c.ClientIP() - userAgent := c.Request.UserAgent() - - err = producer.UpdateConfigValue(ctx, &req, configID, userID, ipAddress, userAgent) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse[any](c, http.StatusAccepted, "Configuration value updated successfully", nil) -} - -// UpdateConfigMetadata updates configuration metadata (rare admin operation) -// -// @Summary Update configuration metadata -// @Description Update configuration metadata such as min/max values, validation rules, etc. This is a high-privilege operation. -// @Tags Configurations -// @ID update_config_metadata -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param config_id path int true "Configuration ID" -// @Param request body dto.UpdateConfigMetadataReq true "Configuration metadata update request" -// @Success 200 {object} dto.GenericResponse[dto.ConfigResp] "Configuration metadata updated successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid config ID/request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied - admin only" -// @Failure 404 {object} dto.GenericResponse[any] "Configuration not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /system/configs/{config_id}/metadata [put] -func UpdateConfigMetadata(c *gin.Context) { - userID, exists := middleware.GetCurrentUserID(c) - if !exists || userID <= 0 { - dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") - return - } - - configIDStr := c.Param(consts.URLPathConfigID) - configID, err := strconv.Atoi(configIDStr) - if err != nil || configID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid config ID") - return - } - - var req dto.UpdateConfigMetadataReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request parameters: "+err.Error()) - return - } - - ipAddress := c.ClientIP() - userAgent := c.Request.UserAgent() - - resp, err := producer.UpdateConfigMetadata(&req, configID, userID, ipAddress, userAgent) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusOK, "Configuration metadata updated successfully", resp) -} - -// ===================== Config History ===================== - -// ListConfigHistories handles listing config histories with pagination and filtering -// -// @Summary List configuration histories -// @Description Get paginated list of config histories for a specific config -// @Tags Configurations -// @ID list_config_histories -// @Produce json -// @Security BearerAuth -// @Param config_id path int true "Configuration ID" -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.ConfigHistoryResp]] "Config histories retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /system/configs/{config_id}/histories [get] -func ListConfigHistories(c *gin.Context) { - configIDStr := c.Param(consts.URLPathConfigID) - configID, err := strconv.Atoi(configIDStr) - if err != nil || configID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid config ID") - return - } - - var req dto.ListConfigHistoryReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request parameters: "+err.Error()) - return - } - - resp, err := producer.ListConfigHistories(&req, configID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusOK, "Config historys retrieved successfully", resp) -} diff --git a/src/handlers/system/health.go b/src/handlers/system/health.go deleted file mode 100644 index f08a3fc4..00000000 --- a/src/handlers/system/health.go +++ /dev/null @@ -1,289 +0,0 @@ -package system - -import ( - "aegis/client" - "aegis/client/k8s" - "aegis/config" - "aegis/database" - "aegis/dto" - "context" - "fmt" - "net" - "net/http" - "time" - - "github.com/gin-gonic/gin" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// GetHealth handles system health check -// -// @Summary System health check -// @Description Get system health status and service information -// @Tags System -// @ID get_system_health -// @Produce json -// @Success 200 {object} dto.GenericResponse[dto.HealthCheckResp] "Health check successful" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /system/health [get] -// @x-api-type {"sdk":"true"} -func GetHealth(c *gin.Context) { - start := time.Now() - - services := make(map[string]dto.ServiceInfo) - overallStatus := "healthy" - - buildkitInfo := checkBuildKitHealth() - services["buildkit"] = buildkitInfo - if buildkitInfo.Status != "healthy" { - overallStatus = "unhealthy" - } - - dbInfo := checkDatabaseHealth() - services["database"] = dbInfo - if dbInfo.Status != "healthy" { - overallStatus = "unhealthy" - } - - jaegerInfo := checkJaegerHealth() - services["jaeger"] = jaegerInfo - if jaegerInfo.Status != "healthy" { - overallStatus = "unhealthy" - } - - k8sInfo := checkKubernetesHealth() - services["kubernetes"] = k8sInfo - if k8sInfo.Status != "healthy" { - overallStatus = "unhealthy" - } - - redisInfo := checkRedisHealth() - services["redis"] = redisInfo - if redisInfo.Status != "healthy" { - overallStatus = "unhealthy" - } - - response := dto.HealthCheckResp{ - Status: overallStatus, - Timestamp: time.Now(), - Version: config.GetString("version"), - Uptime: time.Since(start).String(), - Services: services, - } - - dto.SuccessResponse(c, response) -} - -// checkBuildKitHealth checks BuildKit daemon connectivity -func checkBuildKitHealth() dto.ServiceInfo { - start := time.Now() - - buildkitAddr := config.GetString("buildkit.address") - - conn, err := net.DialTimeout("tcp", buildkitAddr, 5*time.Second) - responseTime := time.Since(start) - - if err != nil { - return dto.ServiceInfo{ - Status: "unhealthy", - LastChecked: time.Now(), - ResponseTime: responseTime.String(), - Error: "BuildKit daemon unreachable", - Details: fmt.Sprintf("Cannot connect to BuildKit at %s: %v", buildkitAddr, err), - } - } - defer func() { _ = conn.Close() }() - - return dto.ServiceInfo{ - Status: "healthy", - LastChecked: time.Now(), - ResponseTime: responseTime.String(), - } -} - -// checkDatabaseHealth checks database connectivity -func checkDatabaseHealth() dto.ServiceInfo { - start := time.Now() - - if database.DB == nil { - return dto.ServiceInfo{ - Status: "unhealthy", - LastChecked: time.Now(), - ResponseTime: "N/A", - Error: "Database connection not available", - } - } - - // Test connection with a simple query - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - var result int - err := database.DB.WithContext(ctx).Raw("SELECT 1").Scan(&result).Error - responseTime := time.Since(start) - - if err != nil { - return dto.ServiceInfo{ - Status: "unhealthy", - LastChecked: time.Now(), - ResponseTime: responseTime.String(), - Error: "Database query failed", - Details: err.Error(), - } - } - - return dto.ServiceInfo{ - Status: "healthy", - LastChecked: time.Now(), - ResponseTime: responseTime.String(), - } -} - -// checkJaegerHealth checks Jaeger tracing service connectivity -func checkJaegerHealth() dto.ServiceInfo { - start := time.Now() - - jaegerURL := fmt.Sprintf("http://%s/v1/traces", config.GetString("jaeger.endpoint")) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "HEAD", jaegerURL, nil) - if err != nil { - return dto.ServiceInfo{ - Status: "unhealthy", - LastChecked: time.Now(), - ResponseTime: time.Since(start).String(), - Error: "Failed to create Jaeger OTLP request", - Details: err.Error(), - } - } - - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Do(req) - responseTime := time.Since(start) - - if err != nil { - return dto.ServiceInfo{ - Status: "unhealthy", - LastChecked: time.Now(), - ResponseTime: responseTime.String(), - Error: "Jaeger OTLP endpoint unreachable", - Details: err.Error(), - } - } - defer func() { _ = resp.Body.Close() }() - - // OTLP endpoints typically return 405 Method Not Allowed for HEAD requests - if resp.StatusCode != http.StatusMethodNotAllowed && resp.StatusCode != http.StatusOK { - return dto.ServiceInfo{ - Status: "unhealthy", - LastChecked: time.Now(), - ResponseTime: responseTime.String(), - Error: fmt.Sprintf("Jaeger OTLP returned unexpected status %d", resp.StatusCode), - } - } - - return dto.ServiceInfo{ - Status: "healthy", - LastChecked: time.Now(), - ResponseTime: responseTime.String(), - Details: "Jaeger OTLP endpoint responding", - } -} - -// checkKubernetesHealth checks Kubernetes API connectivity -func checkKubernetesHealth() dto.ServiceInfo { - start := time.Now() - - // Try to get Kubernetes config - restConfig := k8s.GetK8sRestConfig() - if restConfig == nil { - return dto.ServiceInfo{ - Status: "unhealthy", - LastChecked: time.Now(), - ResponseTime: time.Since(start).String(), - Error: "Kubernetes config not available", - } - } - - // Create Kubernetes client - k8sClient := k8s.GetK8sClient() - if k8sClient == nil { - return dto.ServiceInfo{ - Status: "unhealthy", - LastChecked: time.Now(), - ResponseTime: time.Since(start).String(), - Error: "Kubernetes client not available", - } - } - - // Create Kubernetes dynamic client - k8sDynamicClient := k8s.GetK8sDynamicClient() - if k8sDynamicClient == nil { - return dto.ServiceInfo{ - Status: "unhealthy", - LastChecked: time.Now(), - ResponseTime: time.Since(start).String(), - Error: "Kubernetes dynamic client not available", - } - } - - // Test API connectivity - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - _, err := k8sClient.CoreV1().Namespaces().List(ctx, metav1.ListOptions{Limit: 1}) - if err != nil { - return dto.ServiceInfo{ - Status: "unhealthy", - LastChecked: time.Now(), - ResponseTime: time.Since(start).String(), - Error: "Kubernetes API request failed", - Details: err.Error(), - } - } - - return dto.ServiceInfo{ - Status: "healthy", - LastChecked: time.Now(), - ResponseTime: time.Since(start).String(), - } -} - -// checkRedisHealth checks Redis connectivity -func checkRedisHealth() dto.ServiceInfo { - start := time.Now() - - rdb := client.GetRedisClient() - if rdb == nil { - return dto.ServiceInfo{ - Status: "unhealthy", - LastChecked: time.Now(), - ResponseTime: "N/A", - Error: "Redis connection not available", - } - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // Test connection with PING - result := rdb.Ping(ctx) - responseTime := time.Since(start) - - if result.Err() != nil { - return dto.ServiceInfo{ - Status: "unhealthy", - LastChecked: time.Now(), - ResponseTime: responseTime.String(), - Error: result.Err().Error(), - } - } - - return dto.ServiceInfo{ - Status: "healthy", - LastChecked: time.Now(), - ResponseTime: responseTime.String(), - } -} diff --git a/src/handlers/system/monitor.go b/src/handlers/system/monitor.go deleted file mode 100644 index 5a72ce94..00000000 --- a/src/handlers/system/monitor.go +++ /dev/null @@ -1,152 +0,0 @@ -package system - -import ( - "aegis/config" - "aegis/dto" - "aegis/handlers" - producer "aegis/service/producer" - "net/http" - "runtime" - "time" - - "github.com/gin-gonic/gin" -) - -// GetMetrics handles monitoring metrics query -// -// @Summary Get monitoring metrics -// @Description Deprecated: This endpoint returns hardcoded/fabricated data. Use the v2 equivalent GET /api/v2/system/metrics which provides real system metrics via gopsutil. -// @Deprecated -// @Tags System -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body dto.MonitoringQueryReq true "Metrics query request" -// @Success 200 {object} dto.GenericResponse[dto.MonitoringMetricsResp] "Metrics retrieved successfully" -// @Success 400 {object} dto.GenericResponse[any] "Invalid request format" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /system/monitor/metrics [post] -func GetMetrics(c *gin.Context) { - var req dto.MonitoringQueryReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - // Deprecated: returns hardcoded data. Use GET /api/v2/system/metrics instead. - c.Header("Deprecation", "true") - c.Header("Link", `; rel="successor-version"`) - - metrics := map[string]dto.MetricValue{ - "cpu_usage": { - Value: 25.5, - Timestamp: time.Now(), - Unit: "percent", - }, - "memory_usage": { - Value: 60.2, - Timestamp: time.Now(), - Unit: "percent", - }, - "disk_usage": { - Value: 45.8, - Timestamp: time.Now(), - Unit: "percent", - }, - "active_connections": { - Value: 142, - Timestamp: time.Now(), - Unit: "count", - }, - } - - labels := map[string]string{ - "instance": "rcabench-01", - "version": config.GetString("version"), - } - - response := dto.MonitoringMetricsResp{ - Timestamp: time.Now(), - Metrics: metrics, - Labels: labels, - } - - dto.SuccessResponse(c, response) -} - -// GetSystemInfo handles basic system information -// -// @Summary Get system information -// @Description Deprecated: This endpoint returns partially hardcoded data. Use the v2 equivalent GET /api/v2/system/metrics which provides real system metrics via gopsutil. -// @Deprecated -// @Tags System -// @Produce json -// @Security BearerAuth -// @Success 200 {object} dto.GenericResponse[dto.SystemInfo] "System info retrieved successfully" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /system/monitor/info [get] -func GetSystemInfo(c *gin.Context) { - var memStats runtime.MemStats - runtime.ReadMemStats(&memStats) - - // Deprecated: returns partially hardcoded data. Use GET /api/v2/system/metrics instead. - c.Header("Deprecation", "true") - c.Header("Link", `; rel="successor-version"`) - - info := dto.SystemInfo{ - CPUUsage: 25.5, - MemoryUsage: float64(memStats.Alloc) / float64(memStats.Sys) * 100, - DiskUsage: 45.8, - LoadAverage: "1.2, 1.5, 1.8", - } - - dto.SuccessResponse(c, info) -} - -// ListNamespaceLocks handles listing of namespace locks -// -// @Summary List namespace locks -// @Description Retrieve the list of currently locked namespaces -// @Tags System -// @Produce json -// @Security BearerAuth -// @Success 200 {object} dto.GenericResponse[dto.ListNamespaceLockResp] "Successfully retrieved the list of locks" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal Server Error" -// @Router /system/monitor/namespaces/locks [get] -func ListNamespaceLocks(c *gin.Context) { - items, err := producer.InspectLock(c.Request.Context()) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusOK, "Successfully retrieved the list of locks", items) -} - -// ListQueuedTasks handles listing of queued tasks -// -// @Summary List queued tasks -// @Description List tasks in queue (ready and delayed) -// @Tags System -// @Produce json -// @Security BearerAuth -// @Success 200 {object} dto.GenericResponse[dto.QueuedTasksResp] "Queued tasks retrieved successfully" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "No queued tasks found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /system/monitor/tasks/queue [post] -func ListQueuedTasks(c *gin.Context) { - ctx := c.Request.Context() - resp, err := producer.ListQueuedTasks(ctx) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusOK, "Queued tasks retrieved successfully", resp) -} diff --git a/src/handlers/v2/auth.go b/src/handlers/v2/auth.go deleted file mode 100644 index 1b6fd9eb..00000000 --- a/src/handlers/v2/auth.go +++ /dev/null @@ -1,220 +0,0 @@ -package v2 - -import ( - "context" - "net/http" - - "aegis/dto" - "aegis/handlers" - "aegis/middleware" - producer "aegis/service/producer" - "aegis/utils" - - "github.com/gin-gonic/gin" -) - -// Register handles user registration -// -// @Summary User registration -// @Description Register a new user account -// @Tags Authentication -// @ID register_user -// @Accept json -// @Produce json -// @Param request body dto.RegisterReq true "Registration details" -// @Success 201 {object} dto.GenericResponse[dto.UserInfo] "Registration successful" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format/parameters" -// @Failure 409 {object} dto.GenericResponse[any] "User already exists" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/auth/register [post] -// @x-api-type {"sdk":"true"} -func Register(c *gin.Context) { - var req dto.RegisterReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.Register(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusCreated, "Registration successful", resp) -} - -// Login handles user authentication -// -// @Summary User login -// @Description Authenticate user with username and password -// @Tags Authentication -// @ID login -// @Accept json -// @Produce json -// @Param request body dto.LoginReq true "Login credentials" -// @Success 200 {object} dto.GenericResponse[dto.LoginResp] "Login successful" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format" -// @Failure 401 {object} dto.GenericResponse[any] "Invalid user name or password" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/auth/login [post] -// @x-api-type {"sdk":"true"} -func Login(c *gin.Context) { - var req dto.LoginReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusUnauthorized, err.Error()) - return - } - - resp, err := producer.Login(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusOK, "Login successful", resp) -} - -// RefreshToken handles JWT token refresh -// -// @Summary Refresh JWT token -// @Description Refresh an existing JWT token -// @Tags Authentication -// @ID refresh_auth_token -// @Accept json -// @Produce json -// @Param request body dto.TokenRefreshReq true "Token refresh request" -// @Success 200 {object} dto.GenericResponse[dto.TokenRefreshResp] "Token refreshed successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format" -// @Failure 401 {object} dto.GenericResponse[any] "Invalid token" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/auth/refresh [post] -func RefreshToken(c *gin.Context) { - var req dto.TokenRefreshReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusUnauthorized, err.Error()) - return - } - - resp, err := producer.RefreshToken(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusOK, "Token refreshed successfully", resp) -} - -// Logout handles user logout -// -// @Summary User logout -// @Description Logout user and invalidate token -// @Tags Authentication -// @ID logout -// @Produce json -// @Success 200 {object} dto.GenericResponse[any] "Logout successful" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid authorization header" -// @Failure 401 {object} dto.GenericResponse[any] "Invalid token" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/auth/logout [post] -func Logout(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - token, err := utils.ExtractTokenFromHeader(authHeader) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid authorization header") - return - } - - claims, err := utils.ValidateToken(token) - if err != nil { - dto.ErrorResponse(c, http.StatusUnauthorized, "Invalid token") - return - } - - err = producer.Logout(context.Background(), claims) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse[any](c, http.StatusOK, "Logged out successfully", nil) -} - -// ChangePassword handles password change -// -// @Summary Change user password -// @Description Change password for authenticated user -// @Tags Authentication -// @ID change_password -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body dto.ChangePasswordReq true "Password change request" -// @Success 200 {object} dto.GenericResponse[any] "Password changed successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format/parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/auth/change-password [post] -func ChangePassword(c *gin.Context) { - userID, exists := middleware.GetCurrentUserID(c) - if !exists || userID <= 0 { - dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") - return - } - - var req dto.ChangePasswordReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - err := producer.ChangePassword(&req, userID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse[any](c, http.StatusOK, "Password changed successfully", nil) -} - -// GetProfile handles getting current user profile -// -// @Summary Get current user profile -// @Description Get profile information for authenticated user -// @Tags Authentication -// @ID get_current_user_profile -// @Produce json -// @Security BearerAuth -// @Success 200 {object} dto.GenericResponse[dto.UserDetailResp] "Profile retrieved successfully" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/auth/profile [get] -func GetProfile(c *gin.Context) { - userID, exists := middleware.GetCurrentUserID(c) - if !exists || userID <= 0 { - dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") - return - } - - resp, err := producer.GetProfile(userID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusOK, "Profile retrieved successfully", resp) -} diff --git a/src/handlers/v2/executions.go b/src/handlers/v2/executions.go deleted file mode 100644 index 87aabf8b..00000000 --- a/src/handlers/v2/executions.go +++ /dev/null @@ -1,353 +0,0 @@ -package v2 - -import ( - "aegis/consts" - "aegis/dto" - "aegis/handlers" - "aegis/middleware" - producer "aegis/service/producer" - "context" - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" -) - -// BatchDeleteExecutions handles batch deletion of executions -// -// @Summary Batch delete executions -// @Description Batch delete executions by IDs or labels with cascading deletion of related records -// @Tags Executions -// @ID batch_delete_executions -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body dto.BatchDeleteExecutionReq true "Batch delete request" -// @Success 200 {object} dto.GenericResponse[any] "Executions deleted successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/executions/batch-delete [post] -func BatchDeleteExecutions(c *gin.Context) { - var req dto.BatchDeleteExecutionReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - var err error - if len(req.IDs) > 0 { - err = producer.BatchDeleteExecutionsByIDs(req.IDs) - } else { - err = producer.BatchDeleteExecutionsByLabels(req.Labels) - } - - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse[any](c, http.StatusNoContent, "Executions deleted successfully", nil) -} - -// GetExecution handles getting a single execution by ID -// -// @Summary Get execution by ID -// @Description Get detailed information about a specific execution -// @Tags Executions -// @ID get_execution_by_id -// @Produce json -// @Security BearerAuth -// @Param id path int true "Execution ID" -// @Success 200 {object} dto.GenericResponse[dto.ExecutionDetailResp] "Execution retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid execution ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Execution not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/executions/{id} [get] -// @x-api-type {"sdk":"true"} -func GetExecution(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, err := strconv.Atoi(idStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid execution ID") - return - } - - resp, err := producer.GetExecutionDetail(id) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// ListExecutions handles listing executions with pagination and filtering -// -// @Summary List executions -// @Description Get a paginated list of executions with pagination and filtering -// @Tags Executions -// @ID list_executions -// @Produce json -// @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param state query consts.ExecutionState false "Filter by execution state" -// @Param status query consts.StatusType false "Filter by status" -// @Param labels query []string false "Filter by labels (array of key:value strings, e.g., 'type:test')" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.ExecutionResp]] "Executions retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/executions [get] -// @x-api-type {"sdk":"true"} -func ListExecutions(c *gin.Context) { - var req dto.ListExecutionReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.ListExecutions(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// ListExecutionLabels handles listing available execution labels -// -// @Summary List execution labels -// @Description List all available label keys for executions -// @Tags Executions -// @ID list_execution_labels -// @Security BearerAuth -// @Produce json -// @Success 200 {object} dto.GenericResponse[[]dto.LabelItem] "Available label keys" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/executions/labels [get] -// @x-api-type {"sdk":"true"} -func ListAvaliableExecutionLabels(c *gin.Context) { - labels, err := producer.ListAvaliableExecutionLabels() - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, labels) -} - -// ManageExecutionCustomLabels manages execution custom labels (key-value pairs) -// -// @Summary Manage execution custom labels -// @Description Add or remove custom labels (key-value pairs) for an execution -// @Tags Executions -// @ID update_execution_labels -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path int true "Execution ID" -// @Param manage body dto.ManageExecutionLabelReq true "Custom label management request" -// @Success 200 {object} dto.GenericResponse[dto.ExecutionResp] "Custom labels managed successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid execution ID or request format/parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Execution not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/executions/{id}/labels [patch] -func ManageExecutionCustomLabels(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, err := strconv.Atoi(idStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid execution ID") - return - } - - var req dto.ManageExecutionLabelReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.ManageExecutionLabels(&req, id) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// SubmitAlgorithmExecution submits batch algorithm execution for multiple datapacks or datasets -// -// @Summary Submit batch algorithm execution -// @Description Submit multiple algorithm execution tasks in batch. Supports mixing datapack (v1 compatible) and dataset (v2 feature) executions. -// @Tags Executions -// @ID run_algorithm -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body dto.SubmitExecutionReq true "Algorithm execution request" -// @Success 200 {object} dto.GenericResponse[dto.SubmitExecutionResp] "Algorithm execution submitted successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Project, algorithm, datapack or dataset not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/executions/execute [post] -// @x-api-type {"sdk":"true"} -func SubmitAlgorithmExecution(c *gin.Context) { - groupID := c.GetString("groupID") - userID, exists := middleware.GetCurrentUserID(c) - if !exists { - dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") - return - } - - ctx, ok := c.Get(middleware.SpanContextKey) - if !ok { - logrus.Error("failed to get span context from gin.Context") - dto.ErrorResponse(c, http.StatusInternalServerError, "Internal server error") - return - } - - spanCtx := ctx.(context.Context) - span := trace.SpanFromContext(spanCtx) - - var req dto.SubmitExecutionReq - if err := c.ShouldBindJSON(&req); err != nil { - span.SetStatus(codes.Error, "validation error in SubmitAlgorithmExecution: "+err.Error()) - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - span.SetStatus(codes.Error, "validation error in SubmitAlgorithmExecution: "+err.Error()) - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.ProduceAlgorithmExeuctionTasks(spanCtx, &req, groupID, userID) - if err != nil { - span.SetStatus(codes.Error, "service error in SubmitAlgorithmExecution: "+err.Error()) - logrus.Errorf("Failed to submit algorithm execution: %v", err) - handlers.HandleServiceError(c, err) - return - } - - span.SetStatus(codes.Ok, "Successfully submitted algorithm execution") - dto.SuccessResponse(c, resp) -} - -// UploadDetectorResults uploads detector results -// -// @Summary Upload detector results -// @Description Upload detection results for detector algorithm via API instead of file collection -// @Tags Executions -// @ID upload_detection_results -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param execution_id path int true "Execution ID" -// @Param request body dto.UploadDetectorResultReq true "Detector results" -// @Success 200 {object} dto.GenericResponse[dto.UploadExecutionResultResp] "Results uploaded successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid executionID or invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Execution not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/executions/{execution_id}/detector_results [post] -// @x-api-type {"sdk":"true"} -func UploadDetectorResults(c *gin.Context) { - executionIDStr := c.Param(consts.URLPathExecutionID) - executionID, err := strconv.Atoi(executionIDStr) - if err != nil || executionID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid execution ID") - return - } - - var req dto.UploadDetectorResultReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.BatchCreateDetectorResults(&req, executionID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// UploadGranularityResults uploads granularity results -// -// @Summary Upload granularity results -// @Description Upload granularity results for regular algorithms via API instead of file collection -// @Tags Executions -// @ID upload_localization_results -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param execution_id path int true "Execution ID" -// @Param request body dto.UploadGranularityResultReq true "Granularity results" -// @Success 200 {object} dto.GenericResponse[dto.UploadExecutionResultResp] "Results uploaded successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid exeuction ID or invalid request form or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Execution not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/executions/{execution_id}/granularity_results [post] -// @x-api-type {"sdk":"true"} -func UploadGranularityResults(c *gin.Context) { - executionIDStr := c.Param(consts.URLPathExecutionID) - executionID, err := strconv.Atoi(executionIDStr) - if err != nil || executionID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid execution ID") - return - } - - var req dto.UploadGranularityResultReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.BatchCreateGranularityResults(&req, executionID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} diff --git a/src/handlers/v2/injections.go b/src/handlers/v2/injections.go deleted file mode 100644 index 532f2461..00000000 --- a/src/handlers/v2/injections.go +++ /dev/null @@ -1,1040 +0,0 @@ -package v2 - -import ( - "aegis/consts" - "aegis/utils" - "archive/zip" - "context" - "fmt" - "io" - "net/http" - "sort" - "strconv" - "strings" - - "aegis/dto" - "aegis/handlers" - "aegis/middleware" - producer "aegis/service/producer" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" - - chaos "github.com/OperationsPAI/chaos-experiment/handler" -) - -// BatchDeleteInjections -// -// @Summary Batch delete injections -// @Description Batch delete injections by IDs or labels or tags with cascading deletion of related records -// @Tags Injections -// @ID batch_delete_injections -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param batch_delete body dto.BatchDeleteInjectionReq true "Batch delete request" -// @Success 200 {object} dto.GenericResponse[any] "Injections deleted successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/batch-delete [post] -func BatchDeleteInjections(c *gin.Context) { - var req dto.BatchDeleteInjectionReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - var err error - if len(req.IDs) > 0 { - err = producer.BatchDeleteInjectionsByIDs(req.IDs) - } else { - err = producer.BatchDeleteInjectionsByLabels(req.Labels) - } - - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse[any](c, http.StatusNoContent, "Injections deleted successfully", nil) -} - -// GetInjection handles getting a single injection by ID -// -// @Summary Get injection by ID -// @Description Get detailed information about a specific injection -// @Tags Injections -// @ID get_injection_by_id -// @Produce json -// @Security BearerAuth -// @Param id path int true "Injection ID" -// @Success 200 {object} dto.GenericResponse[dto.InjectionDetailResp] "Injection retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid injection ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Injection not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/{id} [get] -// @x-api-type {"sdk":"true"} -func GetInjection(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, ok := handlers.ParsePositiveID(c, idStr, "injection ID") - if !ok { - logrus.WithField("idStr", idStr).Warn("GetInjection: invalid ID format or ID <= 0") - return - } - - resp, err := producer.GetInjectionDetail(id) - if err != nil { - logrus.WithFields(logrus.Fields{ - "id": id, - "error": err.Error(), - }).Error("GetInjection: failed to get injection detail") - } - - if handlers.HandleServiceError(c, err) { - return - } - - logrus.WithField("id", id).Info("GetInjection: successfully retrieved injection") - dto.SuccessResponse(c, resp) -} - -// GetInjectionMetadata -// -// @Summary Get Injection Metadata (DEPRECATED) -// @Description DEPRECATED: This endpoint exposes the legacy integer-indexed Node/InjectionConf metadata used by the old translate round-trip. The inject pipeline now accepts FriendlyFaultSpec/chaoscli.Spec directly and this endpoint is kept only for frontend backward compatibility. Get injection-related metadata including configuration, field mappings, and system resources -// @Deprecated -// @Tags Injections -// @ID get_injection_metadata -// @Produce json -// @Security BearerAuth -// @Param system query chaos.SystemType true "System for config and resources metadata" -// @Success 200 {object} dto.GenericResponse[dto.InjectionMetadataResp] "Successfully returned metadata" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid system" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Resource not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/metadata [get] -// @x-api-type {"sdk":"true"} -func GetInjectionMetadata(c *gin.Context) { - c.JSON(http.StatusGone, gin.H{"error": "endpoint removed; migrate to /inject with GuidedConfig"}) -} - -// ListInjections handles listing injections with pagination and filtering -// -// @Summary List injections -// @Description Get a paginated list of injections with pagination and filtering -// @Tags Injections -// @ID list_injections -// @Produce json -// @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param type query chaos.ChaosType false "Filter by fault type" -// @Param benchmark query string false "Filter by benchmark" -// @Param state query consts.DatapackState false "Filter by injection state" -// @Param status query int false "Filter by status" -// @Param labels query []string false "Filter by labels (array of key:value strings, e.g., 'type:chaos')" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.InjectionResp]] "Injections retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections [get] -// @x-api-type {"sdk":"true"} -func ListInjections(c *gin.Context) { - var req dto.ListInjectionReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.ListInjections(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// SearchInjections -// -// @Summary Search injections -// @Description Advanced search for injections with complex filtering including name search, custom labels, tags, and time ranges -// @Tags Injections -// @ID search_injections -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param search body dto.SearchInjectionReq true "Search criteria" -// @Success 200 {object} dto.GenericResponse[dto.SearchResp[dto.InjectionDetailResp]] "Search results" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/search [post] -// @x-api-type {"sdk":"true"} -func SearchInjections(c *gin.Context) { - searchInjectionsCommon(c, nil) -} - -// ListFaultInjectionNoIssues -// -// @Summary Query Fault Injection Records Without Issues -// @Description Query all fault injection records without issues based on time range, returning detailed records including configuration information -// @Tags Injections -// @ID list_failed_injections -// @Produce json -// @Param labels query []string false "Filter by labels (array of key:value strings, e.g., 'type:chaos')" -// @Param lookback query string false "Time range query, supports custom relative time (1h/24h/7d) or custom, default not set" -// @Param custom_start_time query string false "Custom start time, RFC3339 format, required when lookback=custom" Format(date-time) -// @Param custom_end_time query string false "Custom end time, RFC3339 format, required when lookback=custom" Format(date-time) -// @Success 200 {object} dto.GenericResponse[[]dto.InjectionNoIssuesResp] "Successfully returned fault injection records without issues" -// @Failure 400 {object} dto.GenericResponse[any] "Request parameter error, such as incorrect time format or parameter validation failure, etc." -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/analysis/no-issues [get] -// @x-api-type {"sdk":"true"} -func ListFaultInjectionNoIssues(c *gin.Context) { - listFaultInjectionNoIssuesCommon(c, nil) -} - -// ListFaultInjectionWithIssues -// -// @Summary Query Fault Injection Records With Issues -// @Description Query all fault injection records with issues based on time range -// @Tags Injections -// @ID list_successful_injections -// @Produce json -// @Param labels query []string false "Filter by labels (array of key:value strings, e.g., 'type:chaos')" -// @Param lookback query string false "Time range query, supports custom relative time (1h/24h/7d) or custom, default not set" -// @Param custom_start_time query string false "Custom start time, RFC3339 format, required when lookback=custom" Format(date-time) -// @Param custom_end_time query string false "Custom end time, RFC3339 format, required when lookback=custom" Format(date-time) -// @Success 200 {object} dto.GenericResponse[[]dto.InjectionWithIssuesResp] -// @Failure 400 {object} dto.GenericResponse[any] "Request parameter error, such as incorrect time format or parameter validation failure, etc." -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/analysis/with-issues [get] -// @x-api-type {"sdk":"true"} -func ListFaultInjectionWithIssues(c *gin.Context) { - listFaultInjectionWithIssuesCommon(c, nil) -} - -// ManageInjectionCustomLabels manages injection custom labels (key-value pairs) -// -// @Summary Manage injection custom labels -// @Description Add or remove custom labels (key-value pairs) for an injection -// @Tags Injections -// @ID manage_injection_labels -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path int true "Injection ID" -// @Param manage body dto.ManageInjectionLabelReq true "Custom label management request" -// @Success 200 {object} dto.GenericResponse[dto.InjectionResp] "Custom labels managed successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid injection ID or request format/parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Injection not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/{id}/labels [patch] -// @x-api-type {"sdk":"true"} -func ManageInjectionCustomLabels(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, ok := handlers.ParsePositiveID(c, idStr, "injection ID") - if !ok { - return - } - - var req dto.ManageInjectionLabelReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.ManageInjectionLabels(&req, id) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// BatchManageInjectionLabels -// -// @Summary Batch manage injection labels -// @Description Add or remove labels from multiple injections by IDs with success/failure tracking -// @Tags Injections -// @ID batch_manage_injection_labels -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param batch_manage body dto.BatchManageInjectionLabelReq true "Batch manage label request" -// @Success 200 {object} dto.GenericResponse[dto.BatchManageInjectionLabelResp] "Injection labels managed successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/labels/batch [patch] -// @x-api-type {"sdk":"true"} -func BatchManageInjectionLabels(c *gin.Context) { - var req dto.BatchManageInjectionLabelReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.BatchManageInjectionLabels(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// SubmitFaultInjection submits batch fault injections -// -// @Summary Submit batch fault injections -// @Description Submit multiple fault injection tasks in batch -// @Tags Injections -// @ID inject_fault -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param body body dto.SubmitInjectionReq true "Fault injection request body" -// @Success 200 {object} dto.GenericResponse[dto.SubmitInjectionResp] "Fault injection submitted successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Resource not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/inject [post] -// @x-api-type {"sdk":"true"} -func SubmitFaultInjection(c *gin.Context) { - submitFaultInjectionCommon(c, nil) -} - -// SubmitDatapackBuilding submits batch datapack buildings -// -// @Summary Submit batch datapack buildings -// @Description. Submit multiple datapack building tasks in batch -// @Tags Injections -// @ID build_datapack -// @Accept json -// @Produce json -// @Param body body dto.SubmitDatapackBuildingReq true "Datapack building request body" -// @Success 202 {object} dto.GenericResponse[dto.SubmitDatapackBuildingResp] "Datapack building submitted successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Resource not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/build [post] -// @x-api-type {"sdk":"true"} -func SubmitDatapackBuilding(c *gin.Context) { - submitDatapackBuildingCommon(c, nil) -} - -// CloneInjection handles cloning an injection configuration -// -// @Summary Clone injection -// @Description Clone an existing injection configuration for reuse -// @Tags Injections -// @ID clone_injection -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path int true "Injection ID" -// @Param body body dto.CloneInjectionReq true "Clone request" -// @Success 201 {object} dto.GenericResponse[dto.InjectionDetailResp] "Injection cloned successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 404 {object} dto.GenericResponse[any] "Injection not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/{id}/clone [post] -// @x-api-type {"sdk":"true"} -func CloneInjection(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, ok := handlers.ParsePositiveID(c, idStr, "injection ID") - if !ok { - return - } - - var req dto.CloneInjectionReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request: "+err.Error()) - return - } - - resp, err := producer.CloneInjection(id, &req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusCreated, "Injection cloned successfully", resp) -} - -// GetInjectionLogs handles getting injection execution logs -// -// @Summary Get injection logs -// @Description Get execution logs for a specific injection -// @Tags Injections -// @ID get_injection_logs -// @Produce json -// @Security BearerAuth -// @Param id path int true "Injection ID" -// @Success 200 {object} dto.GenericResponse[dto.InjectionLogsResp] "Logs retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid injection ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 404 {object} dto.GenericResponse[any] "Injection not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/{id}/logs [get] -// @x-api-type {"sdk":"true"} -func GetInjectionLogs(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, ok := handlers.ParsePositiveID(c, idStr, "injection ID") - if !ok { - return - } - - resp, err := producer.GetInjectionLogs(id) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusOK, "Logs retrieved successfully", resp) -} - -// DownloadDatapack handles datapack file download -// -// @Summary Download datapack -// @Description Download datapack file by injection ID -// @Tags Injections -// @ID download_datapack -// @Produce application/zip -// @Security BearerAuth -// @Param id path int true "Injection ID" -// @Success 200 {file} binary "Datapack zip file" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid injection ID" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Injection not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/{id}/download [get] -// @x-api-type {"sdk":"true"} -func DownloadDatapack(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, ok := handlers.ParsePositiveID(c, idStr, "injection ID") - if !ok { - return - } - - filename, err := producer.GetDatapackFilename(id) - if handlers.HandleServiceError(c, err) { - return - } - - c.Header("Content-Type", "application/zip") - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip", filename)) - - zipWriter := zip.NewWriter(c.Writer) - defer func() { _ = zipWriter.Close() }() - - if err := producer.DownloadDatapack(zipWriter, []utils.ExculdeRule{}, id); err != nil { - delete(c.Writer.Header(), "Content-Disposition") - c.Header("Content-Type", "application/json; charset=utf-8") - handlers.HandleServiceError(c, err) - } -} - -// ListDatapackFiles handles getting the file structure of an injection datapack -// -// @Summary List datapack files -// @Description Get the file structure of an injection datapack -// @Tags Injections -// @ID list_datapack_files -// @Produce json -// @Security BearerAuth -// @Param id path int true "Injection ID" -// @Success 200 {object} dto.GenericResponse[dto.DatapackFilesResp] "Files retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid injection ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 404 {object} dto.GenericResponse[any] "Datapack not found or not ready" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/{id}/files [get] -func ListDatapackFiles(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, ok := handlers.ParsePositiveID(c, idStr, "datapack ID") - if !ok { - return - } - - // Get base URL from request - scheme := "http" - if c.Request.TLS != nil { - scheme = "https" - } - baseURL := fmt.Sprintf("%s://%s", scheme, c.Request.Host) - - resp, err := producer.GetDatapackFiles(id, baseURL) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// DownloadDatapackFile handles downloading a specific file from a datapack. -// Supports HTTP Range requests for resumable downloads. -// -// @Summary Download datapack file -// @Description Download a specific file from a datapack. Supports Range requests for resumable download. -// @Tags Injections -// @ID download_datapack_file -// @Produce application/octet-stream -// @Security BearerAuth -// @Param id path int true "Injection ID" -// @Param path query string true "Relative path to the file" -// @Success 200 {file} binary "Complete file content" -// @Success 206 {file} binary "Partial file content (Range request)" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid injection ID or file path" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Datapack or file not found" -// @Failure 416 {object} dto.GenericResponse[any] "Range not satisfiable" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/{id}/files/download [get] -func DownloadDatapackFile(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, ok := handlers.ParsePositiveID(c, idStr, "datapack ID") - if !ok { - return - } - - filePath := c.Query("path") - if filePath == "" { - dto.ErrorResponse(c, http.StatusBadRequest, "File path is required") - return - } - - fileName, contentType, fileSize, fileReader, err := producer.DownloadDatapackFile(id, filePath) - if handlers.HandleServiceError(c, err) { - return - } - defer func() { _ = fileReader.Close() }() - - c.Header("Content-Type", contentType) - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) - c.Header("Cache-Control", "no-cache, no-store, must-revalidate") - c.Header("Accept-Ranges", "bytes") - - // Handle Range request for resumable download - rangeHeader := c.GetHeader("Range") - if rangeHeader != "" { - serveRangeRequest(c, fileReader, fileSize, rangeHeader) - return - } - - // Full file response - c.Header("Content-Length", strconv.FormatInt(fileSize, 10)) - c.Status(http.StatusOK) - - if _, err := io.Copy(c.Writer, fileReader); err != nil { - logrus.WithError(err).Error("failed to stream file content") - return - } -} - -// QueryDatapackFile handles querying the content of a specific file in the datapack. -// Returns the complete file with Content-Length for download progress tracking. -// -// NOTE: Arrow IPC is a structured stream that must be read sequentially from the -// beginning — Range requests are intentionally NOT supported here. Use -// DownloadDatapackFile for resumable downloads of raw files. -// -// @Summary Query datapack file content -// @Description Query the content of a parquet file in the datapack, returned as a complete stream. Content-Length header is provided for progress tracking. -// @Tags Injections -// @ID query_datapack_file -// @Produce application/vnd.apache.arrow.stream -// @Security BearerAuth -// @Param id path int true "Injection ID" -// @Param path query string true "Relative path to the file" -// @Success 200 {file} binary "Complete Arrow IPC stream" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid injection ID or file path" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Datapack or file not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/{id}/files/query [get] -func QueryDatapackFile(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, ok := handlers.ParsePositiveID(c, idStr, "datapack ID") - if !ok { - return - } - - filePath := c.Query("path") - if filePath == "" { - dto.ErrorResponse(c, http.StatusBadRequest, "File path is required") - return - } - - ctx := c.Request.Context() - - fileName, totalRows, reader, err := producer.QueryDatapackFileContent(ctx, id, filePath) - if err != nil { - if handlers.HandleServiceError(c, err) { - return - } - } - defer func() { _ = reader.Close() }() - - // Content-Length enables axios onDownloadProgress to calculate percentage - c.Header("Content-Type", "application/vnd.apache.arrow.stream") - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s.arrow", fileName)) - c.Header("Cache-Control", "no-cache, no-store, must-revalidate") - c.Header("X-Total-Rows", strconv.FormatInt(totalRows, 10)) - c.Header("X-Accel-Buffering", "no") - c.Status(http.StatusOK) - - if _, err := io.Copy(c.Writer, reader); err != nil { - logrus.Errorf("failed to stream file content: %v", err) - return - } -} - -// GetSystemMapping returns a mapping of system type names to integer indices. -// -// @Summary Get system type mapping -// @Description Returns all registered system types with their integer indices, sorted alphabetically -// @Tags Injections -// @ID get_system_mapping -// @Produce json -// @Security BearerAuth -// @Success 200 {object} dto.GenericResponse[dto.SystemMappingResp] "System mapping retrieved successfully" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/systems [get] -func GetSystemMapping(c *gin.Context) { - allSystems := chaos.GetAllSystemTypes() - systemMap := utils.BuildSystemIndexMap(allSystems) - - // Build sorted details list - details := make([]dto.SystemDetail, 0, len(systemMap)) - for name, idx := range systemMap { - details = append(details, dto.SystemDetail{Name: name, Index: idx}) - } - sort.Slice(details, func(i, j int) bool { - return details[i].Index < details[j].Index - }) - - dto.SuccessResponse(c, &dto.SystemMappingResp{ - Systems: systemMap, - SystemDetails: details, - }) -} - -// TranslateFaultSpecs translates human-readable fault specs into chaos.Node trees. -// -// @Summary Translate fault specs to Nodes (DEPRECATED) -// @Description DEPRECATED: The inject pipeline (POST /api/v2/projects/{project_id}/injections/inject) now auto-detects FriendlyFaultSpec/chaoscli.Spec entries server-side via FriendlySpecToNode — CLI callers should submit FaultSpec YAML directly instead of round-tripping through /translate. Kept live for frontend backward compatibility. Converts human-readable fault specifications (type names, durations, etc.) into the integer-indexed Node AST used by the injection engine -// @Deprecated -// @Tags Injections -// @ID translate_fault_specs -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param body body dto.TranslateFaultSpecsReq true "Fault specs to translate" -// @Success 200 {object} dto.GenericResponse[dto.TranslateFaultSpecsResp] "Translation successful" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/translate [post] -func TranslateFaultSpecs(c *gin.Context) { - c.JSON(http.StatusGone, gin.H{"error": "endpoint removed; migrate to /inject with GuidedConfig"}) -} - -// ===================== Private Helper Functions ===================== - -// serveRangeRequest handles HTTP Range requests for partial content delivery. -// Supports single range requests in the format "bytes=start-end". -func serveRangeRequest(c *gin.Context, reader io.ReadSeeker, fileSize int64, rangeHeader string) { - // Parse "bytes=start-end" format - const prefix = "bytes=" - if !strings.HasPrefix(rangeHeader, prefix) { - dto.ErrorResponse(c, http.StatusRequestedRangeNotSatisfiable, "Invalid range format") - return - } - - rangeSpec := strings.TrimPrefix(rangeHeader, prefix) - // Only support single range (no multi-range) - if strings.Contains(rangeSpec, ",") { - dto.ErrorResponse(c, http.StatusRequestedRangeNotSatisfiable, "Multi-range not supported") - return - } - - parts := strings.SplitN(rangeSpec, "-", 2) - if len(parts) != 2 { - dto.ErrorResponse(c, http.StatusRequestedRangeNotSatisfiable, "Invalid range format") - return - } - - var start, end int64 - var err error - - if parts[0] == "" { - // Suffix range: "bytes=-500" means last 500 bytes - suffix, err := strconv.ParseInt(parts[1], 10, 64) - if err != nil || suffix <= 0 || suffix > fileSize { - c.Header("Content-Range", fmt.Sprintf("bytes */%d", fileSize)) - dto.ErrorResponse(c, http.StatusRequestedRangeNotSatisfiable, "Invalid range") - return - } - start = fileSize - suffix - end = fileSize - 1 - } else { - start, err = strconv.ParseInt(parts[0], 10, 64) - if err != nil || start < 0 || start >= fileSize { - c.Header("Content-Range", fmt.Sprintf("bytes */%d", fileSize)) - dto.ErrorResponse(c, http.StatusRequestedRangeNotSatisfiable, "Invalid range start") - return - } - - if parts[1] == "" { - // Open-ended range: "bytes=100-" means from 100 to end - end = fileSize - 1 - } else { - end, err = strconv.ParseInt(parts[1], 10, 64) - if err != nil || end < start || end >= fileSize { - c.Header("Content-Range", fmt.Sprintf("bytes */%d", fileSize)) - dto.ErrorResponse(c, http.StatusRequestedRangeNotSatisfiable, "Invalid range end") - return - } - } - } - - contentLength := end - start + 1 - - // Seek to start position - if _, err := reader.Seek(start, io.SeekStart); err != nil { - logrus.Errorf("failed to seek to range start: %v", err) - dto.ErrorResponse(c, http.StatusInternalServerError, "Failed to seek to range start") - return - } - - c.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, fileSize)) - c.Header("Content-Length", strconv.FormatInt(contentLength, 10)) - c.Status(http.StatusPartialContent) - - if _, err := io.CopyN(c.Writer, reader, contentLength); err != nil { - logrus.Errorf("failed to stream partial content: %v", err) - return - } -} - -// searchInjectionsCommon is the common logic for searching injections -func searchInjectionsCommon(c *gin.Context, projectID *int) { - var req dto.SearchInjectionReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - // Note: Project filtering should be handled at the service layer - // For project-scoped calls, the service layer will filter by project - resp, err := producer.SearchInjections(&req, projectID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// listFaultInjectionNoIssuesCommon is the common logic for listing injections without issues -func listFaultInjectionNoIssuesCommon(c *gin.Context, projectID *int) { - var req dto.ListInjectionNoIssuesReq - if err := c.BindQuery(&req); err != nil { - logrus.Errorf("failed to bind query parameters: %v", err) - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid query parameters") - return - } - - if err := req.Validate(); err != nil { - logrus.Errorf("invalid query parameters: %v", err) - dto.ErrorResponse(c, http.StatusBadRequest, err.Error()) - return - } - - // Note: Project filtering should be handled at the service layer - // For project-scoped calls, the service layer will filter by project - - items, err := producer.ListInjectionsNoIssues(&req, projectID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, items) -} - -// listFaultInjectionWithIssuesCommon is the common logic for listing injections with issues -func listFaultInjectionWithIssuesCommon(c *gin.Context, projectID *int) { - var req dto.ListInjectionWithIssuesReq - if err := c.BindQuery(&req); err != nil { - logrus.Errorf("failed to bind query parameters: %v", err) - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid query parameters") - return - } - - if err := req.Validate(); err != nil { - logrus.Errorf("invalid query parameters: %v", err) - dto.ErrorResponse(c, http.StatusBadRequest, err.Error()) - return - } - - // Note: Project filtering should be handled at the service layer - // For project-scoped calls, the service layer will filter by project - - items, err := producer.ListInjectionsWithIssues(&req, projectID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, items) -} - -// submitFaultInjectionCommon is the common logic for submitting fault injections -func submitFaultInjectionCommon(c *gin.Context, projectID *int) { - groupID := c.GetString("groupID") - userID, exists := middleware.GetCurrentUserID(c) - if !exists { - dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") - return - } - - ctx, ok := c.Get(middleware.SpanContextKey) - if !ok { - logrus.Error("Failed to get span context from gin.Context in SubmitFaultInjection") - dto.ErrorResponse(c, http.StatusInternalServerError, "Internal server error") - return - } - - spanCtx := ctx.(context.Context) - span := trace.SpanFromContext(spanCtx) - - var req dto.SubmitInjectionReq - if err := c.BindJSON(&req); err != nil { - span.SetStatus(codes.Error, "validation error in SubmitFaultInjection: "+err.Error()) - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - span.SetStatus(codes.Error, "validation error in SubmitFaultInjection: "+err.Error()) - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - // Resolve specs: auto-detect friendly YAML format vs chaos.Node DSL and convert all to chaos.Node - if err := req.ResolveSpecs(producer.FriendlySpecToNode); err != nil { - span.SetStatus(codes.Error, "spec conversion error in SubmitFaultInjection: "+err.Error()) - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid fault spec: "+err.Error()) - return - } - - if req.ProjectName == "" && projectID == nil { - span.SetStatus(codes.Error, "validation error in SubmitFaultInjection: project name is required") - dto.ErrorResponse(c, http.StatusBadRequest, "Project name or ID is required") - return - } - - resp, err := producer.ProduceRestartPedestalTasks(spanCtx, &req, groupID, userID, projectID) - if err != nil { - span.SetStatus(codes.Error, "service error in SubmitFaultInjection: "+err.Error()) - logrus.Errorf("Failed to submit fault injection: %v", err) - handlers.HandleServiceError(c, err) - return - } - - span.SetStatus(codes.Ok, fmt.Sprintf("Successfully submitted %d fault injections with groupID: %s", len(resp.Items), groupID)) - dto.SuccessResponse(c, resp) -} - -// submitDatapackBuildingCommon is the common logic for submitting datapack buildings -func submitDatapackBuildingCommon(c *gin.Context, projectID *int) { - groupID := c.GetString("groupID") - userID, exists := middleware.GetCurrentUserID(c) - if !exists { - dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") - return - } - - ctx, ok := c.Get(middleware.SpanContextKey) - if !ok { - logrus.Error("Failed to get span context from gin.Context in SubmitDatapackBuilding") - dto.ErrorResponse(c, http.StatusInternalServerError, "Internal server error") - return - } - - spanCtx := ctx.(context.Context) - span := trace.SpanFromContext(spanCtx) - - var req dto.SubmitDatapackBuildingReq - if err := c.BindJSON(&req); err != nil { - span.SetStatus(codes.Error, "validation error in SubmitDatapackBuilding: "+err.Error()) - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - span.SetStatus(codes.Error, "validation error in SubmitDatapackBuilding: "+err.Error()) - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - if req.ProjectName == "" && projectID == nil { - span.SetStatus(codes.Error, "validation error in SubmitFaultInjection: project name is required") - dto.ErrorResponse(c, http.StatusBadRequest, "Project name or ID is required") - return - } - - resp, err := producer.ProduceDatapackBuildingTasks(spanCtx, &req, groupID, userID, projectID) - if err != nil { - span.SetStatus(codes.Error, "service error in SubmitDatapackBuilding: "+err.Error()) - logrus.Errorf("Failed to submit datapack building: %v", err) - handlers.HandleServiceError(c, err) - return - } - - span.SetStatus(codes.Ok, fmt.Sprintf("Successfully submitted %d datapack buildings with groupID: %s", len(resp.Items), groupID)) - dto.SuccessResponse(c, resp) -} - -// UploadDatapack handles manual datapack upload -// -// @Summary Upload a manual datapack -// @Description Upload a zip archive as a manual datapack data source -// @Tags Injections -// @ID upload_datapack -// @Accept multipart/form-data -// @Produce json -// @Security BearerAuth -// @Param name formData string true "Datapack name" -// @Param description formData string false "Description" -// @Param category formData string false "Category" -// @Param labels formData string false "JSON-encoded labels" -// @Param ground_truths formData string false "JSON-encoded ground truths" -// @Param file formData file true "Zip archive file" -// @Success 201 {object} dto.GenericResponse[dto.UploadDatapackResp] "Datapack uploaded successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/injections/upload [post] -func UploadDatapack(c *gin.Context) { - var req dto.UploadDatapackReq - if err := c.ShouldBind(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - file, header, err := c.Request.FormFile("file") - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "File is required: "+err.Error()) - return - } - defer func() { _ = file.Close() }() - - // Validate .zip extension - if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") { - dto.ErrorResponse(c, http.StatusBadRequest, "Only .zip files are accepted") - return - } - - // Max 2GB - const maxSize = 2 << 30 // 2GB - if header.Size > maxSize { - dto.ErrorResponse(c, http.StatusBadRequest, "File size exceeds maximum allowed size of 2GB") - return - } - - resp, err := producer.UploadDatapack(&req, file, header.Size) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusCreated, "Datapack uploaded successfully", resp) -} - -// UpdateGroundtruth handles updating ground truth for a datapack -// -// @Summary Update datapack ground truth -// @Description Update or set ground truth labels for a datapack (fault injection) -// @Tags Injections -// @ID update_groundtruth -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path int true "Injection ID" -// @Param request body dto.UpdateGroundtruthReq true "Ground truth data" -// @Success 200 {object} dto.GenericResponse[any] "Ground truth updated" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 404 {object} dto.GenericResponse[any] "Injection not found" -// @Router /api/v2/injections/{id}/groundtruth [put] -func UpdateGroundtruth(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, ok := handlers.ParsePositiveID(c, idStr, "injection ID") - if !ok { - return - } - - var req dto.UpdateGroundtruthReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - err := producer.UpdateGroundtruth(id, &req) - if handlers.HandleServiceError(c, err) { - return - } - - logrus.WithField("id", id).Info("UpdateGroundtruth: successfully updated ground truth") - dto.JSONResponse[any](c, http.StatusOK, "Ground truth updated", nil) -} diff --git a/src/handlers/v2/metrics.go b/src/handlers/v2/metrics.go deleted file mode 100644 index d05dc0b1..00000000 --- a/src/handlers/v2/metrics.go +++ /dev/null @@ -1,106 +0,0 @@ -package v2 - -import ( - "aegis/dto" - "aegis/handlers" - producer "aegis/service/producer" - "net/http" - - "github.com/gin-gonic/gin" -) - -// GetInjectionMetrics handles retrieval of injection metrics -// -// @Summary Get injection metrics -// @Description Get aggregated metrics for injections including success rate, duration stats, and state distribution -// @Tags Metrics -// @ID get_injection_metrics -// @Produce json -// @Security BearerAuth -// @Param start_time query string false "Start time (RFC3339)" -// @Param end_time query string false "End time (RFC3339)" -// @Param fault_type query string false "Filter by fault type" -// @Success 200 {object} dto.GenericResponse[dto.InjectionMetrics] "Injection metrics" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/metrics/injections [get] -// @x-api-type {"sdk":"true"} -func GetInjectionMetrics(c *gin.Context) { - var req dto.GetMetricsReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request: "+err.Error()) - return - } - - metrics, err := producer.GetInjectionMetrics(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusOK, "Injection metrics retrieved successfully", metrics) -} - -// GetExecutionMetrics handles retrieval of execution metrics -// -// @Summary Get execution metrics -// @Description Get aggregated metrics for algorithm executions including performance stats and state distribution -// @Tags Metrics -// @ID get_execution_metrics -// @Produce json -// @Security BearerAuth -// @Param start_time query string false "Start time (RFC3339)" -// @Param end_time query string false "End time (RFC3339)" -// @Param algorithm_id query int false "Filter by algorithm ID" -// @Success 200 {object} dto.GenericResponse[dto.ExecutionMetrics] "Execution metrics" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/metrics/executions [get] -// @x-api-type {"sdk":"true"} -func GetExecutionMetrics(c *gin.Context) { - var req dto.GetMetricsReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request: "+err.Error()) - return - } - - metrics, err := producer.GetExecutionMetrics(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusOK, "Execution metrics retrieved successfully", metrics) -} - -// GetAlgorithmMetrics handles retrieval of algorithm comparison metrics -// -// @Summary Get algorithm comparison metrics -// @Description Get comparative metrics across different algorithms for performance analysis -// @Tags Metrics -// @ID get_algorithm_metrics -// @Produce json -// @Security BearerAuth -// @Param algorithm_ids query string false "Comma-separated algorithm IDs" -// @Param start_time query string false "Start time (RFC3339)" -// @Param end_time query string false "End time (RFC3339)" -// @Success 200 {object} dto.GenericResponse[dto.AlgorithmMetrics] "Algorithm metrics" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/metrics/algorithms [get] -// @x-api-type {"sdk":"true"} -func GetAlgorithmMetrics(c *gin.Context) { - var req dto.GetMetricsReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request: "+err.Error()) - return - } - - metrics, err := producer.GetAlgorithmMetrics(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusOK, "Algorithm metrics retrieved successfully", metrics) -} diff --git a/src/handlers/v2/pedestal_helm.go b/src/handlers/v2/pedestal_helm.go deleted file mode 100644 index 775ce24e..00000000 --- a/src/handlers/v2/pedestal_helm.go +++ /dev/null @@ -1,198 +0,0 @@ -package v2 - -import ( - "errors" - "net/http" - "strconv" - - "aegis/database" - "aegis/dto" - "aegis/handlers/v2/pedestalhelm" - "aegis/middleware" - "aegis/repository" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -// defaultHelmRunner is overridable from tests / future server wiring. -var defaultHelmRunner pedestalhelm.Runner = pedestalhelm.RealRunner{} - -// GetPedestalHelmConfig returns the helm_configs row for a given container_version_id. -// -// @Summary Get pedestal helm config -// @Description Retrieve the helm chart configuration bound to a pedestal container version. -// @Tags Pedestal -// @ID get_pedestal_helm_config -// @Produce json -// @Security BearerAuth -// @Param container_version_id path int true "Container version ID" -// @Success 200 {object} dto.GenericResponse[dto.PedestalHelmConfigResp] -// @Failure 400 {object} dto.GenericResponse[any] -// @Failure 401 {object} dto.GenericResponse[any] -// @Failure 404 {object} dto.GenericResponse[any] -// @Router /api/v2/pedestal/helm/{container_version_id} [get] -// @x-api-type {"sdk":"true"} -func GetPedestalHelmConfig(c *gin.Context) { - if _, ok := middleware.GetCurrentUserID(c); !ok { - dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") - return - } - versionID, ok := parsePedestalVersionID(c) - if !ok { - return - } - - cfg, err := repository.GetHelmConfigByContainerVersionID(database.DB, versionID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - dto.ErrorResponse(c, http.StatusNotFound, "helm config not found for container_version_id") - return - } - dto.ErrorResponse(c, http.StatusInternalServerError, "failed to load helm config: "+err.Error()) - return - } - - dto.SuccessResponse(c, toHelmConfigResp(cfg)) -} - -// UpsertPedestalHelmConfig creates or updates the helm_configs row for the -// given container version. Requires container-version upload permission -// (same tier as POST .../helm-chart). -// -// @Summary Upsert pedestal helm config -// @Description Create or update the helm_configs row for a pedestal container version. Admin-only. -// @Tags Pedestal -// @ID upsert_pedestal_helm_config -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param container_version_id path int true "Container version ID" -// @Param request body dto.UpsertPedestalHelmConfigReq true "Helm config fields" -// @Success 200 {object} dto.GenericResponse[dto.PedestalHelmConfigResp] -// @Router /api/v2/pedestal/helm/{container_version_id} [put] -// @x-api-type {"sdk":"true"} -func UpsertPedestalHelmConfig(c *gin.Context) { - if _, ok := middleware.GetCurrentUserID(c); !ok { - dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") - return - } - versionID, ok := parsePedestalVersionID(c) - if !ok { - return - } - - var req dto.UpsertPedestalHelmConfigReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - - existing, err := repository.GetHelmConfigByContainerVersionID(database.DB, versionID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - dto.ErrorResponse(c, http.StatusInternalServerError, "failed to query existing helm config: "+err.Error()) - return - } - - if existing != nil && existing.ID != 0 { - existing.ChartName = req.ChartName - existing.Version = req.Version - existing.RepoURL = req.RepoURL - existing.RepoName = req.RepoName - existing.ValueFile = req.ValueFile - existing.LocalPath = req.LocalPath - if err := repository.UpdateHelmConfig(database.DB, existing); err != nil { - dto.ErrorResponse(c, http.StatusInternalServerError, "failed to update helm config: "+err.Error()) - return - } - dto.SuccessResponse(c, toHelmConfigResp(existing)) - return - } - - created := &database.HelmConfig{ - ContainerVersionID: versionID, - ChartName: req.ChartName, - Version: req.Version, - RepoURL: req.RepoURL, - RepoName: req.RepoName, - ValueFile: req.ValueFile, - LocalPath: req.LocalPath, - } - if err := repository.BatchCreateHelmConfigs(database.DB, []*database.HelmConfig{created}); err != nil { - dto.ErrorResponse(c, http.StatusInternalServerError, "failed to create helm config: "+err.Error()) - return - } - dto.SuccessResponse(c, toHelmConfigResp(created)) -} - -// VerifyPedestalHelmConfig dry-runs helm repo add + helm pull + value-file -// parse. It never triggers a real restart_pedestal task. -// -// @Summary Verify pedestal helm config -// @Description Dry-run helm repo add + pull and parse the values file without starting a task. -// @Tags Pedestal -// @ID verify_pedestal_helm_config -// @Produce json -// @Security BearerAuth -// @Param container_version_id path int true "Container version ID" -// @Success 200 {object} dto.GenericResponse[dto.PedestalHelmVerifyResp] -// @Router /api/v2/pedestal/helm/{container_version_id}/verify [post] -// @x-api-type {"sdk":"true"} -func VerifyPedestalHelmConfig(c *gin.Context) { - if _, ok := middleware.GetCurrentUserID(c); !ok { - dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") - return - } - versionID, ok := parsePedestalVersionID(c) - if !ok { - return - } - - cfg, err := repository.GetHelmConfigByContainerVersionID(database.DB, versionID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - dto.ErrorResponse(c, http.StatusNotFound, "helm config not found for container_version_id") - return - } - dto.ErrorResponse(c, http.StatusInternalServerError, "failed to load helm config: "+err.Error()) - return - } - - result := pedestalhelm.Run(defaultHelmRunner, pedestalhelm.Config{ - ChartName: cfg.ChartName, - Version: cfg.Version, - RepoURL: cfg.RepoURL, - RepoName: cfg.RepoName, - ValueFile: cfg.ValueFile, - }, pedestalhelm.VerifyValueFile) - - resp := dto.PedestalHelmVerifyResp{OK: result.OK, Checks: make([]dto.PedestalHelmVerifyCheck, len(result.Checks))} - for i, chk := range result.Checks { - resp.Checks[i] = dto.PedestalHelmVerifyCheck{Name: chk.Name, OK: chk.OK, Detail: chk.Detail} - } - dto.SuccessResponse(c, resp) -} - -func parsePedestalVersionID(c *gin.Context) (int, bool) { - raw := c.Param("container_version_id") - id, err := strconv.Atoi(raw) - if err != nil || id <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "invalid container_version_id: "+raw) - return 0, false - } - return id, true -} - -func toHelmConfigResp(cfg *database.HelmConfig) dto.PedestalHelmConfigResp { - return dto.PedestalHelmConfigResp{ - ID: cfg.ID, - ContainerVersionID: cfg.ContainerVersionID, - ChartName: cfg.ChartName, - Version: cfg.Version, - RepoURL: cfg.RepoURL, - RepoName: cfg.RepoName, - ValueFile: cfg.ValueFile, - LocalPath: cfg.LocalPath, - Checksum: cfg.Checksum, - } -} diff --git a/src/handlers/v2/pedestalhelm/verify_test.go b/src/handlers/v2/pedestalhelm/verify_test.go deleted file mode 100644 index fcba04b9..00000000 --- a/src/handlers/v2/pedestalhelm/verify_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package pedestalhelm - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" -) - -type fakeRunner struct { - addErr error - addOut string - updateErr error - updateOut string - pullErr error - pullOut string - - addCalled bool - updateCalled bool - pullCalled bool -} - -func (f *fakeRunner) RepoAdd(name, url string) (string, error) { - f.addCalled = true - return f.addOut, f.addErr -} - -func (f *fakeRunner) RepoUpdate() (string, error) { - f.updateCalled = true - return f.updateOut, f.updateErr -} - -func (f *fakeRunner) Pull(repo, chart, version, dest string) (string, error) { - f.pullCalled = true - return f.pullOut, f.pullErr -} - -func writeTempYAML(t *testing.T, content string) string { - t.Helper() - dir := t.TempDir() - p := filepath.Join(dir, "values.yaml") - if err := os.WriteFile(p, []byte(content), 0o644); err != nil { - t.Fatalf("write temp yaml: %v", err) - } - return p -} - -func TestRun_AllGreen(t *testing.T) { - cfg := Config{ - ChartName: "pedestal", - Version: "1.2.3", - RepoURL: "https://example.com/charts", - RepoName: "aegis", - ValueFile: writeTempYAML(t, "image:\n repository: nginx\n tag: \"1.25\"\n"), - } - r := &fakeRunner{} - got := Run(r, cfg, VerifyValueFile) - if !got.OK { - t.Fatalf("expected OK=true, got %+v", got) - } - if !r.addCalled || !r.updateCalled || !r.pullCalled { - t.Fatalf("expected all helm calls, got %+v", r) - } - want := []string{"repo_add", "repo_update", "helm_pull", "value_file"} - if len(got.Checks) != len(want) { - t.Fatalf("expected %v checks, got %+v", want, got.Checks) - } - for i, n := range want { - if got.Checks[i].Name != n { - t.Fatalf("check[%d]=%q want %q", i, got.Checks[i].Name, n) - } - } -} - -func TestRun_RepoAddFailsShortCircuits(t *testing.T) { - cfg := Config{RepoName: "aegis", RepoURL: "https://bad"} - r := &fakeRunner{addErr: errors.New("boom"), addOut: "stderr text"} - got := Run(r, cfg, VerifyValueFile) - if got.OK { - t.Fatalf("expected OK=false") - } - if len(got.Checks) != 1 || got.Checks[0].Name != "repo_add" || got.Checks[0].OK { - t.Fatalf("unexpected checks: %+v", got.Checks) - } - if !strings.Contains(got.Checks[0].Detail, "stderr text") { - t.Fatalf("stderr should be surfaced, got %q", got.Checks[0].Detail) - } - if r.updateCalled || r.pullCalled { - t.Fatalf("later steps must not run after repo_add failure") - } -} - -func TestRun_PullFailDoesNotSkipValueFile(t *testing.T) { - cfg := Config{ - ChartName: "p", Version: "1", RepoURL: "u", RepoName: "n", - ValueFile: writeTempYAML(t, "image:\n repository: nginx\n tag: \"v1\"\n"), - } - r := &fakeRunner{pullErr: errors.New("nope"), pullOut: "not found"} - got := Run(r, cfg, VerifyValueFile) - if got.OK { - t.Fatal("expected OK=false") - } - var gotPull, gotVF bool - for _, c := range got.Checks { - if c.Name == "helm_pull" { - gotPull = true - if c.OK { - t.Fatal("helm_pull should be false") - } - if !strings.Contains(c.Detail, "not found") { - t.Fatalf("pull stderr not surfaced: %q", c.Detail) - } - } - if c.Name == "value_file" { - gotVF = true - if !c.OK { - t.Fatalf("value_file should still run and succeed: %+v", c) - } - } - } - if !gotPull || !gotVF { - t.Fatalf("both helm_pull and value_file expected, got %+v", got.Checks) - } -} - -func TestRun_BadValueFile(t *testing.T) { - bad := writeTempYAML(t, "image:\n repository: [nope\n") - cfg := Config{ - ChartName: "p", Version: "1", RepoURL: "u", RepoName: "n", - ValueFile: bad, - } - r := &fakeRunner{} - got := Run(r, cfg, VerifyValueFile) - if got.OK { - t.Fatal("expected OK=false for bad yaml") - } - last := got.Checks[len(got.Checks)-1] - if last.Name != "value_file" || last.OK { - t.Fatalf("expected value_file to fail, got %+v", last) - } -} - -func TestVerifyValueFile_RejectsNonStringRepository(t *testing.T) { - p := writeTempYAML(t, "image:\n repository:\n - a\n - b\n") - if err := VerifyValueFile(p); err == nil { - t.Fatal("expected error for non-string image.repository") - } -} - -func TestVerifyValueFile_AcceptsIntTag(t *testing.T) { - p := writeTempYAML(t, "image:\n repository: nginx\n tag: 1\n") - if err := VerifyValueFile(p); err != nil { - t.Fatalf("int tag should be accepted, got %v", err) - } -} - -func TestVerifyValueFile_NoImageSection(t *testing.T) { - p := writeTempYAML(t, "service:\n type: ClusterIP\n") - if err := VerifyValueFile(p); err != nil { - t.Fatalf("absent image section should be fine, got %v", err) - } -} diff --git a/src/handlers/v2/permissions.go b/src/handlers/v2/permissions.go deleted file mode 100644 index 5bf4b69b..00000000 --- a/src/handlers/v2/permissions.go +++ /dev/null @@ -1,119 +0,0 @@ -package v2 - -import ( - "aegis/consts" - "net/http" - "strconv" - - "aegis/dto" - "aegis/handlers" - producer "aegis/service/producer" - - "github.com/gin-gonic/gin" -) - -// GetPermission handles getting a single permission by ID -// -// @Summary Get permission by ID -// @Description Get detailed information about a specific permission -// @Tags Permissions -// @ID get_permission_by_id -// @Produce json -// @Security BearerAuth -// @Param id path int true "Permission ID" -// @Success 200 {object} dto.GenericResponse[dto.PermissionDetailResp] "Permission retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid permission ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Permission not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/permissions/{id} [get] -func GetPermission(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, err := strconv.Atoi(idStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid permission ID") - return - } - - resp, err := producer.GetPermissionDetail(id) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// ListPermissions handles listing permissions with pagination and filtering -// -// @Summary List permissions -// @Description Get paginated list of permissions with optional filtering -// @Tags Permissions -// @ID list_permissions -// @Produce json -// @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param action query string false "Filter by action" -// @Param is_system query bool false "Filter by system permission" -// @Param status query consts.StatusType false "Filter by status" -// @Success 200 {object} dto.GenericResponse[dto.PermissionResp] "Permissions retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/permissions [get] -func ListPermissions(c *gin.Context) { - var req dto.ListPermissionReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - response, err := producer.ListPermissions(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, response) -} - -// ===================== Role-Permission API ===================== - -// ListRolesFromPermission handles listing roles assigned to a permission -// -// @Summary List roles from permission -// @Description Get list of roles assigned to a specific permission -// @Tags Permissions -// @ID list_roles_with_permission -// @Produce json -// @Security BearerAuth -// @Param permission_id path int true "Permission ID" -// @Success 200 {object} dto.GenericResponse[[]dto.RoleResp] "Roles retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid permission ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Permission not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/permissions/{permission_id}/roles [get] -// @x-api-type {"sdk":"true"} -func ListRolesFromPermission(c *gin.Context) { - permissionIDStr := c.Param(consts.URLPathPermissionID) - permissionID, err := strconv.Atoi(permissionIDStr) - if err != nil || permissionID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid permission ID") - return - } - - resp, err := producer.ListRolesFromPermission(permissionID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} diff --git a/src/handlers/v2/projects.go b/src/handlers/v2/projects.go deleted file mode 100644 index 2b57d425..00000000 --- a/src/handlers/v2/projects.go +++ /dev/null @@ -1,504 +0,0 @@ -package v2 - -import ( - "aegis/consts" - "net/http" - "strconv" - - "aegis/dto" - "aegis/handlers" - "aegis/middleware" - producer "aegis/service/producer" - - "github.com/gin-gonic/gin" -) - -// CreateProject handles project creation -// -// @Summary Create a new project -// @Description Create a new project with specified details -// @Tags Projects -// @ID create_project -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body dto.CreateProjectReq true "Project creation request" -// @Success 201 {object} dto.GenericResponse[dto.ProjectResp] "Project created successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 409 {object} dto.GenericResponse[any] "Project already exists" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/projects [post] -// @x-api-type {"sdk":"true"} -func CreateProject(c *gin.Context) { - userID, exists := middleware.GetCurrentUserID(c) - if !exists { - dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") - return - } - - var req dto.CreateProjectReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.CreateProject(&req, userID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusCreated, "Project created successfully", resp) -} - -// DeleteProject handles project deletion -// -// @Summary Delete project -// @Description Delete a project -// @Tags Projects -// @ID delete_project -// @Produce json -// @Security BearerAuth -// @Param project_id path int true "Project ID" -// @Success 204 {object} dto.GenericResponse[any] "Project deleted successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid project ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Project not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/projects/{project_id} [delete] -func DeleteProject(c *gin.Context) { - projectIdStr := c.Param(consts.URLPathProjectID) - projectID, err := strconv.Atoi(projectIdStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") - return - } - - err = producer.DeleteProject(projectID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse[any](c, http.StatusNoContent, "Project deleted successfully", nil) -} - -// GetProjectDetail handles getting a single project by ID -// -// @Summary Get project by ID -// @Description Get detailed information about a specific project -// @Tags Projects -// @ID get_project_by_id -// @Produce json -// @Security BearerAuth -// @Param project_id path int true "Project ID" -// @Success 200 {object} dto.GenericResponse[dto.ProjectDetailResp] "Project retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid project ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Project not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/projects/{project_id} [get] -// @x-api-type {"sdk":"true"} -func GetProjectDetail(c *gin.Context) { - projectIdStr := c.Param(consts.URLPathProjectID) - projectID, err := strconv.Atoi(projectIdStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") - return - } - - resp, err := producer.GetProjectDetail(projectID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// ListProjects handles listing projects with pagination and filtering -// -// @Summary List projects -// @Description Get paginated list of projects with filtering -// @Tags Projects -// @ID list_projects -// @Produce json -// @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param is_public query bool false "Filter by public status" -// @Param status query consts.StatusType false "Filter by status" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.ProjectResp]] "Projects retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/projects [get] -// @x-api-type {"sdk":"true"} -func ListProjects(c *gin.Context) { - var req dto.ListProjectReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.ListProjects(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// UpdateProject handles project updates -// -// @Summary Update project -// @Description Update an existing project's information -// @Tags Projects -// @ID update_project -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param project_id path int true "Project ID" -// @Param request body dto.UpdateProjectReq true "Project update request" -// @Success 202 {object} dto.GenericResponse[dto.ProjectResp] "Project updated successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid project ID or invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Project not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/projects/{project_id} [patch] -func UpdateProject(c *gin.Context) { - projectIdStr := c.Param(consts.URLPathProjectID) - projectID, err := strconv.Atoi(projectIdStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") - return - } - - var req dto.UpdateProjectReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.UpdateProject(&req, projectID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse[any](c, http.StatusAccepted, "Project updated successfully", resp) -} - -// ===================== Project-Label API ===================== - -// ManageProjectCustomLabels manages project custom labels (key-value pairs) -// -// @Summary Manage project custom labels -// @Description Add or remove custom labels (key-value pairs) for a project -// @Tags Projects -// @ID update_project_labels -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param project_id path int true "Project ID" -// @Param manage body dto.ManageProjectLabelReq true "Label management request" -// @Success 200 {object} dto.GenericResponse[dto.ProjectResp] "Labels managed successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid project ID or invalid request format/parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Project not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/projects/{project_id}/labels [patch] -func ManageProjectCustomLabels(c *gin.Context) { - projectIDStr := c.Param(consts.URLPathProjectID) - projectID, err := strconv.Atoi(projectIDStr) - if err != nil || projectID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") - return - } - - var req dto.ManageProjectLabelReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.ManageProjectLabels(&req, projectID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// ===================== Project-Injection API ===================== - -// ListProjectInjections lists all fault injections for a project -// -// @Summary List project fault injections -// @Description Get paginated list of fault injections for a specific project -// @Tags Projects -// @ID list_project_injections -// @Produce json -// @Security BearerAuth -// @Param project_id path int true "Project ID" -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.InjectionResp]] "Fault injections retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid project ID or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Project not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/projects/{project_id}/injections [get] -// @x-api-type {"sdk":"true"} -func ListProjectInjections(c *gin.Context) { - projectIDStr := c.Param(consts.URLPathProjectID) - projectID, err := strconv.Atoi(projectIDStr) - if err != nil || projectID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") - return - } - - var req dto.ListInjectionReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.ListProjectInjections(&req, projectID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// SearchProjectInjections searches fault injections within a specific project -// -// @Summary Search project fault injections -// @Description Advanced search for injections within a project with complex filtering -// @Tags Projects -// @ID search_project_injections -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param project_id path int true "Project ID" -// @Param search body dto.SearchInjectionReq true "Search criteria" -// @Success 200 {object} dto.GenericResponse[dto.SearchResp[dto.InjectionDetailResp]] "Search results" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid project ID or request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Project not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/projects/{project_id}/injections/search [post] -// @x-api-type {"sdk":"true"} -func SearchProjectInjections(c *gin.Context) { - projectIDStr := c.Param(consts.URLPathProjectID) - projectID, err := strconv.Atoi(projectIDStr) - if err != nil || projectID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") - return - } - - searchInjectionsCommon(c, &projectID) -} - -// ListProjectFaultInjectionNoIssues lists fault injections without issues for a project -// -// @Summary List project fault injections without issues -// @Description Query fault injection records without issues within a project based on time range -// @Tags Projects -// @ID list_project_injections_no_issues -// @Produce json -// @Security BearerAuth -// @Param project_id path int true "Project ID" -// @Param labels query []string false "Filter by labels" -// @Param lookback query string false "Time range query" -// @Param custom_start_time query string false "Custom start time" -// @Param custom_end_time query string false "Custom end time" -// @Success 200 {object} dto.GenericResponse[[]dto.InjectionNoIssuesResp] "Injections retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Project not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/projects/{project_id}/injections/analysis/no-issues [get] -// @x-api-type {"sdk":"true"} -func ListProjectFaultInjectionNoIssues(c *gin.Context) { - projectIDStr := c.Param(consts.URLPathProjectID) - projectID, err := strconv.Atoi(projectIDStr) - if err != nil || projectID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") - return - } - - listFaultInjectionNoIssuesCommon(c, &projectID) -} - -// ListProjectFaultInjectionWithIssues lists fault injections with issues for a project -// -// @Summary List project fault injections with issues -// @Description Query fault injection records with issues within a project based on time range -// @Tags Projects -// @ID list_project_injections_with_issues -// @Produce json -// @Security BearerAuth -// @Param project_id path int true "Project ID" -// @Param labels query []string false "Filter by labels" -// @Param lookback query string false "Time range query" -// @Param custom_start_time query string false "Custom start time" -// @Param custom_end_time query string false "Custom end time" -// @Success 200 {object} dto.GenericResponse[[]dto.InjectionWithIssuesResp] "Injections retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Project not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/projects/{project_id}/injections/analysis/with-issues [get] -// @x-api-type {"sdk":"true"} -func ListProjectFaultInjectionWithIssues(c *gin.Context) { - projectIDStr := c.Param(consts.URLPathProjectID) - projectID, err := strconv.Atoi(projectIDStr) - if err != nil || projectID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") - return - } - - listFaultInjectionWithIssuesCommon(c, &projectID) -} - -// SubmitProjectFaultInjection submits fault injections for a specific project -// -// @Summary Submit project fault injections -// @Description Submit multiple fault injection tasks for a specific project -// @Tags Projects -// @ID submit_project_fault_injection -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param project_id path int true "Project ID" -// @Param body body dto.SubmitInjectionReq true "Fault injection request" -// @Success 200 {object} dto.GenericResponse[dto.SubmitInjectionResp] "Injections submitted successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Project not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/projects/{project_id}/injections/inject [post] -// @x-api-type {"sdk":"true"} -func SubmitProjectFaultInjection(c *gin.Context) { - projectIDStr := c.Param(consts.URLPathProjectID) - projectID, err := strconv.Atoi(projectIDStr) - if err != nil || projectID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") - return - } - - submitFaultInjectionCommon(c, &projectID) -} - -// SubmitProjectDatapackBuilding submits datapack building tasks for a specific project -// -// @Summary Submit project datapack buildings -// @Description Submit multiple datapack building tasks for a specific project -// @Tags Projects -// @ID submit_project_datapack_building -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param project_id path int true "Project ID" -// @Param body body dto.SubmitDatapackBuildingReq true "Datapack building request" -// @Success 202 {object} dto.GenericResponse[dto.SubmitDatapackBuildingResp] "Datapack buildings submitted successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Project not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/projects/{project_id}/injections/build [post] -// @x-api-type {"sdk":"true"} -func SubmitProjectDatapackBuilding(c *gin.Context) { - projectIDStr := c.Param(consts.URLPathProjectID) - projectID, err := strconv.Atoi(projectIDStr) - if err != nil || projectID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") - return - } - - submitDatapackBuildingCommon(c, &projectID) -} - -// ===================== Project-Execution API ===================== - -// ListProjectExecutions lists all algorithm executions for a project -// -// @Summary List project executions -// @Description Get paginated list of algorithm executions for a specific project -// @Tags Projects -// @ID list_project_executions -// @Produce json -// @Security BearerAuth -// @Param project_id path int true "Project ID" -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.ExecutionResp]] "Executions retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid project ID or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Project not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/projects/{project_id}/executions [get] -// @x-api-type {"sdk":"true"} -func ListProjectExecutions(c *gin.Context) { - projectIDStr := c.Param(consts.URLPathProjectID) - projectID, err := strconv.Atoi(projectIDStr) - if err != nil || projectID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") - return - } - - var req dto.ListExecutionReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.ListProjectExecutions(&req, projectID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} diff --git a/src/handlers/v2/resources.go b/src/handlers/v2/resources.go deleted file mode 100644 index 07fbc0d4..00000000 --- a/src/handlers/v2/resources.go +++ /dev/null @@ -1,117 +0,0 @@ -package v2 - -import ( -"aegis/consts" - "aegis/dto" - "aegis/handlers" - producer "aegis/service/producer" - "net/http" - "strconv" - - "github.com/gin-gonic/gin" -) - -// GetResourceDetail handles getting a single resource by ID -// -// @Summary Get resource by ID -// @Description Get detailed information about a specific resource -// @Tags Resources -// @ID get_resource_by_id -// @Produce json -// @Security BearerAuth -// @Param id path int true "Resource ID" -// @Success 200 {object} dto.GenericResponse[dto.ResourceResp] "Resource retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid resource ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Resource not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/resources/{id} [get] -// @x-api-type {"sdk":"true"} -func GetResourceDetail(c *gin.Context) { - resourceIDStr := c.Param(consts.URLPathID) - resourceID, err := strconv.Atoi(resourceIDStr) - if err != nil || resourceID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid resource ID") - return - } - - resp, err := producer.GetResourceDetail(resourceID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// ListResources handles listing resources with pagination and filtering -// -// @Summary List resources -// @Description Get paginated list of resources with filtering -// @Tags Resources -// @ID list_resources -// @Produce json -// @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param type query consts.ResourceType false "Filter by resource type" -// @Param category query consts.ResourceCategory false "Filter by resource category" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.ResourceResp]] "Resources retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/resources [get] -// @x-api-type {"sdk":"true"} -func ListResources(c *gin.Context) { - var req dto.ListResourceReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.ListResources(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// ListResourcePermissions handles listing permissions by resource -// -// @Summary List permissions from resource -// @Description Get list of permissions assigned to a specific resource -// @Tags Resources -// @ID list_resource_permissions -// @Produce json -// @Security BearerAuth -// @Param id path int true "Resource ID" -// @Success 200 {object} dto.GenericResponse[[]dto.PermissionResp] "Permissions retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid resource ID or request form" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Resource not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/resources/{id}/permissions [get] -// @x-api-type {"sdk":"true"} -func ListResourcePermissions(c *gin.Context) { - resourceIDStr := c.Param(consts.URLPathID) - resourceID, err := strconv.Atoi(resourceIDStr) - if err != nil || resourceID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid resource ID") - return - } - - resp, err := producer.ListResourcePermissions(resourceID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} diff --git a/src/handlers/v2/roles.go b/src/handlers/v2/roles.go deleted file mode 100644 index 828981a6..00000000 --- a/src/handlers/v2/roles.go +++ /dev/null @@ -1,276 +0,0 @@ -package v2 - -import ( - "aegis/consts" - "net/http" - "strconv" - - "aegis/dto" - "aegis/handlers" - producer "aegis/service/producer" - - "github.com/gin-gonic/gin" -) - -// CreateRole handles role creation -// -// @Summary Create a new role -// @Description Create a new role with specified permissions -// @Tags Roles -// @ID create_role -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body dto.CreateRoleReq true "Role creation request" -// @Success 201 {object} dto.GenericResponse[dto.RoleResp] "Role created successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 409 {object} dto.GenericResponse[any] "Role already exists" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/roles [post] -// @x-api-type {"sdk":"true"} -func CreateRole(c *gin.Context) { - var req dto.CreateRoleReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - resp, err := producer.CreateRole(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse(c, http.StatusCreated, "Role created successfully", resp) -} - -// DeleteRole handles role deletion -// -// @Summary Delete role -// @Description Delete a role (soft delete by setting status to -1) -// @Tags Roles -// @ID delete_role -// @Produce json -// @Security BearerAuth -// @Param id path int true "Role ID" -// @Success 200 {object} dto.GenericResponse[any] "Role deleted successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid role ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied or cannot delete system role" -// @Failure 404 {object} dto.GenericResponse[any] "Role not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/roles/{id} [delete] -// @x-api-type {"sdk":"true"} -func DeleteRole(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, err := strconv.Atoi(idStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid role ID") - return - } - - err = producer.DeleteRole(id) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse[any](c, http.StatusNoContent, "Role deleted successfully", nil) -} - -// GetRole handles getting a single role by ID -// -// @Summary Get role by ID -// @Description Get detailed information about a specific role -// @Tags Roles -// @ID get_role_by_id -// @Produce json -// @Security BearerAuth -// @Param id path int true "Role ID" -// @Success 200 {object} dto.GenericResponse[dto.RoleDetailResp] "Role retrieved successfully" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid role ID" -// @Failure 404 {object} dto.GenericResponse[any] "Role not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/roles/{id} [get] -// @x-api-type {"sdk":"true"} -func GetRole(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, err := strconv.Atoi(idStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid role ID") - return - } - - resp, err := producer.GetRoleDetail(id) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// ListRoles handles listing roles with pagination and filtering -// -// @Summary List roles -// @Description Get paginated list of roles with optional filtering -// @Tags Roles -// @ID list_roles -// @Produce json -// @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param is_system query bool false "Filter by system role" -// @Param status query consts.StatusType false "Filter by status" -// @Success 200 {object} dto.GenericResponse[dto.ListRoleResp] "Roles retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Validation failed" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/roles [get] -// @x-api-type {"sdk":"true"} -func ListRoles(c *gin.Context) { - var req dto.ListRoleReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.ListRoles(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// UpdateRole handles role updates -// -// @Summary Update role -// @Description Update role information (partial update supported) -// @Tags Roles -// @ID update_role -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path int true "Role ID" -// @Param request body dto.UpdateRoleReq true "Role update request" -// @Success 202 {object} dto.GenericResponse[dto.RoleResp] "Role updated successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Role not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/roles/{id} [patch] -// @x-api-type {"sdk":"true"} -func UpdateRole(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, err := strconv.Atoi(idStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid role ID") - return - } - - var req dto.UpdateRoleReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.UpdateRole(&req, id) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse[any](c, http.StatusAccepted, "Role updated successfully", resp) -} - -// ===================== Role-Permission API ===================== - -// AssignRolePermission handles role-permission assignment -// -// @Summary Assign permissions to role -// @Description Assign multiple permissions to a role -// @Tags Roles -// @ID grant_permissions_to_role -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param role_id path int true "Role ID" -// @Param request body dto.AssignRolePermissionReq true "Permission assignment request" -// @Success 200 {object} dto.GenericResponse[any] "Permissions assigned successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid role ID or request format" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Role not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/roles/{role_id}/permissions/assign [post] -// @x-api-type {"sdk":"true"} -func AssignRolePermission(c *gin.Context) { - roleIdStr := c.Param(consts.URLPathRoleID) - roleID, err := strconv.Atoi(roleIdStr) - if err != nil || roleID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid role ID") - return - } - - var req dto.AssignRolePermissionReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - err = producer.BatchAssignRolePermissions(req.PermissionIDs, roleID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse[any](c, http.StatusOK, "Permissions assigned successfully", nil) -} - -// RemovePermissionsFromRole handles permission removal from role -// -// @Summary Remove permissions from role -// @Description Remove multiple permissions from a role -// @Tags Roles -// @ID revoke_permissions_from_role -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param role_id path int true "Role ID" -// @Param request body dto.RemoveRolePermissionReq true "Permission removal request" -// @Success 200 {object} dto.GenericResponse[any] "Permissions removed successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid role ID or request format" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Role not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/roles/{role_id}/permissions/remove [post] -// @x-api-type {"sdk":"true"} -func RemovePermissionsFromRole(c *gin.Context) { - roleIDStr := c.Param(consts.URLPathRoleID) - roleID, err := strconv.Atoi(roleIDStr) - if err != nil || roleID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid role ID") - return - } - - var req dto.RemoveRolePermissionReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - err = producer.RemovePermissionsFromRole(req.PermissionIDs, roleID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.JSONResponse[any](c, http.StatusOK, "Permissions removed successfully", nil) -} diff --git a/src/handlers/v2/sdk_evaluations.go b/src/handlers/v2/sdk_evaluations.go deleted file mode 100644 index 592cc543..00000000 --- a/src/handlers/v2/sdk_evaluations.go +++ /dev/null @@ -1,131 +0,0 @@ -package v2 - -import ( - "net/http" - - "aegis/consts" - "aegis/dto" - "aegis/handlers" - producer "aegis/service/producer" - - "github.com/gin-gonic/gin" -) - -// ListSDKEvaluations handles listing SDK evaluation samples with pagination -// -// @Summary List SDK evaluation samples -// @Description Get a paginated list of SDK evaluation samples, optionally filtered by exp_id and stage -// @Tags SDK Evaluations -// @ID list_sdk_evaluations -// @Produce json -// @Security BearerAuth -// @Param exp_id query string false "Experiment ID filter" -// @Param stage query string false "Stage filter (init, rollout, judged)" -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Success 200 {object} dto.GenericResponse[dto.ListResp[database.SDKEvaluationSample]] "SDK evaluations retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/sdk/evaluations [get] -func ListSDKEvaluations(c *gin.Context) { - var req dto.ListSDKEvaluationReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.ListSDKEvaluations(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// GetSDKEvaluation handles getting a single SDK evaluation sample by ID -// -// @Summary Get SDK evaluation sample by ID -// @Description Get detailed information about a specific SDK evaluation sample -// @Tags SDK Evaluations -// @ID get_sdk_evaluation -// @Produce json -// @Security BearerAuth -// @Param id path int true "SDK Evaluation Sample ID" -// @Success 200 {object} dto.GenericResponse[database.SDKEvaluationSample] "SDK evaluation sample retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid evaluation ID" -// @Failure 404 {object} dto.GenericResponse[any] "SDK evaluation sample not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/sdk/evaluations/{id} [get] -func GetSDKEvaluation(c *gin.Context) { - id, ok := handlers.ParsePositiveID(c, c.Param(consts.URLPathID), "SDK evaluation ID") - if !ok { - return - } - - resp, err := producer.GetSDKEvaluation(id) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// ListSDKExperiments handles listing all distinct experiment IDs -// -// @Summary List SDK experiment IDs -// @Description Get all distinct experiment IDs from SDK evaluation data -// @Tags SDK Evaluations -// @ID list_sdk_experiments -// @Produce json -// @Security BearerAuth -// @Success 200 {object} dto.GenericResponse[dto.SDKExperimentListResp] "SDK experiments retrieved successfully" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/sdk/evaluations/experiments [get] -func ListSDKExperiments(c *gin.Context) { - resp, err := producer.ListSDKExperiments() - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - -// ListSDKDatasetSamples handles listing SDK dataset samples with pagination -// -// @Summary List SDK dataset samples -// @Description Get a paginated list of SDK dataset samples, optionally filtered by dataset name -// @Tags SDK Datasets -// @ID list_sdk_dataset_samples -// @Produce json -// @Security BearerAuth -// @Param dataset query string false "Dataset name filter" -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Success 200 {object} dto.GenericResponse[dto.ListResp[database.SDKDatasetSample]] "SDK dataset samples retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/sdk/datasets [get] -func ListSDKDatasetSamples(c *gin.Context) { - var req dto.ListSDKDatasetSampleReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.ListSDKDatasetSamples(&req) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} diff --git a/src/handlers/v2/system.go b/src/handlers/v2/system.go deleted file mode 100644 index 9f0d5493..00000000 --- a/src/handlers/v2/system.go +++ /dev/null @@ -1,62 +0,0 @@ -package v2 - -import ( - "fmt" - "net/http" - - "aegis/consts" - "aegis/dto" - "aegis/handlers" - producer "aegis/service/producer" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" -) - -// GetSystemMetrics retrieves current system metrics -// -// @Summary Get current system metrics -// @Description Get current CPU, memory, and disk usage metrics -// @Tags System -// @ID get_system_metrics -// @Produce json -// @Security BearerAuth -// @Success 200 {object} dto.GenericResponse[dto.SystemMetricsResp] "System metrics retrieved successfully" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/system/metrics [get] -// @x-api-type {"sdk":"true"} -func GetSystemMetrics(c *gin.Context) { - resp, err := producer.GetSystemMetrics(c.Request.Context()) - if err != nil { - logrus.WithError(err).Error("Failed to get system metrics") - handlers.HandleServiceError(c, fmt.Errorf("%w: %v", consts.ErrInternal, err)) - return - } - - dto.JSONResponse(c, http.StatusOK, "System metrics retrieved successfully", resp) -} - -// GetSystemMetricsHistory retrieves historical system metrics (24 hours) -// -// @Summary Get historical system metrics -// @Description Get 24-hour historical CPU and memory usage metrics -// @Tags System -// @ID get_system_metrics_history -// @Produce json -// @Security BearerAuth -// @Success 200 {object} dto.GenericResponse[dto.SystemMetricsHistoryResp] "System metrics history retrieved successfully" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/system/metrics/history [get] -// @x-api-type {"sdk":"true"} -func GetSystemMetricsHistory(c *gin.Context) { - resp, err := producer.GetSystemMetricsHistory(c.Request.Context()) - if err != nil { - logrus.WithError(err).Error("Failed to get system metrics history") - handlers.HandleServiceError(c, fmt.Errorf("%w: %v", consts.ErrInternal, err)) - return - } - - dto.JSONResponse(c, http.StatusOK, "System metrics history retrieved successfully", resp) -} diff --git a/src/handlers/common.go b/src/httpx/common.go similarity index 95% rename from src/handlers/common.go rename to src/httpx/common.go index 66a84911..b4715fc6 100644 --- a/src/handlers/common.go +++ b/src/httpx/common.go @@ -1,18 +1,18 @@ -package handlers +package httpx import ( + "net/http" + "strconv" + "aegis/consts" "aegis/dto" "aegis/utils" - "net/http" - "strconv" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" ) -// ParsePositiveID parses a string ID parameter and validates it's a positive integer -// Returns the parsed ID and true if valid, or writes error response and returns false +// ParsePositiveID parses a string ID parameter and validates it's a positive integer. func ParsePositiveID(c *gin.Context, idStr, fieldName string) (int, bool) { logrus.WithFields(logrus.Fields{ "idStr": idStr, @@ -66,14 +66,14 @@ func HandleServiceError(c *gin.Context, err error) bool { switch innermostErr { case consts.ErrAuthenticationFailed: dto.ErrorResponse(c, http.StatusUnauthorized, msg) + case consts.ErrPermissionDenied: + dto.ErrorResponse(c, http.StatusForbidden, msg) case consts.ErrBadRequest: dto.ErrorResponse(c, http.StatusBadRequest, msg) case consts.ErrNotFound: dto.ErrorResponse(c, http.StatusNotFound, msg) case consts.ErrAlreadyExists: dto.ErrorResponse(c, http.StatusConflict, msg) - case consts.ErrPermissionDenied: - dto.ErrorResponse(c, http.StatusForbidden, msg) case consts.ErrInternal: logrus.WithFields(logrus.Fields{ "path": c.Request.URL.Path, diff --git a/src/httpx/request_id.go b/src/httpx/request_id.go new file mode 100644 index 00000000..eba9aee5 --- /dev/null +++ b/src/httpx/request_id.go @@ -0,0 +1,119 @@ +package httpx + +import ( + "context" + "strings" + + "github.com/google/uuid" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +const ( + RequestIDHeader = "X-Request-Id" + requestIDMetadataKey = "x-request-id" +) + +type requestIDContextKey struct{} + +func NewRequestID() string { + return uuid.NewString() +} + +func WithRequestID(ctx context.Context, requestID string) context.Context { + if ctx == nil { + ctx = context.Background() + } + + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return ctx + } + + return context.WithValue(ctx, requestIDContextKey{}, requestID) +} + +func RequestIDFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + + if requestID, ok := ctx.Value(requestIDContextKey{}).(string); ok && strings.TrimSpace(requestID) != "" { + return strings.TrimSpace(requestID) + } + + if md, ok := metadata.FromIncomingContext(ctx); ok { + if requestID := firstMetadataValue(md, requestIDMetadataKey); requestID != "" { + return requestID + } + } + + if md, ok := metadata.FromOutgoingContext(ctx); ok { + if requestID := firstMetadataValue(md, requestIDMetadataKey); requestID != "" { + return requestID + } + } + + return "" +} + +func WithOutgoingRequestID(ctx context.Context) context.Context { + requestID := RequestIDFromContext(ctx) + if requestID == "" { + return ctx + } + + md, _ := metadata.FromOutgoingContext(ctx) + md = md.Copy() + md.Set(requestIDMetadataKey, requestID) + return metadata.NewOutgoingContext(WithRequestID(ctx, requestID), md) +} + +func UnaryClientRequestIDInterceptor() grpc.UnaryClientInterceptor { + return func( + ctx context.Context, + method string, + req any, + reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, + ) error { + return invoker(WithOutgoingRequestID(ctx), method, req, reply, cc, opts...) + } +} + +func UnaryServerRequestIDInterceptor() grpc.UnaryServerInterceptor { + return func( + ctx context.Context, + req any, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, + ) (any, error) { + requestID := RequestIDFromContext(ctx) + if requestID == "" { + requestID = NewRequestID() + } + + ctx = WithRequestID(ctx, requestID) + if err := grpc.SetHeader(ctx, metadata.Pairs(requestIDMetadataKey, requestID)); err != nil { + return nil, err + } + + return handler(ctx, req) + } +} + +func firstMetadataValue(md metadata.MD, key string) string { + if md == nil { + return "" + } + + values := md.Get(key) + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/src/httpx/request_id_test.go b/src/httpx/request_id_test.go new file mode 100644 index 00000000..5a7050e8 --- /dev/null +++ b/src/httpx/request_id_test.go @@ -0,0 +1,48 @@ +package httpx + +import ( + "context" + "testing" + + "google.golang.org/grpc/metadata" +) + +func TestRequestIDFromContextPrefersLocalValue(t *testing.T) { + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-request-id", "from-md")) + ctx = WithRequestID(ctx, "from-context") + + if got := RequestIDFromContext(ctx); got != "from-context" { + t.Fatalf("RequestIDFromContext() = %q, want %q", got, "from-context") + } +} + +func TestRequestIDFromContextFallsBackToIncomingMetadata(t *testing.T) { + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-request-id", "from-md")) + + if got := RequestIDFromContext(ctx); got != "from-md" { + t.Fatalf("RequestIDFromContext() = %q, want %q", got, "from-md") + } +} + +func TestWithOutgoingRequestIDCopiesValueToMetadata(t *testing.T) { + ctx := WithRequestID(context.Background(), "req-123") + + outgoing := WithOutgoingRequestID(ctx) + md, ok := metadata.FromOutgoingContext(outgoing) + if !ok { + t.Fatal("expected outgoing metadata to exist") + } + + values := md.Get("x-request-id") + if len(values) != 1 || values[0] != "req-123" { + t.Fatalf("outgoing request id = %v, want [req-123]", values) + } +} + +func TestRequestIDFromContextFallsBackToOutgoingMetadata(t *testing.T) { + ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("x-request-id", "outgoing-md")) + + if got := RequestIDFromContext(ctx); got != "outgoing-md" { + t.Fatalf("RequestIDFromContext() = %q, want %q", got, "outgoing-md") + } +} diff --git a/src/infra/buildkit/gateway.go b/src/infra/buildkit/gateway.go new file mode 100644 index 00000000..5914bf04 --- /dev/null +++ b/src/infra/buildkit/gateway.go @@ -0,0 +1,52 @@ +package buildkit + +import ( + "context" + "fmt" + "net" + "time" + + "aegis/config" + + buildkitclient "github.com/moby/buildkit/client" +) + +type Gateway struct{} + +func NewGateway() *Gateway { + return &Gateway{} +} + +func (g *Gateway) Address() string { + return config.GetString("buildkit.address") +} + +func (g *Gateway) Endpoint() string { + address := g.Address() + if address == "" { + return "" + } + return fmt.Sprintf("tcp://%s", address) +} + +func (g *Gateway) NewClient(ctx context.Context) (*buildkitclient.Client, error) { + endpoint := g.Endpoint() + if endpoint == "" { + return nil, fmt.Errorf("buildkit address is not configured") + } + return buildkitclient.New(ctx, endpoint) +} + +func (g *Gateway) CheckHealth(ctx context.Context, timeout time.Duration) error { + address := g.Address() + if address == "" { + return fmt.Errorf("buildkit address is not configured") + } + + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(ctx, "tcp", address) + if err != nil { + return fmt.Errorf("cannot connect to BuildKit at %s: %w", address, err) + } + return conn.Close() +} diff --git a/src/infra/buildkit/module.go b/src/infra/buildkit/module.go new file mode 100644 index 00000000..28c3f26f --- /dev/null +++ b/src/infra/buildkit/module.go @@ -0,0 +1,7 @@ +package buildkit + +import "go.uber.org/fx" + +var Module = fx.Module("buildkit", + fx.Provide(NewGateway), +) diff --git a/src/infra/chaos/module.go b/src/infra/chaos/module.go new file mode 100644 index 00000000..d93d4cfc --- /dev/null +++ b/src/infra/chaos/module.go @@ -0,0 +1,15 @@ +package chaos + +import ( + chaosCli "github.com/OperationsPAI/chaos-experiment/client" + "go.uber.org/fx" + "k8s.io/client-go/rest" +) + +var Module = fx.Module("chaos", + fx.Invoke(Initialize), +) + +func Initialize(restConfig *rest.Config) { + chaosCli.InitWithConfig(restConfig) +} diff --git a/src/infra/config/module.go b/src/infra/config/module.go new file mode 100644 index 00000000..15b28985 --- /dev/null +++ b/src/infra/config/module.go @@ -0,0 +1,19 @@ +package config + +import ( + "aegis/config" + + "go.uber.org/fx" +) + +type Params struct { + Path string +} + +var Module = fx.Module("config", + fx.Invoke(Init), +) + +func Init(params Params) { + config.Init(params.Path) +} diff --git a/src/infra/db/config.go b/src/infra/db/config.go new file mode 100644 index 00000000..96b46665 --- /dev/null +++ b/src/infra/db/config.go @@ -0,0 +1,38 @@ +package db + +import ( + "fmt" + + "aegis/config" +) + +type DatabaseConfig struct { + Type string + Host string + Port int + User string + Password string + Database string + Timezone string +} + +func NewDatabaseConfig(databaseType string) *DatabaseConfig { + return &DatabaseConfig{ + Type: databaseType, + Host: config.GetString(fmt.Sprintf("database.%s.host", databaseType)), + Port: config.GetInt(fmt.Sprintf("database.%s.port", databaseType)), + User: config.GetString(fmt.Sprintf("database.%s.user", databaseType)), + Password: config.GetString(fmt.Sprintf("database.%s.password", databaseType)), + Database: config.GetString(fmt.Sprintf("database.%s.db", databaseType)), + Timezone: config.GetString(fmt.Sprintf("database.%s.timezone", databaseType)), + } +} + +func (d *DatabaseConfig) ToDSN() (string, error) { + if d.Type != "mysql" { + return "", fmt.Errorf("unsupported database type: %s", d.Type) + } + + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", + d.User, d.Password, d.Host, d.Port, d.Database), nil +} diff --git a/src/infra/db/migration.go b/src/infra/db/migration.go new file mode 100644 index 00000000..ff3c3669 --- /dev/null +++ b/src/infra/db/migration.go @@ -0,0 +1,125 @@ +package db + +import ( + "aegis/model" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func migrate(db *gorm.DB) { + if err := db.AutoMigrate( + &model.Container{}, + &model.ContainerVersion{}, + &model.HelmConfig{}, + &model.ParameterConfig{}, + &model.Dataset{}, + &model.DatasetVersion{}, + &model.Project{}, + &model.Label{}, + &model.User{}, + &model.APIKey{}, + &model.Role{}, + &model.Permission{}, + &model.Resource{}, + &model.AuditLog{}, + &model.Task{}, + &model.FaultInjection{}, + &model.Execution{}, + &model.DetectorResult{}, + &model.GranularityResult{}, + &model.ContainerLabel{}, + &model.DatasetLabel{}, + &model.ProjectLabel{}, + &model.ContainerVersionEnvVar{}, + &model.HelmConfigValue{}, + &model.DatasetVersionInjection{}, + &model.FaultInjectionLabel{}, + &model.ExecutionInjectionLabel{}, + &model.ConfigLabel{}, + &model.UserContainer{}, + &model.UserDataset{}, + &model.UserProject{}, + &model.UserRole{}, + &model.RolePermission{}, + &model.UserPermission{}, + &model.UserTeam{}, + &model.DynamicConfig{}, + &model.ConfigHistory{}, + &model.Evaluation{}, + &model.System{}, + &model.SystemMetadata{}, + ); err != nil { + logrus.Fatalf("Failed to migrate database: %v", err) + } + + createDetectorViews(db) +} + +func addDetectorJoins(query *gorm.DB) *gorm.DB { + return query. + Joins(`JOIN ( + SELECT + e.id, + c.id AS algorithm_id, + e.datapack_id, + ROW_NUMBER() OVER ( + PARTITION BY c.id, e.datapack_id + ORDER BY e.created_at DESC, e.id DESC + ) as rn + FROM executions e + JOIN container_versions cv ON e.algorithm_version_id = cv.id + JOIN containers c ON c.id = cv.container_id + WHERE e.state = 2 AND e.status = 1 AND c.id = ? + ) er_ranked ON fi.id = er_ranked.datapack_id AND er_ranked.rn = 1`, 1). + Joins("JOIN detector_results dr ON er_ranked.id = dr.execution_id") +} + +func createDetectorViews(db *gorm.DB) { + _ = db.Migrator().DropView("fault_injection_no_issues") + _ = db.Migrator().DropView("fault_injection_with_issues") + + noIssuesQuery := addDetectorJoins(db.Table("fault_injections fi"). + Select(`DISTINCT + fi.id AS datapack_id, + fi.name AS name, + fi.fault_type AS fault_type, + fi.category AS category, + fi.engine_config AS engine_config, + l.label_key as label_key, + l.label_value as label_value, + fi.created_at`). + Joins("LEFT JOIN fault_injection_labels fil ON fil.fault_injection_id = fi.id"). + Joins("LEFT JOIN labels l ON fil.label_id = l.id"). + Group("fi.id, fi.name, fi.fault_type, fi.engine_config, fi.created_at, l.label_key, l.label_value"), + ).Where("dr.issues = '{}' OR dr.issues IS NULL") + if err := db.Migrator().CreateView("fault_injection_no_issues", gorm.ViewOption{Query: noIssuesQuery}); err != nil { + logrus.Errorf("failed to create fault_injection_no_issues view: %v", err) + } + + withIssuesQuery := addDetectorJoins(db.Table("fault_injections fi"). + Select(`DISTINCT + fi.id AS datapack_id, + fi.name AS name, + fi.fault_type AS fault_type, + fi.category AS category, + fi.engine_config AS engine_config, + l.label_key as label_key, + l.label_value as label_value, + fi.created_at, + dr.issues, + dr.abnormal_avg_duration, + dr.normal_avg_duration, + dr.abnormal_succ_rate, + dr.normal_succ_rate, + dr.abnormal_p99, + dr.normal_p99`). + Joins("LEFT JOIN tasks t ON t.id = fi.task_id"). + Joins("LEFT JOIN fault_injection_labels fil ON fil.fault_injection_id = fi.id"). + Joins("LEFT JOIN labels l ON fil.label_id = l.id"). + Group("fi.id, fi.name, fi.fault_type, fi.engine_config, fi.created_at, l.label_key, l.label_value, dr.issues, dr.abnormal_avg_duration, dr.normal_avg_duration, dr.abnormal_succ_rate, dr.normal_succ_rate, dr.abnormal_p99, dr.normal_p99"), + ).Where("dr.issues != '{}' AND dr.issues IS NOT NULL") + if err := db.Migrator().CreateView("fault_injection_with_issues", gorm.ViewOption{Query: withIssuesQuery}); err != nil { + logrus.Errorf("failed to create fault_injection_with_issues view: %v", err) + } +} diff --git a/src/infra/db/module.go b/src/infra/db/module.go new file mode 100644 index 00000000..80922dd2 --- /dev/null +++ b/src/infra/db/module.go @@ -0,0 +1,77 @@ +package db + +import ( + "context" + "log" + "os" + "time" + + "github.com/sirupsen/logrus" + "go.uber.org/fx" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "gorm.io/plugin/opentelemetry/tracing" +) + +var Module = fx.Module("db", + fx.Provide(NewGormDB), +) + +func NewGormDB(lc fx.Lifecycle) *gorm.DB { + db := connectWithRetry(NewDatabaseConfig("mysql")) + migrate(db) + + lc.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + sqlDB, err := db.DB() + if err != nil { + return err + } + logrus.Info("Closing database connection") + return sqlDB.Close() + }, + }) + + return db +} + +func connectWithRetry(dbConfig *DatabaseConfig) *gorm.DB { + const maxRetries = 3 + const retryDelay = 10 * time.Second + + dsn, err := dbConfig.ToDSN() + if err != nil { + logrus.Fatalf("Failed to construct DSN: %v", err) + } + + for i := 0; i <= maxRetries; i++ { + db, openErr := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), + logger.Config{ + SlowThreshold: time.Second, + LogLevel: logger.Warn, + IgnoreRecordNotFoundError: true, + Colorful: true, + }), + TranslateError: true, + }) + if openErr == nil { + logrus.Info("Successfully connected to the database") + if pluginErr := db.Use(tracing.NewPlugin()); pluginErr != nil { + panic(pluginErr) + } + return db + } + + err = openErr + logrus.Errorf("Failed to connect to database (attempt %d/%d): %v", i+1, maxRetries+1, err) + if i < maxRetries { + logrus.Infof("Retrying in %v...", retryDelay) + time.Sleep(retryDelay) + } + } + + logrus.Fatalf("Failed to connect to database after %d attempts: %v", maxRetries+1, err) + return nil +} diff --git a/src/infra/etcd/gateway.go b/src/infra/etcd/gateway.go new file mode 100644 index 00000000..79156a43 --- /dev/null +++ b/src/infra/etcd/gateway.go @@ -0,0 +1,126 @@ +package etcd + +import ( + "context" + "fmt" + "time" + + "aegis/config" + + "github.com/sirupsen/logrus" + clientv3 "go.etcd.io/etcd/client/v3" + "go.uber.org/fx" +) + +type Gateway struct { + client *clientv3.Client +} + +func NewGateway(client *clientv3.Client) *Gateway { + if client == nil { + client = newClient() + } + return &Gateway{client: client} +} + +func NewGatewayWithLifecycle(lc fx.Lifecycle) *Gateway { + gateway := NewGateway(nil) + + lc.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + logrus.Info("Closing etcd client") + return gateway.close() + }, + }) + + return gateway +} + +func (g *Gateway) Put(ctx context.Context, key, value string, ttl time.Duration) error { + client := g.clientOrInit() + if ttl > 0 { + lease, err := client.Grant(ctx, int64(ttl.Seconds())) + if err != nil { + return fmt.Errorf("failed to create lease: %w", err) + } + + if _, err = client.Put(ctx, key, value, clientv3.WithLease(lease.ID)); err != nil { + return fmt.Errorf("failed to put key with lease: %w", err) + } + return nil + } + + if _, err := client.Put(ctx, key, value); err != nil { + return fmt.Errorf("failed to put key: %w", err) + } + return nil +} + +func (g *Gateway) Get(ctx context.Context, key string) (string, error) { + resp, err := g.clientOrInit().Get(ctx, key) + if err != nil { + return "", fmt.Errorf("failed to get key: %w", err) + } + if len(resp.Kvs) == 0 { + return "", fmt.Errorf("key not found: %s", key) + } + return string(resp.Kvs[0].Value), nil +} + +func (g *Gateway) Delete(ctx context.Context, key string) error { + if _, err := g.clientOrInit().Delete(ctx, key); err != nil { + return fmt.Errorf("failed to delete key: %w", err) + } + return nil +} + +func (g *Gateway) Watch(ctx context.Context, key string, withPrefix bool) clientv3.WatchChan { + var opts []clientv3.OpOption + if withPrefix { + opts = append(opts, clientv3.WithPrefix()) + } + return g.clientOrInit().Watch(ctx, key, opts...) +} + +func (g *Gateway) clientOrInit() *clientv3.Client { + if g.client == nil { + g.client = newClient() + } + return g.client +} + +func (g *Gateway) close() error { + if g.client == nil { + return nil + } + return g.client.Close() +} + +func newClient() *clientv3.Client { + endpoints := config.GetStringSlice("etcd.endpoints") + if len(endpoints) == 0 { + endpoints = []string{"localhost:2379"} + logrus.Warn("etcd.endpoints not configured, using default: localhost:2379") + } + + logrus.Infof("Connecting to etcd endpoints: %v", endpoints) + + client, err := clientv3.New(clientv3.Config{ + Endpoints: endpoints, + DialTimeout: 5 * time.Second, + Username: config.GetString("etcd.username"), + Password: config.GetString("etcd.password"), + }) + if err != nil { + logrus.Fatalf("Failed to connect to etcd: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if _, err := client.Status(ctx, endpoints[0]); err != nil { + logrus.Fatalf("Failed to verify etcd connection: %v", err) + } + + logrus.Info("Successfully connected to etcd") + return client +} diff --git a/src/infra/etcd/module.go b/src/infra/etcd/module.go new file mode 100644 index 00000000..228c97ee --- /dev/null +++ b/src/infra/etcd/module.go @@ -0,0 +1,7 @@ +package etcd + +import "go.uber.org/fx" + +var Module = fx.Module("etcd", + fx.Provide(NewGatewayWithLifecycle), +) diff --git a/src/infra/harbor/gateway.go b/src/infra/harbor/gateway.go new file mode 100644 index 00000000..f690f914 --- /dev/null +++ b/src/infra/harbor/gateway.go @@ -0,0 +1,118 @@ +package harbor + +import ( + "context" + "fmt" + "sort" + "time" + + "aegis/config" + "aegis/consts" + + "github.com/goharbor/go-client/pkg/harbor" + "github.com/goharbor/go-client/pkg/sdk/v2.0/client/artifact" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" +) + +type Gateway struct { + namespace string + clientSet *harbor.ClientSet +} + +func NewGateway() *Gateway { + namespace := config.GetString("harbor.namespace") + return &Gateway{ + namespace: namespace, + clientSet: newClientSet(), + } +} + +func (g *Gateway) GetLatestTag(image string) (string, error) { + if g.clientSet == nil { + return "", fmt.Errorf("harbor client is not initialized") + } + + ctx, cancel := context.WithTimeout(context.Background(), consts.HarborTimeout*consts.HarborTimeUnit) + defer cancel() + + response, err := g.clientSet.V2().Artifact.ListArtifacts(ctx, &artifact.ListArtifactsParams{ + ProjectName: g.namespace, + RepositoryName: image, + Context: ctx, + }) + if err != nil { + return "", fmt.Errorf("failed to list artifacts: %v", err) + } + if len(response.Payload) == 0 { + return "", fmt.Errorf("no artifacts found for image %s", image) + } + + var allTags []*models.Tag + for _, item := range response.Payload { + if item.Tags != nil { + allTags = append(allTags, item.Tags...) + } + } + if len(allTags) == 0 { + return "", fmt.Errorf("no tags found for image %s", image) + } + + sort.Slice(allTags, func(i, j int) bool { + return time.Time(allTags[i].PushTime).After(time.Time(allTags[j].PushTime)) + }) + + return allTags[0].Name, nil +} + +func (g *Gateway) CheckImageExists(repository, tag string) (bool, error) { + if g.clientSet == nil { + return false, fmt.Errorf("harbor client is not initialized") + } + + ctx, cancel := context.WithTimeout(context.Background(), consts.HarborTimeout*consts.HarborTimeUnit) + defer cancel() + + response, err := g.clientSet.V2().Artifact.ListArtifacts(ctx, &artifact.ListArtifactsParams{ + ProjectName: g.namespace, + RepositoryName: repository, + Context: ctx, + }) + if err != nil || len(response.Payload) == 0 { + return false, nil + } + if tag == "" || tag == consts.DefaultContainerTag { + return true, nil + } + + for _, item := range response.Payload { + if item.Tags == nil { + continue + } + for _, currentTag := range item.Tags { + if currentTag.Name == tag { + return true, nil + } + } + } + + return false, nil +} + +func newClientSet() *harbor.ClientSet { + registry := config.GetString("harbor.registry") + username := config.GetString("harbor.username") + password := config.GetString("harbor.password") + harborURL := fmt.Sprintf("http://%s", registry) + + clientSet, err := harbor.NewClientSet(&harbor.ClientSetConfig{ + URL: harborURL, + Username: username, + Password: password, + Insecure: true, + }) + if err != nil { + return nil + } + + return clientSet +} diff --git a/src/infra/harbor/module.go b/src/infra/harbor/module.go new file mode 100644 index 00000000..b8ecfa6c --- /dev/null +++ b/src/infra/harbor/module.go @@ -0,0 +1,7 @@ +package harbor + +import "go.uber.org/fx" + +var Module = fx.Module("harbor", + fx.Provide(NewGateway), +) diff --git a/src/infra/helm/gateway.go b/src/infra/helm/gateway.go new file mode 100644 index 00000000..89731264 --- /dev/null +++ b/src/infra/helm/gateway.go @@ -0,0 +1,262 @@ +package helm + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "aegis/config" + "aegis/tracing" + + "github.com/sirupsen/logrus" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/repo" + "k8s.io/cli-runtime/pkg/genericclioptions" + "sigs.k8s.io/yaml" +) + +type Gateway struct{} + +func NewGateway() *Gateway { + return &Gateway{} +} + +func (g *Gateway) AddRepo(namespace, name, url string) error { + settings, _, err := newRuntime(namespace) + if err != nil { + return err + } + + repoFile := settings.RepositoryConfig + if err := os.MkdirAll(settings.RepositoryCache, 0755); err != nil && !os.IsExist(err) { + return fmt.Errorf("could not create repository cache directory: %w", err) + } + + data, err := os.ReadFile(repoFile) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("could not read repository file: %w", err) + } + + var repoFileModel repo.File + if err == nil { + if err := yaml.Unmarshal(data, &repoFileModel); err != nil { + return fmt.Errorf("cannot unmarshal repository file: %w", err) + } + } + + if repoFileModel.Has(name) { + if repoFileModel.Get(name).URL != url { + repoFileModel.Get(name).URL = url + } + if err := repoFileModel.WriteFile(repoFile, 0644); err != nil { + return fmt.Errorf("failed to write repository file: %w", err) + } + logrus.Infof("Updated repository %s URL to %s", name, url) + return nil + } + + entry := &repo.Entry{Name: name, URL: url} + repository, err := repo.NewChartRepository(entry, getter.All(settings)) + if err != nil { + return fmt.Errorf("failed to create chart repository: %w", err) + } + if _, err := repository.DownloadIndexFile(); err != nil { + return fmt.Errorf("looks like %q is not a valid chart repository or cannot be reached: %w", url, err) + } + + repoFileModel.Update(entry) + if err := repoFileModel.WriteFile(repoFile, 0644); err != nil { + return fmt.Errorf("failed to write repository file: %w", err) + } + + return nil +} + +func (g *Gateway) Install(ctx context.Context, namespace, releaseName, chartName, version string, values map[string]any, installTimeout, uninstallTimeout time.Duration) error { + settings, actionConfig, err := newRuntime(namespace) + if err != nil { + return err + } + + installed, err := g.isReleaseInstalled(actionConfig, releaseName) + if err != nil { + return err + } + if installed { + logrus.Infof("Uninstalling existing %s release", releaseName) + if err := g.uninstallRelease(actionConfig, releaseName, uninstallTimeout); err != nil { + return err + } + } else { + logrus.Infof("No existing %s release found", releaseName) + } + + return g.installRelease(ctx, settings, actionConfig, namespace, releaseName, chartName, version, values, installTimeout) +} + +func (g *Gateway) UpdateRepo(namespace, name string) error { + settings, _, err := newRuntime(namespace) + if err != nil { + return err + } + + data, err := os.ReadFile(settings.RepositoryConfig) + if err != nil { + return fmt.Errorf("could not read repository file: %w", err) + } + + var repoFileModel repo.File + if err := yaml.Unmarshal(data, &repoFileModel); err != nil { + return fmt.Errorf("cannot unmarshal repository file: %w", err) + } + + for _, entry := range repoFileModel.Repositories { + if name != "" && name != entry.Name { + continue + } + logrus.Infof("Updating repository %s", entry.Name) + repository, err := repo.NewChartRepository(entry, getter.All(settings)) + if err != nil { + return fmt.Errorf("failed to create chart repository for %s: %w", entry.Name, err) + } + if _, err := repository.DownloadIndexFile(); err != nil { + return fmt.Errorf("failed to update repository %s: %w", entry.Name, err) + } + } + + return nil +} + +func (g *Gateway) installRelease(ctx context.Context, settings *cli.EnvSettings, actionConfig *action.Configuration, namespace, releaseName, chartName, version string, vals map[string]any, timeout time.Duration) error { + return tracing.WithSpan(ctx, func(ctx context.Context) error { + now := time.Now() + defer func() { + log.Printf("InstallRelease took %s", time.Since(now)) + }() + + installAction := action.NewInstall(actionConfig) + installAction.ReleaseName = releaseName + installAction.Namespace = namespace + installAction.Wait = true + installAction.Timeout = timeout + installAction.CreateNamespace = true + installAction.Version = version + + chartPath, err := findCachedChart(settings, chartName) + if err != nil { + return err + } + if chartPath == "" { + logrus.Infof("Chart %s not found in cache, downloading...", chartName) + chartPath, err = installAction.LocateChart(chartName, settings) + if err != nil { + return fmt.Errorf("failed to locate chart %s: %w", chartName, err) + } + } else { + logrus.Infof("Using cached chart for %s at %s", chartName, chartPath) + } + + chart, err := loader.Load(chartPath) + if err != nil { + return fmt.Errorf("failed to load chart %s: %w", chartName, err) + } + if _, err := installAction.Run(chart, vals); err != nil { + return fmt.Errorf("failed to install release %s: %v", releaseName, err) + } + return nil + }) +} + +func (g *Gateway) isReleaseInstalled(actionConfig *action.Configuration, releaseName string) (bool, error) { + statusAction := action.NewStatus(actionConfig) + _, err := statusAction.Run(releaseName) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return false, nil + } + return false, fmt.Errorf("failed to get release status: %w", err) + } + return true, nil +} + +func (g *Gateway) uninstallRelease(actionConfig *action.Configuration, releaseName string, timeout time.Duration) error { + uninstallAction := action.NewUninstall(actionConfig) + uninstallAction.Wait = true + uninstallAction.Timeout = timeout + + _, err := uninstallAction.Run(releaseName) + if err != nil { + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "release: not found") { + logrus.Infof("Release %s is not installed, nothing to uninstall", releaseName) + return nil + } + return fmt.Errorf("failed to uninstall release %s: %w", releaseName, err) + } + return nil +} + +func newRuntime(namespace string) (*cli.EnvSettings, *action.Configuration, error) { + settings := cli.New() + settings.SetNamespace(namespace) + settings.Debug = config.GetBool("helm.debug") + + actionConfig := new(action.Configuration) + configFlags := genericclioptions.NewConfigFlags(true) + configFlags.Namespace = &namespace + if err := actionConfig.Init(configFlags, namespace, os.Getenv("HELM_DRIVER"), log.Printf); err != nil { + return nil, nil, fmt.Errorf("failed to initialize Helm action configuration: %w", err) + } + + return settings, actionConfig, nil +} + +func findCachedChart(settings *cli.EnvSettings, chartName string) (string, error) { + if _, err := os.Stat(chartName); err == nil { + abs, err := filepath.Abs(chartName) + if err == nil { + logrus.Infof("Found local chart at: %s", abs) + return abs, nil + } + } + + cacheDir := settings.RepositoryCache + var searchPatterns []string + if strings.Contains(chartName, "/") { + parts := strings.Split(chartName, "/") + if len(parts) == 2 { + chartBaseName := parts[1] + searchPatterns = append(searchPatterns, + fmt.Sprintf("%s/*/%s-*.tgz", cacheDir, chartBaseName), + fmt.Sprintf("%s/%s-*.tgz", cacheDir, chartBaseName), + ) + } + } else { + searchPatterns = append(searchPatterns, + fmt.Sprintf("%s/*/%s-*.tgz", cacheDir, chartName), + fmt.Sprintf("%s/%s-*.tgz", cacheDir, chartName), + ) + } + + for _, pattern := range searchPatterns { + matches, err := filepath.Glob(pattern) + if err == nil && len(matches) > 0 { + logrus.Infof("Found cached chart at: %s", matches[0]) + return matches[0], nil + } + } + + localChartDir := filepath.Join(cacheDir, chartName) + if stat, err := os.Stat(localChartDir); err == nil && stat.IsDir() { + logrus.Infof("Found cached chart directory at: %s", localChartDir) + return localChartDir, nil + } + + return "", nil +} diff --git a/src/infra/helm/module.go b/src/infra/helm/module.go new file mode 100644 index 00000000..3b6607a7 --- /dev/null +++ b/src/infra/helm/module.go @@ -0,0 +1,7 @@ +package helm + +import "go.uber.org/fx" + +var Module = fx.Module("helm", + fx.Provide(NewGateway), +) diff --git a/src/client/k8s/controller.go b/src/infra/k8s/controller.go similarity index 99% rename from src/client/k8s/controller.go rename to src/infra/k8s/controller.go index 6322f1ca..c7c9f163 100644 --- a/src/client/k8s/controller.go +++ b/src/infra/k8s/controller.go @@ -77,7 +77,7 @@ type Controller struct { cancelFunc context.CancelFunc } -func NewController() *Controller { +func newController() *Controller { crdInformers := make(map[string]map[schema.GroupVersionResource]cache.SharedIndexInformer) activeNamespaces := make(map[string]bool) @@ -86,7 +86,7 @@ func NewController() *Controller { } platformFactory := informers.NewSharedInformerFactoryWithOptions( - GetK8sClient(), + getK8sClient(), resyncPeriod, informers.WithNamespace(config.GetString("k8s.namespace")), informers.WithTweakListOptions(tweakListOptions), @@ -168,7 +168,7 @@ func (c *Controller) AddNamespaceInformers(namespaces []string) error { // Create new factory for this namespace logrus.Debugf("Creating new CRD informers for namespace: %s", namespace) chaosFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory( - GetK8sDynamicClient(), + getK8sDynamicClient(), resyncPeriod, namespace, tweakListOptions, @@ -534,7 +534,7 @@ func (c *Controller) genPodEventHandlerFuncs() cache.ResourceEventHandlerFuncs { for _, reason := range podReasons { if checkPodReason(newPod, reason) { - job, err := GetJob(c.ctx, newPod.Namespace, jobOwnerRef.Name) + job, err := getJob(c.ctx, newPod.Namespace, jobOwnerRef.Name) if err != nil { logrus.WithField("job_name", jobOwnerRef.Name).Error(err) } @@ -626,7 +626,7 @@ func (c *Controller) checkRecoveryStatus(item QueueItem) error { "name": item.Name, }) - obj, err := GetK8sDynamicClient(). + obj, err := getK8sDynamicClient(). Resource(*item.GVR). Namespace(item.Namespace). Get(context.Background(), item.Name, metav1.GetOptions{}) @@ -810,7 +810,7 @@ func checkPodReason(pod *corev1.Pod, reason string) bool { func handlePodError(ctx context.Context, pod *corev1.Pod, job *batchv1.Job, reason string) { // Get Pod events - events, err := GetK8sClient().CoreV1().Events(pod.Namespace).List(ctx, metav1.ListOptions{ + events, err := getK8sClient().CoreV1().Events(pod.Namespace).List(ctx, metav1.ListOptions{ FieldSelector: fmt.Sprintf("involvedObject.name=%s", pod.Name), }) if err != nil { diff --git a/src/client/k8s/crd.go b/src/infra/k8s/crd.go similarity index 85% rename from src/client/k8s/crd.go rename to src/infra/k8s/crd.go index 15fee929..25585a90 100644 --- a/src/client/k8s/crd.go +++ b/src/infra/k8s/crd.go @@ -27,7 +27,7 @@ func deleteCRD(ctx context.Context, gvr *schema.GroupVersionResource, namespace, }) // 1. Check if resource exists - obj, err := k8sDynamicClient.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) + obj, err := getK8sDynamicClient().Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) if err != nil { if errors.IsNotFound(err) { return nil @@ -42,7 +42,7 @@ func deleteCRD(ctx context.Context, gvr *schema.GroupVersionResource, namespace, } // 3. Execute deletion (idempotent operation) - _, err = k8sDynamicClient.Resource(*gvr).Namespace(namespace).Patch( + _, err = getK8sDynamicClient().Resource(*gvr).Namespace(namespace).Patch( timeoutCtx, name, types.MergePatchType, @@ -59,7 +59,7 @@ func deleteCRD(ctx context.Context, gvr *schema.GroupVersionResource, namespace, logEntry.Info("Successfully cleared finalizers") - err = k8sDynamicClient.Resource(*gvr).Namespace(namespace).Delete(ctx, name, deleteOptions) + err = getK8sDynamicClient().Resource(*gvr).Namespace(namespace).Delete(ctx, name, deleteOptions) if err != nil && !errors.IsNotFound(err) { if timeoutCtx.Err() != nil { return fmt.Errorf("timeout while deleting CRD %s/%s: %v", namespace, name, timeoutCtx.Err()) diff --git a/src/infra/k8s/gateway.go b/src/infra/k8s/gateway.go new file mode 100644 index 00000000..0b866aba --- /dev/null +++ b/src/infra/k8s/gateway.go @@ -0,0 +1,142 @@ +package k8s + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + + "aegis/consts" + + "github.com/sirupsen/logrus" + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +type Gateway struct { + controller *Controller +} + +var ( + k8sRestConfig *rest.Config + k8sClient *kubernetes.Clientset + k8sDynamicClient *dynamic.DynamicClient + k8sController *Controller + + k8sRestConfigOnce sync.Once + k8sClientOnce sync.Once + k8sDynamicClientOnce sync.Once + controllerOnce sync.Once +) + +func NewGateway(controller *Controller) *Gateway { + if controller == nil { + controller = getK8sController() + } + return &Gateway{controller: controller} +} + +func (g *Gateway) GetVolumeMountConfigMap() (map[consts.VolumeMountName]VolumeMountConfig, error) { + return getVolumeMountConfigMap() +} + +func (g *Gateway) CreateJob(ctx context.Context, jobConfig *JobConfig) error { + return createJob(ctx, jobConfig) +} + +func (g *Gateway) GetJob(ctx context.Context, namespace, jobName string) (*batchv1.Job, error) { + return getJob(ctx, namespace, jobName) +} + +func (g *Gateway) WaitForJobCompletion(ctx context.Context, namespace, jobName string) error { + return waitForJobCompletion(ctx, namespace, jobName) +} + +func (g *Gateway) GetJobPodLogs(ctx context.Context, namespace, jobName string) (map[string][]string, error) { + return getJobPodLogs(ctx, namespace, jobName) +} + +func (g *Gateway) DeleteJob(ctx context.Context, namespace, jobName string) error { + return deleteJob(ctx, namespace, jobName) +} + +func (g *Gateway) CheckHealth(ctx context.Context) error { + if getK8sRestConfig() == nil { + return fmt.Errorf("kubernetes config not available") + } + client := getK8sClient() + if client == nil { + return fmt.Errorf("kubernetes client not available") + } + if getK8sDynamicClient() == nil { + return fmt.Errorf("kubernetes dynamic client not available") + } + + if _, err := client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{Limit: 1}); err != nil { + return fmt.Errorf("kubernetes API request failed: %w", err) + } + return nil +} + +func getK8sClient() *kubernetes.Clientset { + k8sClientOnce.Do(func() { + restConfig := getK8sRestConfig() + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + logrus.Fatalf("failed to create Kubernetes clientset: %v", err) + } + + k8sClient = clientset + }) + return k8sClient +} + +func getK8sDynamicClient() *dynamic.DynamicClient { + k8sDynamicClientOnce.Do(func() { + restConfig := getK8sRestConfig() + dynamicClient, err := dynamic.NewForConfig(restConfig) + if err != nil { + logrus.Fatalf("failed to create Kubernetes dynamic client: %v", err) + } + + k8sDynamicClient = dynamicClient + }) + return k8sDynamicClient +} + +func getK8sRestConfig() *rest.Config { + k8sRestConfigOnce.Do(func() { + restConfig, err := rest.InClusterConfig() + if err == nil { + logrus.Info("Successfully loaded In-Cluster Kubernetes configuration.") + k8sRestConfig = restConfig + logrus.Infof("Using Kubernetes Context: %s", "In-Cluster") + return + } + + logrus.Warn("In-cluster config not found, trying kubeconfig file") + kubeconfig := filepath.Join(os.Getenv("HOME"), ".kube", "config") + config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + logrus.Fatalf("Failed to load Kubernetes config: %v", err) + } + if config == nil { + logrus.Fatalf("Failed to establish Kubernetes REST config: Neither In-Cluster nor external Kubeconfig available.") + } + + k8sRestConfig = config + }) + return k8sRestConfig +} + +func getK8sController() *Controller { + controllerOnce.Do(func() { + k8sController = newController() + }) + return k8sController +} diff --git a/src/client/k8s/job.go b/src/infra/k8s/job.go similarity index 80% rename from src/client/k8s/job.go rename to src/infra/k8s/job.go index 1c9265cf..0ce2e7a7 100644 --- a/src/client/k8s/job.go +++ b/src/infra/k8s/job.go @@ -113,15 +113,31 @@ func (v *VolumeMountConfig) GetVolume() corev1.Volume { return volume } -func CreateJob(ctx context.Context, jobConfig *JobConfig) error { +func createJob(ctx context.Context, jobConfig *JobConfig) error { return tracing.WithSpan(ctx, func(ctx context.Context) error { span := trace.SpanFromContext(ctx) - jobConfig.Namespace = config.GetString("k8s.namespace") - jobConfig.BackoffLimit = int32(0) - jobConfig.Parallelism = int32(1) - jobConfig.Completions = int32(1) - jobConfig.RestartPolicy = corev1.RestartPolicyNever + if jobConfig.Namespace == "" { + jobConfig.Namespace = config.GetString("k8s.namespace") + } + if jobConfig.BackoffLimit == 0 { + jobConfig.BackoffLimit = int32(0) + } + if jobConfig.Parallelism == 0 { + jobConfig.Parallelism = int32(1) + } + if jobConfig.Completions == 0 { + jobConfig.Completions = int32(1) + } + if jobConfig.RestartPolicy == "" { + jobConfig.RestartPolicy = corev1.RestartPolicyNever + } + if jobConfig.Annotations == nil { + jobConfig.Annotations = make(map[string]string) + } + if jobConfig.Labels == nil { + jobConfig.Labels = make(map[string]string) + } volumeMounts := []corev1.VolumeMount{} volumes := []corev1.Volume{} @@ -174,7 +190,7 @@ func CreateJob(ctx context.Context, jobConfig *JobConfig) error { }, } - _, err := k8sClient.BatchV1().Jobs(jobConfig.Namespace).Create(ctx, job, metav1.CreateOptions{}) + _, err := getK8sClient().BatchV1().Jobs(jobConfig.Namespace).Create(ctx, job, metav1.CreateOptions{}) if err != nil { span.RecordError(err) span.AddEvent("failed to create job") @@ -186,7 +202,7 @@ func CreateJob(ctx context.Context, jobConfig *JobConfig) error { } // GetVolumeMountConfigMap retrieves volume mount configurations from the application config. -func GetVolumeMountConfigMap() (map[consts.VolumeMountName]VolumeMountConfig, error) { +func getVolumeMountConfigMap() (map[consts.VolumeMountName]VolumeMountConfig, error) { volumeMountConfigMapOnce.Do(func() { cfgMap := config.GetMap("k8s.job.volume_mount") if len(cfgMap) == 0 { @@ -226,7 +242,7 @@ func deleteJob(ctx context.Context, namespace, name string) error { logEntry := logrus.WithField("namespace", namespace).WithField("name", name) // 1. First check if Job exists and its status - job, err := k8sClient.BatchV1().Jobs(namespace).Get(ctx, name, metav1.GetOptions{}) + job, err := getK8sClient().BatchV1().Jobs(namespace).Get(ctx, name, metav1.GetOptions{}) if err != nil { if errors.IsNotFound(err) { return nil @@ -241,7 +257,7 @@ func deleteJob(ctx context.Context, namespace, name string) error { } // 3. Execute deletion (idempotent operation) - err = k8sClient.BatchV1().Jobs(namespace).Delete(ctx, name, deleteOptions) + err = getK8sClient().BatchV1().Jobs(namespace).Delete(ctx, name, deleteOptions) if err != nil { if errors.IsNotFound(err) { return nil @@ -253,16 +269,16 @@ func deleteJob(ctx context.Context, namespace, name string) error { return nil } -func GetJob(ctx context.Context, namespace, jobName string) (*batchv1.Job, error) { - job, err := k8sClient.BatchV1().Jobs(namespace).Get(ctx, jobName, metav1.GetOptions{}) +func getJob(ctx context.Context, namespace, jobName string) (*batchv1.Job, error) { + job, err := getK8sClient().BatchV1().Jobs(namespace).Get(ctx, jobName, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("failed to get job: %v", err) } return job, nil } -func GetJobPodLogs(ctx context.Context, namespace, jobName string) (map[string][]string, error) { - podList, err := k8sClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ +func getJobPodLogs(ctx context.Context, namespace, jobName string) (map[string][]string, error) { + podList, err := getK8sClient().CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ LabelSelector: fmt.Sprintf("%s=%s", consts.JobLabelName, jobName), }) if err != nil { @@ -279,7 +295,7 @@ func GetJobPodLogs(ctx context.Context, namespace, jobName string) (map[string][ continue } - req := k8sClient.CoreV1().Pods(namespace).GetLogs(pod.Name, &corev1.PodLogOptions{}) + req := getK8sClient().CoreV1().Pods(namespace).GetLogs(pod.Name, &corev1.PodLogOptions{}) logStream, err := req.Stream(ctx) if err != nil { return nil, fmt.Errorf("failed to get logs for pod %s: %v", pod.Name, err) @@ -322,20 +338,32 @@ func isPodReadyForLogs(pod corev1.Pod) bool { } } -func WaitForJobCompletion(ctx context.Context, namespace, jobName string) error { +func waitForJobCompletion(ctx context.Context, namespace, jobName string) error { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { - job, err := k8sClient.BatchV1().Jobs(namespace).Get(ctx, jobName, metav1.GetOptions{}) + job, err := getK8sClient().BatchV1().Jobs(namespace).Get(ctx, jobName, metav1.GetOptions{}) if err != nil { return fmt.Errorf("failed to get job: %v", err) } if job.Status.Succeeded > 0 { logrus.Info("Job completed successfully!") - break + return nil + } + + for _, condition := range job.Status.Conditions { + if condition.Type == batchv1.JobFailed && condition.Status == corev1.ConditionTrue { + return fmt.Errorf("job %s failed: %s", jobName, condition.Message) + } } logrus.Info("Waiting for job to complete...") - time.Sleep(2 * time.Second) + select { + case <-ctx.Done(): + return fmt.Errorf("waiting for job completion: %w", ctx.Err()) + case <-ticker.C: + } } - return nil } diff --git a/src/infra/k8s/k8s_test.go b/src/infra/k8s/k8s_test.go new file mode 100644 index 00000000..78e80496 --- /dev/null +++ b/src/infra/k8s/k8s_test.go @@ -0,0 +1,186 @@ +package k8s + +import ( + "aegis/config" + "aegis/utils" + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/k0kubun/pp/v3" + corev1 "k8s.io/api/core/v1" +) + +const ( + runK8sIntegrationEnv = "RUN_K8S_INTEGRATION" + runK8sIntegrationNamespaceEnv = "RUN_K8S_INTEGRATION_NAMESPACE" + runK8sIntegrationImageEnv = "RUN_K8S_INTEGRATION_IMAGE" + runK8sIntegrationKeepJobEnv = "RUN_K8S_INTEGRATION_KEEP_JOB" +) + +type integrationConfig struct { + namespace string + image string + keepJob bool +} + +func TestGetVolumeMountConfigs(t *testing.T) { + config.Init("../..") + + volumeMountConfigs := make([]VolumeMountConfig, 0) + mapData := config.GetMap("k8s.job.volume_mount") + for _, cfgData := range mapData { + cfg, err := utils.ConvertToType[VolumeMountConfig](cfgData) + if err != nil { + t.Errorf("invalid volume mount config %v: %v", cfgData, err) + } + + volumeMountConfigs = append(volumeMountConfigs, cfg) + } + + volumeMounts := []corev1.VolumeMount{} + volumes := []corev1.Volume{} + for _, cfg := range volumeMountConfigs { + volumeMounts = append(volumeMounts, cfg.GetVolumeMount()) + volumes = append(volumes, cfg.GetVolume()) + } + + pp.Println(volumeMountConfigs) //nolint:errcheck + pp.Println(volumeMounts) //nolint:errcheck + pp.Println(volumes) //nolint:errcheck +} + +func requireIntegrationConfig(t *testing.T) integrationConfig { + t.Helper() + + if os.Getenv(runK8sIntegrationEnv) != "1" { + t.Skipf( + "set %s=1 to run Kubernetes integration test (optional overrides: %s, %s, %s)", + runK8sIntegrationEnv, + runK8sIntegrationNamespaceEnv, + runK8sIntegrationImageEnv, + runK8sIntegrationKeepJobEnv, + ) + } + + config.Init("../..") + + namespace := config.GetString("k8s.namespace") + if override := strings.TrimSpace(os.Getenv(runK8sIntegrationNamespaceEnv)); override != "" { + namespace = override + } + if namespace == "" { + t.Fatal("kubernetes integration namespace is empty; set k8s.namespace or RUN_K8S_INTEGRATION_NAMESPACE") + } + + image := strings.TrimSpace(os.Getenv(runK8sIntegrationImageEnv)) + if image == "" { + image = "busybox:1.36" + } + + return integrationConfig{ + namespace: namespace, + image: image, + keepJob: os.Getenv(runK8sIntegrationKeepJobEnv) == "1", + } +} + +func TestK8sGatewayJobLifecycleIntegration(t *testing.T) { + cfg := requireIntegrationConfig(t) + + gateway := NewGateway(nil) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + if err := gateway.CheckHealth(ctx); err != nil { + t.Fatalf("kubernetes health precheck failed: %v", err) + } + + jobName := fmt.Sprintf("aegis-k8s-integration-%d", time.Now().UnixNano()) + command := []string{"sh", "-c", "for i in $(seq 1 5); do echo \"Log line $i\"; sleep 1; done"} + restartPolicy := corev1.RestartPolicyNever + backoffLimit := int32(1) + parallelism := int32(1) + completions := int32(1) + + envVars := []corev1.EnvVar{ + {Name: "AEGIS_K8S_INTEGRATION", Value: "true"}, + } + + if !cfg.keepJob { + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + _ = gateway.DeleteJob(cleanupCtx, cfg.namespace, jobName) + }) + } + + t.Logf("running Kubernetes integration against namespace=%s image=%s", cfg.namespace, cfg.image) + + if err := gateway.CreateJob(ctx, &JobConfig{ + Namespace: cfg.namespace, + JobName: jobName, + Image: cfg.image, + Command: command, + RestartPolicy: restartPolicy, + BackoffLimit: backoffLimit, + Parallelism: parallelism, + Completions: completions, + EnvVars: envVars, + }); err != nil { + t.Fatalf("create job failed: %v", err) + } + t.Logf("job %s created successfully", jobName) + + job, err := gateway.GetJob(ctx, cfg.namespace, jobName) + if err != nil { + t.Fatalf("get job failed: %v", err) + } + if job.Name != jobName { + t.Errorf("expected job name %s, got %s", jobName, job.Name) + } + + t.Logf("waiting for job %s to complete", jobName) + if err := gateway.WaitForJobCompletion(ctx, cfg.namespace, jobName); err != nil { + t.Fatalf("wait for job completion failed: %v", err) + } + t.Logf("job %s completed successfully", jobName) + + logs, err := gateway.GetJobPodLogs(ctx, cfg.namespace, jobName) + if err != nil { + t.Fatalf("get job pod logs failed: %v", err) + } + if len(logs) == 0 { + t.Fatalf("expected logs for job %s, got none", jobName) + } + + foundLogLine := false + for podName, podLogs := range logs { + t.Logf("pod %s emitted %d log lines", podName, len(podLogs)) + for _, line := range podLogs { + if strings.Contains(line, "Log line") { + foundLogLine = true + break + } + } + if foundLogLine { + break + } + } + if !foundLogLine { + t.Fatalf("expected job logs to include test output, got %#v", logs) + } + + if cfg.keepJob { + t.Logf("keeping job %s because %s=1", jobName, runK8sIntegrationKeepJobEnv) + return + } + + if err := gateway.DeleteJob(ctx, cfg.namespace, jobName); err != nil { + t.Fatalf("delete job failed: %v", err) + } + t.Logf("job %s and its associated pods deleted successfully", jobName) +} diff --git a/src/infra/k8s/module.go b/src/infra/k8s/module.go new file mode 100644 index 00000000..34673da1 --- /dev/null +++ b/src/infra/k8s/module.go @@ -0,0 +1,21 @@ +package k8s + +import ( + "k8s.io/client-go/rest" + + "go.uber.org/fx" +) + +var Module = fx.Module("k8s", + fx.Provide(ProvideController), + fx.Provide(NewGateway), + fx.Provide(ProvideRestConfig), +) + +func ProvideController() *Controller { + return getK8sController() +} + +func ProvideRestConfig() *rest.Config { + return getK8sRestConfig() +} diff --git a/src/infra/logger/module.go b/src/infra/logger/module.go new file mode 100644 index 00000000..b4033dee --- /dev/null +++ b/src/infra/logger/module.go @@ -0,0 +1,37 @@ +package logger + +import ( + "fmt" + "path" + "runtime" + "sync" + + nested "github.com/antonfisher/nested-logrus-formatter" + "github.com/sirupsen/logrus" + "go.uber.org/fx" +) + +var ( + configureOnce sync.Once + + Module = fx.Module("logger", + fx.Invoke(Configure), + ) +) + +func Configure() { + configureOnce.Do(func() { + logrus.SetReportCaller(true) + logrus.SetFormatter(&nested.Formatter{ + CustomCallerFormatter: func(f *runtime.Frame) string { + filename := path.Base(f.File) + return fmt.Sprintf(" (%s:%d)", filename, f.Line) + }, + FieldsOrder: []string{"component", "category"}, + HideKeys: true, + TimestampFormat: "2006-01-02 15:04:05", + }) + logrus.SetLevel(logrus.InfoLevel) + logrus.Info("Logger initialized") + }) +} diff --git a/src/client/loki.go b/src/infra/loki/client.go similarity index 69% rename from src/client/loki.go rename to src/infra/loki/client.go index 51f9e565..9314b933 100644 --- a/src/client/loki.go +++ b/src/infra/loki/client.go @@ -1,4 +1,4 @@ -package client +package loki import ( "context" @@ -16,34 +16,30 @@ import ( "github.com/sirupsen/logrus" ) -// LokiClient wraps the Loki HTTP API for querying historical logs -type LokiClient struct { +type Client struct { address string httpClient *http.Client } -// QueryOpts defines options for Loki log queries type QueryOpts struct { - Start time.Time // Query start time (default: 1 hour ago) - End time.Time // Query end time (default: now) - Limit int // Max entries to return (default: 5000) - Direction string // "forward" (chronological) or "backward" + Start time.Time + End time.Time + Limit int + Direction string } -// lokiQueryRangeResponse represents the Loki query_range API response -type lokiQueryRangeResponse struct { +type queryRangeResponse struct { Status string `json:"status"` Data struct { ResultType string `json:"resultType"` Result []struct { Stream map[string]string `json:"stream"` - Values [][]string `json:"values"` // [[nanosecond_timestamp, log_line], ...] + Values [][]string `json:"values"` } `json:"result"` } `json:"data"` } -// NewLokiClient creates a new Loki client using configuration -func NewLokiClient() *LokiClient { +func NewClient() *Client { address := config.GetString("loki.address") timeout := config.GetString("loki.timeout") timeoutDuration := 10 * time.Second @@ -53,7 +49,7 @@ func NewLokiClient() *LokiClient { } } - return &LokiClient{ + return &Client{ address: address, httpClient: &http.Client{ Timeout: timeoutDuration, @@ -61,13 +57,11 @@ func NewLokiClient() *LokiClient { } } -// QueryJobLogs queries historical job logs from Loki by task_id -func (c *LokiClient) QueryJobLogs(ctx context.Context, taskID string, opts QueryOpts) ([]dto.LogEntry, error) { +func (c *Client) QueryJobLogs(ctx context.Context, taskID string, opts QueryOpts) ([]dto.LogEntry, error) { if taskID == "" { return nil, fmt.Errorf("taskID is required") } - // Apply defaults if opts.Start.IsZero() { opts.Start = time.Now().Add(-1 * time.Hour) } @@ -86,11 +80,8 @@ func (c *LokiClient) QueryJobLogs(ctx context.Context, taskID string, opts Query opts.Direction = "forward" } - // Build LogQL query - // Use Structured Metadata filter since task_id is stored as structured metadata in Loki logQL := fmt.Sprintf(`{app="rcabench"} | task_id=%q`, taskID) - // Build request URL params := url.Values{} params.Set("query", logQL) params.Set("start", strconv.FormatInt(opts.Start.UnixNano(), 10)) @@ -99,7 +90,6 @@ func (c *LokiClient) QueryJobLogs(ctx context.Context, taskID string, opts Query params.Set("direction", opts.Direction) reqURL := fmt.Sprintf("%s/loki/api/v1/query_range?%s", c.address, params.Encode()) - logrus.Infof("Loki query: url=%s", reqURL) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) @@ -123,44 +113,36 @@ func (c *LokiClient) QueryJobLogs(ctx context.Context, taskID string, opts Query return nil, fmt.Errorf("failed to read Loki response: %w", err) } - var lokiResp lokiQueryRangeResponse + var lokiResp queryRangeResponse if err := json.Unmarshal(body, &lokiResp); err != nil { return nil, fmt.Errorf("failed to parse Loki response: %w", err) } - if lokiResp.Status != "success" { return nil, fmt.Errorf("loki query status: %s", lokiResp.Status) } - if len(lokiResp.Data.Result) == 0 { logrus.Warnf("Loki returned 0 streams for task %s, raw response: %s", taskID, string(body)) } - // Convert Loki results to LogEntry var entries []dto.LogEntry for _, result := range lokiResp.Data.Result { for _, value := range result.Values { if len(value) < 2 { continue } - - // Parse nanosecond timestamp nsec, err := strconv.ParseInt(value[0], 10, 64) if err != nil { logrus.Warnf("Loki: invalid timestamp %s: %v", value[0], err) continue } - entry := dto.LogEntry{ + entries = append(entries, dto.LogEntry{ Timestamp: time.Unix(0, nsec), Line: value[1], TaskID: taskID, - // Extract additional metadata from stream labels if available - TraceID: result.Stream["trace_id"], - JobID: result.Stream["job_id"], - } - - entries = append(entries, entry) + TraceID: result.Stream["trace_id"], + JobID: result.Stream["job_id"], + }) } } diff --git a/src/infra/loki/module.go b/src/infra/loki/module.go new file mode 100644 index 00000000..a4760d62 --- /dev/null +++ b/src/infra/loki/module.go @@ -0,0 +1,7 @@ +package loki + +import "go.uber.org/fx" + +var Module = fx.Module("loki", + fx.Provide(NewClient), +) diff --git a/src/infra/redis/gateway.go b/src/infra/redis/gateway.go new file mode 100644 index 00000000..7fdccef7 --- /dev/null +++ b/src/infra/redis/gateway.go @@ -0,0 +1,374 @@ +package redis + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "aegis/config" + "aegis/consts" + + "github.com/redis/go-redis/v9" + "github.com/sirupsen/logrus" + "go.uber.org/fx" +) + +type Gateway struct { + client *redis.Client +} + +func NewGateway(client *redis.Client) *Gateway { + if client == nil { + client = newClient() + } + return &Gateway{client: client} +} + +func NewGatewayWithLifecycle(lc fx.Lifecycle) *Gateway { + gateway := NewGateway(nil) + + lc.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + logrus.Info("Closing Redis client") + return gateway.close() + }, + }) + + return gateway +} + +func (g *Gateway) clientOrInit() *redis.Client { + if g.client == nil { + g.client = newClient() + } + return g.client +} + +func (g *Gateway) close() error { + if g.client == nil { + return nil + } + return g.client.Close() +} + +func (g *Gateway) CheckCachedField(ctx context.Context, key, field string) bool { + exists, err := g.clientOrInit().HExists(ctx, key, field).Result() + if err != nil { + logrus.Errorf("failed to check if field %s exists in cache: %v", field, err) + return false + } + return exists +} + +func (g *Gateway) GetHashField(ctx context.Context, key, field string, target any) error { + itemJSON, err := g.clientOrInit().HGet(ctx, key, field).Result() + if err != nil && err != redis.Nil { + return fmt.Errorf("failed to get hash field %s from key %s: %w", field, key, err) + } + if itemJSON == "" { + logrus.Warnf("field %s not found in cache key %s", field, key) + return nil + } + if err := json.Unmarshal([]byte(itemJSON), target); err != nil { + return fmt.Errorf("failed to unmarshal cached items for field %s: %w", field, err) + } + return nil +} + +func (g *Gateway) SetHashField(ctx context.Context, key, field string, item any) error { + itemJSON, err := json.Marshal(item) + if err != nil { + return fmt.Errorf("failed to marshal items to JSON: %w", err) + } + if _, err := g.clientOrInit().Pipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.HSet(ctx, key, field, itemJSON) + return nil + }); err != nil { + return fmt.Errorf("failed to set hash field %s in key %s: %w", field, key, err) + } + return nil +} + +func (g *Gateway) ListRange(ctx context.Context, key string) ([]string, error) { + result, err := g.clientOrInit().LRange(ctx, key, 0, -1).Result() + if err != nil { + return nil, fmt.Errorf("failed to get list range for key '%s': %w", key, err) + } + return result, nil +} + +func (g *Gateway) ListLength(ctx context.Context, key string) (int64, error) { + result, err := g.clientOrInit().LLen(ctx, key).Result() + if err != nil { + return 0, fmt.Errorf("failed to get list length for key '%s': %w", key, err) + } + return result, nil +} + +// ScanKeys iterates through keys matching the given pattern and returns all of them. +func (g *Gateway) ScanKeys(ctx context.Context, pattern string) ([]string, error) { + iter := g.clientOrInit().Scan(ctx, 0, pattern, 0).Iterator() + var keys []string + for iter.Next(ctx) { + keys = append(keys, iter.Val()) + } + if err := iter.Err(); err != nil { + return nil, fmt.Errorf("failed to scan keys %q: %w", pattern, err) + } + return keys, nil +} + +// DeleteKey removes a single key; returns 1 if it existed, 0 otherwise. +func (g *Gateway) DeleteKey(ctx context.Context, key string) (int64, error) { + n, err := g.clientOrInit().Del(ctx, key).Result() + if err != nil { + return 0, fmt.Errorf("failed to del key %q: %w", key, err) + } + return n, nil +} + +func (g *Gateway) SetMembers(ctx context.Context, key string) ([]string, error) { + result, err := g.clientOrInit().SMembers(ctx, key).Result() + if err != nil { + return nil, fmt.Errorf("failed to get set members for key '%s': %w", key, err) + } + return result, nil +} + +func (g *Gateway) Exists(ctx context.Context, key string) (bool, error) { + result, err := g.clientOrInit().Exists(ctx, key).Result() + if err != nil { + return false, fmt.Errorf("failed to check key '%s': %w", key, err) + } + return result > 0, nil +} + +func (g *Gateway) HashGetAll(ctx context.Context, key string) (map[string]string, error) { + result, err := g.clientOrInit().HGetAll(ctx, key).Result() + if err != nil { + return nil, fmt.Errorf("failed to get hash fields for key '%s': %w", key, err) + } + return result, nil +} + +func (g *Gateway) HashGet(ctx context.Context, key, field string) (string, error) { + result, err := g.clientOrInit().HGet(ctx, key, field).Result() + if err != nil { + return "", err + } + return result, nil +} + +func (g *Gateway) HashSet(ctx context.Context, key string, values map[string]any) error { + if len(values) == 0 { + return nil + } + if err := g.clientOrInit().HSet(ctx, key, values).Err(); err != nil { + return fmt.Errorf("failed to set hash fields for key '%s': %w", key, err) + } + return nil +} + +func (g *Gateway) SeedNamespaceState(ctx context.Context, namespaceKey, namespace string, endTime int64, status int) error { + _, err := g.clientOrInit().Pipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.SAdd(ctx, consts.NamespacesKey, namespace) + pipe.HSetNX(ctx, namespaceKey, "end_time", endTime) + pipe.HSetNX(ctx, namespaceKey, "trace_id", "") + pipe.HSetNX(ctx, namespaceKey, "status", status) + return nil + }) + if err != nil { + return fmt.Errorf("failed to seed namespace state for '%s': %w", namespace, err) + } + return nil +} + +func (g *Gateway) ZRangeByScoreWithScores(ctx context.Context, key string, limit int64) ([]redis.Z, error) { + if limit <= 0 { + return nil, fmt.Errorf("limit must be a positive number") + } + results, err := g.clientOrInit().ZRangeByScoreWithScores(ctx, key, &redis.ZRangeBy{ + Min: "-inf", + Max: "+inf", + Offset: 0, + Count: limit, + }).Result() + if err != nil { + return nil, fmt.Errorf("failed to get scheduled tasks from key '%s': %w", key, err) + } + return results, nil +} + +func (g *Gateway) ZRangeByScore(ctx context.Context, key, min, max string) ([]string, error) { + result, err := g.clientOrInit().ZRangeByScore(ctx, key, &redis.ZRangeBy{ + Min: min, + Max: max, + }).Result() + if err != nil { + return nil, fmt.Errorf("failed to get sorted set range for key '%s': %w", key, err) + } + return result, nil +} + +func (g *Gateway) SortedSetCard(ctx context.Context, key string) (int64, error) { + result, err := g.clientOrInit().ZCard(ctx, key).Result() + if err != nil { + return 0, fmt.Errorf("failed to get sorted set size for key '%s': %w", key, err) + } + return result, nil +} + +func (g *Gateway) ZAdd(ctx context.Context, key string, member redis.Z) error { + if err := g.clientOrInit().ZAdd(ctx, key, member).Err(); err != nil { + return fmt.Errorf("failed to add sorted set member for key '%s': %w", key, err) + } + return nil +} + +func (g *Gateway) ZRemRangeByScore(ctx context.Context, key, min, max string) error { + if err := g.clientOrInit().ZRemRangeByScore(ctx, key, min, max).Err(); err != nil { + return fmt.Errorf("failed to trim sorted set for key '%s': %w", key, err) + } + return nil +} + +func (g *Gateway) SetRemove(ctx context.Context, key string, members ...any) (int64, error) { + result, err := g.clientOrInit().SRem(ctx, key, members...).Result() + if err != nil { + return 0, fmt.Errorf("failed to remove set members for key '%s': %w", key, err) + } + return result, nil +} + +func (g *Gateway) SetCard(ctx context.Context, key string) (int64, error) { + result, err := g.clientOrInit().SCard(ctx, key).Result() + if err != nil { + return 0, fmt.Errorf("failed to get set size for key '%s': %w", key, err) + } + return result, nil +} + +func (g *Gateway) XAdd(ctx context.Context, stream string, values map[string]any) error { + _, err := g.clientOrInit().XAdd(ctx, &redis.XAddArgs{ + Stream: stream, + MaxLen: 1000, + Approx: true, + ID: "*", + Values: values, + }).Result() + if err != nil { + return fmt.Errorf("redis XADD failed for stream '%s': %w", stream, err) + } + return nil +} + +func (g *Gateway) RunScript(ctx context.Context, script *redis.Script, keys []string, args ...any) (any, error) { + result, err := script.Run(ctx, g.clientOrInit(), keys, args...).Result() + if err != nil { + return nil, err + } + return result, nil +} + +func (g *Gateway) Ping(ctx context.Context) error { + if err := g.clientOrInit().Ping(ctx).Err(); err != nil { + return fmt.Errorf("redis PING failed: %w", err) + } + return nil +} + +func (g *Gateway) HashLength(ctx context.Context, key string) (int64, error) { + result, err := g.clientOrInit().HLen(ctx, key).Result() + if err != nil { + return 0, fmt.Errorf("failed to get hash length for key '%s': %w", key, err) + } + return result, nil +} + +func (g *Gateway) GetInt64(ctx context.Context, key string) (int64, error) { + result, err := g.clientOrInit().Get(ctx, key).Int64() + if err == redis.Nil { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("failed to get int64 value for key '%s': %w", key, err) + } + return result, nil +} + +func (g *Gateway) Watch(ctx context.Context, fn func(*redis.Tx) error, keys ...string) error { + return g.clientOrInit().Watch(ctx, fn, keys...) +} + +func (g *Gateway) XRead(ctx context.Context, streams []string, count int64, block time.Duration) ([]redis.XStream, error) { + result, err := g.clientOrInit().XRead(ctx, &redis.XReadArgs{ + Streams: streams, + Count: count, + Block: block, + }).Result() + if err != nil && err != redis.Nil { + return nil, fmt.Errorf("redis XREAD failed: %w", err) + } + return result, nil +} + +func (g *Gateway) Publish(ctx context.Context, channel string, message any) error { + var payload string + switch v := message.(type) { + case string: + payload = v + default: + data, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + payload = string(data) + } + + if err := g.clientOrInit().Publish(ctx, channel, payload).Err(); err != nil { + return fmt.Errorf("redis PUBLISH failed for channel '%s': %w", channel, err) + } + return nil +} + +func (g *Gateway) Set(ctx context.Context, key string, value any, expiration time.Duration) error { + if err := g.clientOrInit().Set(ctx, key, value, expiration).Err(); err != nil { + return fmt.Errorf("redis SET failed for key '%s': %w", key, err) + } + return nil +} + +func (g *Gateway) SetNX(ctx context.Context, key string, value any, expiration time.Duration) (bool, error) { + result, err := g.clientOrInit().SetNX(ctx, key, value, expiration).Result() + if err != nil { + return false, fmt.Errorf("redis SETNX failed for key '%s': %w", key, err) + } + return result, nil +} + +func (g *Gateway) Subscribe(ctx context.Context, channel string) (*redis.PubSub, error) { + pubsub := g.clientOrInit().Subscribe(ctx, channel) + if _, err := pubsub.Receive(ctx); err != nil { + _ = pubsub.Close() + return nil, fmt.Errorf("redis SUBSCRIBE failed for channel '%s': %w", channel, err) + } + return pubsub, nil +} + +func (g *Gateway) InitConcurrencyLock(ctx context.Context) error { + return g.clientOrInit().Set(ctx, ConcurrencyLockKey, 0, 0).Err() +} + +func newClient() *redis.Client { + logrus.Infof("Connecting to Redis %s", config.GetString("redis.host")) + client := redis.NewClient(&redis.Options{ + Addr: config.GetString("redis.host"), + Password: "", + DB: 0, + }) + if err := client.Ping(context.Background()).Err(); err != nil { + logrus.Fatalf("Failed to connect to Redis: %v", err) + } + return client +} diff --git a/src/infra/redis/module.go b/src/infra/redis/module.go new file mode 100644 index 00000000..690ae323 --- /dev/null +++ b/src/infra/redis/module.go @@ -0,0 +1,7 @@ +package redis + +import "go.uber.org/fx" + +var Module = fx.Module("redis", + fx.Provide(NewGatewayWithLifecycle), +) diff --git a/src/infra/redis/task_queue.go b/src/infra/redis/task_queue.go new file mode 100644 index 00000000..5f81dee2 --- /dev/null +++ b/src/infra/redis/task_queue.go @@ -0,0 +1,318 @@ +package redis + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "aegis/dto" + + "github.com/redis/go-redis/v9" + "github.com/sirupsen/logrus" +) + +const ( + DelayedQueueKey = "task:delayed" + ReadyQueueKey = "task:ready" + DeadLetterKey = "task:dead" + TaskIndexKey = "task:index" + ConcurrencyLockKey = "task:concurrency_lock" + LastBatchInfoKey = "last_batch_info" + MaxConcurrency = 20 +) + +type TaskQueueStats struct { + ReadyCount int64 + DelayedCount int64 + DeadCount int64 + IndexedCount int64 + ConcurrencyCount int64 +} + +func (g *Gateway) SubmitImmediateTask(ctx context.Context, taskData []byte, taskID string) error { + redisCli := g.clientOrInit() + if err := redisCli.LPush(ctx, ReadyQueueKey, taskData).Err(); err != nil { + return err + } + return redisCli.HSet(ctx, TaskIndexKey, taskID, ReadyQueueKey).Err() +} + +func (g *Gateway) GetTask(ctx context.Context, timeout time.Duration) (string, error) { + redisCli := g.clientOrInit() + result, err := redisCli.BRPop(ctx, timeout, ReadyQueueKey).Result() + if err != nil { + return "", err + } + return result[1], nil +} + +func (g *Gateway) HandleFailedTask(ctx context.Context, taskData []byte, backoffSec int) error { + deadLetterTime := time.Now().Add(time.Duration(backoffSec) * time.Second).Unix() + redisCli := g.clientOrInit() + if err := redisCli.ZAdd(ctx, DeadLetterKey, redis.Z{ + Score: float64(deadLetterTime), + Member: taskData, + }).Err(); err != nil { + return err + } + + var task dto.UnifiedTask + if err := json.Unmarshal(taskData, &task); err == nil && task.TaskID != "" { + return redisCli.HSet(ctx, TaskIndexKey, task.TaskID, DeadLetterKey).Err() + } + return nil +} + +func (g *Gateway) SubmitDelayedTask(ctx context.Context, taskData []byte, taskID string, executeTime int64) error { + redisCli := g.clientOrInit() + if err := redisCli.ZAdd(ctx, DelayedQueueKey, redis.Z{ + Score: float64(executeTime), + Member: taskData, + }).Err(); err != nil { + return err + } + return redisCli.HSet(ctx, TaskIndexKey, taskID, DelayedQueueKey).Err() +} + +// ExpediteDelayedTask finds the delayed-queue member for taskID, rewrites its +// embedded execute_time, and re-scores the sorted-set entry to newExecuteTime. +// Returns (found, err). If the task is not present (e.g. scheduler already +// promoted it to ready queue) returns (false, nil). +func (g *Gateway) ExpediteDelayedTask(ctx context.Context, taskID string, newExecuteTime int64) (bool, error) { + cli := g.clientOrInit() + members, err := cli.ZRangeByScore(ctx, DelayedQueueKey, &redis.ZRangeBy{ + Min: "-inf", + Max: "+inf", + }).Result() + if err != nil { + return false, fmt.Errorf("failed to scan delayed queue: %w", err) + } + + for _, member := range members { + var parsed map[string]any + if err := json.Unmarshal([]byte(member), &parsed); err != nil { + continue + } + id, _ := parsed["task_id"].(string) + if id != taskID { + continue + } + + parsed["execute_time"] = newExecuteTime + updated, err := json.Marshal(parsed) + if err != nil { + return false, fmt.Errorf("failed to re-marshal task payload: %w", err) + } + + pipe := cli.TxPipeline() + pipe.ZRem(ctx, DelayedQueueKey, member) + pipe.ZAdd(ctx, DelayedQueueKey, redis.Z{ + Score: float64(newExecuteTime), + Member: updated, + }) + pipe.HSet(ctx, TaskIndexKey, taskID, DelayedQueueKey) + if _, err := pipe.Exec(ctx); err != nil { + return false, fmt.Errorf("failed to rescore delayed task: %w", err) + } + return true, nil + } + + return false, nil +} + +func (g *Gateway) ProcessDelayedTasks(ctx context.Context) ([]string, error) { + redisCli := g.clientOrInit() + now := time.Now().Unix() + + delayedTaskScript := redis.NewScript(` + local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1]) + if #tasks > 0 then + redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1]) + redis.call('LPUSH', KEYS[2], unpack(tasks)) + for _, task in ipairs(tasks) do + local t = cjson.decode(task) + redis.call('HSET', KEYS[3], t.task_id, KEYS[2]) + end + end + return tasks + `) + + result, err := delayedTaskScript.Run(ctx, redisCli, + []string{DelayedQueueKey, ReadyQueueKey, TaskIndexKey}, + now, + ).StringSlice() + if err != nil && err != redis.Nil { + return nil, err + } + return result, nil +} + +func (g *Gateway) HandleCronRescheduleFailure(ctx context.Context, taskData []byte) error { + redisCli := g.clientOrInit() + if err := redisCli.ZAdd(ctx, DeadLetterKey, redis.Z{ + Score: float64(time.Now().Unix()), + Member: taskData, + }).Err(); err != nil { + return err + } + + var task dto.UnifiedTask + if err := json.Unmarshal(taskData, &task); err == nil && task.TaskID != "" { + return redisCli.HSet(ctx, TaskIndexKey, task.TaskID, DeadLetterKey).Err() + } + return nil +} + +func (g *Gateway) AcquireConcurrencyLock(ctx context.Context) bool { + redisCli := g.clientOrInit() + currentCount, _ := redisCli.Get(ctx, ConcurrencyLockKey).Int64() + if currentCount >= MaxConcurrency { + return false + } + return redisCli.Incr(ctx, ConcurrencyLockKey).Err() == nil +} + +func (g *Gateway) ReleaseConcurrencyLock(ctx context.Context) { + if err := g.clientOrInit().Decr(ctx, ConcurrencyLockKey).Err(); err != nil { + logrus.Warnf("error releasing concurrency lock: %v", err) + } +} + +func (g *Gateway) GetTaskQueue(ctx context.Context, taskID string) (string, error) { + return g.clientOrInit().HGet(ctx, TaskIndexKey, taskID).Result() +} + +func (g *Gateway) ListDelayedTasks(ctx context.Context, limit int64) ([]string, error) { + delayedTasksWithScore, err := g.ZRangeByScoreWithScores(ctx, DelayedQueueKey, limit) + if err != nil { + return nil, err + } + + taskDatas := make([]string, 0, len(delayedTasksWithScore)) + for _, z := range delayedTasksWithScore { + taskData, ok := z.Member.(string) + if !ok { + return nil, fmt.Errorf("invalid delayed task data") + } + taskDatas = append(taskDatas, taskData) + } + + return taskDatas, nil +} + +func (g *Gateway) ListDeadLetterTasks(ctx context.Context, limit int64) ([]string, error) { + deadTasksWithScore, err := g.ZRangeByScoreWithScores(ctx, DeadLetterKey, limit) + if err != nil { + return nil, err + } + + taskDatas := make([]string, 0, len(deadTasksWithScore)) + for _, z := range deadTasksWithScore { + taskData, ok := z.Member.(string) + if !ok { + return nil, fmt.Errorf("invalid dead letter task data") + } + taskDatas = append(taskDatas, taskData) + } + + return taskDatas, nil +} + +func (g *Gateway) ListReadyTasks(ctx context.Context) ([]string, error) { + return g.ListRange(ctx, ReadyQueueKey) +} + +func (g *Gateway) RemoveFromList(ctx context.Context, key, taskID string) (bool, error) { + removeFromListScript := redis.NewScript(` + local key = KEYS[1] + local taskID = ARGV[1] + local count = 0 + + for i=0, redis.call('LLEN', key)-1 do + local item = redis.call('LINDEX', key, i) + if item then + local task = cjson.decode(item) + if task.task_id == taskID then + redis.call('LSET', key, i, "__DELETED__") + count = count + 1 + end + end + end + + if count > 0 then + redis.call('LREM', key, count, "__DELETED__") + end + + return count + `) + + result, err := removeFromListScript.Run(ctx, g.clientOrInit(), []string{key}, taskID).Int() + if err != nil { + return false, fmt.Errorf("failed to remove from list: %w", err) + } + return result > 0, nil +} + +func (g *Gateway) RemoveFromZSet(ctx context.Context, key, taskID string) bool { + cli := g.clientOrInit() + members, err := cli.ZRangeByScore(ctx, key, &redis.ZRangeBy{ + Min: "-inf", + Max: "+inf", + }).Result() + if err != nil { + return false + } + + for _, member := range members { + var task dto.UnifiedTask + if json.Unmarshal([]byte(member), &task) == nil && task.TaskID == taskID { + if err := cli.ZRem(ctx, key, member).Err(); err != nil { + logrus.Warnf("failed to remove from ZSet: %v", err) + return false + } + return true + } + } + + return false +} + +func (g *Gateway) DeleteTaskIndex(ctx context.Context, taskID string) error { + return g.clientOrInit().HDel(ctx, TaskIndexKey, taskID).Err() +} + +func (g *Gateway) GetTaskQueueStats(ctx context.Context) (TaskQueueStats, error) { + readyCount, err := g.ListLength(ctx, ReadyQueueKey) + if err != nil { + return TaskQueueStats{}, err + } + + delayedCount, err := g.SortedSetCard(ctx, DelayedQueueKey) + if err != nil { + return TaskQueueStats{}, err + } + + deadCount, err := g.SortedSetCard(ctx, DeadLetterKey) + if err != nil { + return TaskQueueStats{}, err + } + + indexedCount, err := g.HashLength(ctx, TaskIndexKey) + if err != nil { + return TaskQueueStats{}, err + } + + concurrencyCount, err := g.GetInt64(ctx, ConcurrencyLockKey) + if err != nil { + return TaskQueueStats{}, err + } + + return TaskQueueStats{ + ReadyCount: readyCount, + DelayedCount: delayedCount, + DeadCount: deadCount, + IndexedCount: indexedCount, + ConcurrencyCount: concurrencyCount, + }, nil +} diff --git a/src/infra/runtime/module.go b/src/infra/runtime/module.go new file mode 100644 index 00000000..c8f2410c --- /dev/null +++ b/src/infra/runtime/module.go @@ -0,0 +1,23 @@ +package runtimeinfra + +import ( + "time" + + "aegis/consts" + "aegis/utils" + + "go.uber.org/fx" +) + +var Module = fx.Module("runtime", + fx.Invoke(InitializeRuntime), +) + +func InitializeRuntime() { + if consts.InitialTime == nil { + consts.InitialTime = utils.TimePtr(time.Now()) + } + if consts.AppID == "" { + consts.AppID = utils.GenerateULID(consts.InitialTime) + } +} diff --git a/src/infra/tracing/module.go b/src/infra/tracing/module.go new file mode 100644 index 00000000..6eef7107 --- /dev/null +++ b/src/infra/tracing/module.go @@ -0,0 +1,28 @@ +package tracing + +import ( + "context" + + "go.opentelemetry.io/otel/sdk/trace" + "go.uber.org/fx" +) + +var Module = fx.Module("tracing", + fx.Provide(NewTraceProvider), +) + +func NewTraceProvider(lc fx.Lifecycle) *trace.TracerProvider { + provider, err := NewProvider() + if err != nil { + panic(err) + } + + lc.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + ShutdownProvider(ctx, provider) + return nil + }, + }) + + return provider +} diff --git a/src/client/jaeger.go b/src/infra/tracing/provider.go similarity index 65% rename from src/client/jaeger.go rename to src/infra/tracing/provider.go index 58bd4186..9f286312 100644 --- a/src/client/jaeger.go +++ b/src/infra/tracing/provider.go @@ -1,4 +1,4 @@ -package client +package tracing import ( "context" @@ -15,11 +15,7 @@ import ( semconv "go.opentelemetry.io/otel/semconv/v1.34.0" ) -var ( - TraceProvider *sdktrace.TracerProvider -) - -func InitTraceProvider() { +func NewProvider() (*sdktrace.TracerProvider, error) { ctx := context.Background() exporter, err := otlptracehttp.New(ctx, @@ -27,11 +23,10 @@ func InitTraceProvider() { otlptracehttp.WithEndpoint(config.GetString("jaeger.endpoint")), ) if err != nil { - logrus.Errorf("failed to create OTLP HTTP exporter: %v", err) - return + return nil, err } - resource, err := resource.Merge( + res, err := resource.Merge( resource.Default(), resource.NewWithAttributes( semconv.SchemaURL, @@ -40,22 +35,26 @@ func InitTraceProvider() { ), ) if err != nil { - logrus.Errorf("failed to create OTLP sdk resource: %v", err) - return + return nil, err } - TraceProvider = sdktrace.NewTracerProvider( + provider := sdktrace.NewTracerProvider( sdktrace.WithBatcher(exporter), - sdktrace.WithResource(resource), + sdktrace.WithResource(res), ) - otel.SetTracerProvider(TraceProvider) + otel.SetTracerProvider(provider) otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})) + return provider, nil } -func ShutdownTraceProvider(ctx context.Context) { - ctx, cancel := context.WithTimeout(ctx, time.Second*5) +func ShutdownProvider(ctx context.Context, provider *sdktrace.TracerProvider) { + if provider == nil { + return + } + + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - if err := TraceProvider.Shutdown(ctx); err != nil { + if err := provider.Shutdown(shutdownCtx); err != nil { logrus.Errorf("failed to shutdown tracer provider: %v", err) } } diff --git a/src/interface/controller/module.go b/src/interface/controller/module.go new file mode 100644 index 00000000..2966f8c3 --- /dev/null +++ b/src/interface/controller/module.go @@ -0,0 +1,94 @@ +package controller + +import ( + "context" + "log" + "os" + + k8s "aegis/infra/k8s" + redis "aegis/infra/redis" + "aegis/service/consumer" + + "github.com/go-logr/stdr" + "go.uber.org/fx" + "gorm.io/gorm" + k8slogger "sigs.k8s.io/controller-runtime/pkg/log" +) + +var Module = fx.Module("controller", + fx.Provide(newLifecycle), + fx.Invoke(registerLifecycle), +) + +type Params struct { + fx.In + + Controller *k8s.Controller + K8sGateway *k8s.Gateway + RedisGateway *redis.Gateway + DB *gorm.DB + Monitor consumer.NamespaceMonitor + AlgoLimiter *consumer.TokenBucketRateLimiter `name:"algo_limiter"` + BatchManager *consumer.FaultBatchManager + ExecutionOwner consumer.ExecutionOwner + InjectionOwner consumer.InjectionOwner +} + +type Lifecycle struct { + params Params + RunFunc func(context.Context, context.CancelFunc) error + StopFunc func() +} + +func newLifecycle(params Params) *Lifecycle { + return &Lifecycle{params: params} +} + +func (r *Lifecycle) start(ctx context.Context, cancel context.CancelFunc) error { + if r.RunFunc != nil { + return r.RunFunc(ctx, cancel) + } + k8slogger.SetLogger(stdr.New(log.New(os.Stdout, "", log.LstdFlags))) + go r.params.Controller.Initialize( + ctx, + cancel, + consumer.NewHandler( + r.params.DB, + r.params.Monitor, + r.params.AlgoLimiter, + r.params.K8sGateway, + r.params.RedisGateway, + r.params.BatchManager, + r.params.ExecutionOwner, + r.params.InjectionOwner, + ), + ) + return nil +} + +func (r *Lifecycle) stop() { + if r.StopFunc != nil { + r.StopFunc() + } +} + +func registerLifecycle(lc fx.Lifecycle, runner *Lifecycle) { + var ( + controllerCtx context.Context + cancel context.CancelFunc + ) + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + controllerCtx, cancel = context.WithCancel(context.WithoutCancel(ctx)) + return runner.start(controllerCtx, cancel) + }, + OnStop: func(ctx context.Context) error { + if cancel != nil { + cancel() + } + runner.stop() + return nil + }, + }) +} diff --git a/src/interface/grpc/iam/lifecycle.go b/src/interface/grpc/iam/lifecycle.go new file mode 100644 index 00000000..75a7e6b2 --- /dev/null +++ b/src/interface/grpc/iam/lifecycle.go @@ -0,0 +1,94 @@ +package grpciam + +import ( + "context" + "fmt" + "net" + + "aegis/config" + "aegis/httpx" + iamv1 "aegis/proto/iam/v1" + + "github.com/sirupsen/logrus" + "go.uber.org/fx" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" +) + +const defaultIAMGRPCAddr = ":9091" + +type Lifecycle struct { + server *grpc.Server + addr string + listener net.Listener + StartFunc func(context.Context) error + StopFunc func() +} + +func newLifecycle(iamServer *iamServer) (*Lifecycle, error) { + grpcServer := grpc.NewServer(grpc.UnaryInterceptor(httpx.UnaryServerRequestIDInterceptor())) + iamv1.RegisterIAMServiceServer(grpcServer, iamServer) + + healthServer := health.NewServer() + healthServer.SetServingStatus(iamv1.IAMService_ServiceDesc.ServiceName, grpc_health_v1.HealthCheckResponse_SERVING) + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) + + if config.GetBool("iam.grpc.reflection") { + reflection.Register(grpcServer) + } + + addr := config.GetString("iam.grpc.addr") + if addr == "" { + addr = defaultIAMGRPCAddr + } + + return &Lifecycle{ + server: grpcServer, + addr: addr, + }, nil +} + +func (r *Lifecycle) start(ctx context.Context) error { + if r.StartFunc != nil { + return r.StartFunc(ctx) + } + + listener, err := net.Listen("tcp", r.addr) + if err != nil { + return fmt.Errorf("listen iam grpc on %s: %w", r.addr, err) + } + r.listener = listener + + go func() { + logrus.Infof("Starting IAM gRPC server on %s", r.addr) + if err := r.server.Serve(listener); err != nil { + logrus.Errorf("iam gRPC server error: %v", err) + } + }() + return nil +} + +func (r *Lifecycle) stop() { + if r.StopFunc != nil { + r.StopFunc() + return + } + if r.server != nil { + r.server.GracefulStop() + } +} + +func registerLifecycle(lc fx.Lifecycle, runner *Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + return runner.start(ctx) + }, + OnStop: func(ctx context.Context) error { + runner.stop() + return nil + }, + }) +} diff --git a/src/interface/grpc/iam/module.go b/src/interface/grpc/iam/module.go new file mode 100644 index 00000000..f88bd64c --- /dev/null +++ b/src/interface/grpc/iam/module.go @@ -0,0 +1,11 @@ +package grpciam + +import "go.uber.org/fx" + +var Module = fx.Module("grpc_iam", + fx.Provide( + newIAMServer, + newLifecycle, + ), + fx.Invoke(registerLifecycle), +) diff --git a/src/interface/grpc/iam/service.go b/src/interface/grpc/iam/service.go new file mode 100644 index 00000000..fdba7be9 --- /dev/null +++ b/src/interface/grpc/iam/service.go @@ -0,0 +1,990 @@ +package grpciam + +import ( + "context" + "encoding/json" + "errors" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/middleware" + auth "aegis/module/auth" + rbac "aegis/module/rbac" + team "aegis/module/team" + user "aegis/module/user" + iamv1 "aegis/proto/iam/v1" + "aegis/utils" + + "github.com/golang-jwt/jwt/v5" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/structpb" +) + +type iamServer struct { + iamv1.UnimplementedIAMServiceServer + auth *auth.Service + authAPI auth.HandlerService + team team.HandlerService + user user.HandlerService + rbac rbac.HandlerService + middleware middleware.Service +} + +func newIAMServer( + auth *auth.Service, + authAPI auth.HandlerService, + team team.HandlerService, + user user.HandlerService, + rbac rbac.HandlerService, + middlewareService middleware.Service, +) *iamServer { + return &iamServer{ + auth: auth, + authAPI: authAPI, + team: team, + user: user, + rbac: rbac, + middleware: middlewareService, + } +} + +func (s *iamServer) VerifyToken(ctx context.Context, req *iamv1.VerifyTokenRequest) (*iamv1.VerifyTokenResponse, error) { + if req.GetToken() == "" { + return nil, status.Error(codes.InvalidArgument, "token is required") + } + + claims, err := s.auth.VerifyToken(ctx, req.GetToken()) + if err == nil { + return &iamv1.VerifyTokenResponse{ + Valid: true, + TokenType: "user", + UserId: int64(claims.UserID), + Username: claims.Username, + Email: claims.Email, + IsActive: claims.IsActive, + IsAdmin: claims.IsAdmin, + Roles: claims.Roles, + ExpiresAtUnix: claims.ExpiresAt.Unix(), + AuthType: claims.AuthType, + KeyId: int64(claims.APIKeyID), + ApiKeyScopes: append([]string(nil), claims.APIKeyScopes...), + }, nil + } + + serviceClaims, serviceErr := s.auth.VerifyServiceToken(ctx, req.GetToken()) + if serviceErr == nil { + return &iamv1.VerifyTokenResponse{ + Valid: true, + TokenType: "service", + TaskId: serviceClaims.TaskID, + ExpiresAtUnix: serviceClaims.ExpiresAt.Unix(), + }, nil + } + + return nil, status.Error(codes.Unauthenticated, err.Error()) +} + +func (s *iamServer) CheckPermission(ctx context.Context, req *iamv1.CheckPermissionRequest) (*iamv1.CheckPermissionResponse, error) { + if req.GetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + + params := &dto.CheckPermissionParams{ + UserID: int(req.GetUserId()), + Action: consts.ActionName(req.GetAction()), + Scope: consts.ResourceScope(req.GetScope()), + ResourceName: consts.ResourceName(req.GetResourceName()), + TeamID: optionalID(req.GetTeamId()), + ProjectID: optionalID(req.GetProjectId()), + ContainerID: optionalID(req.GetContainerId()), + DatasetID: optionalID(req.GetDatasetId()), + } + + allowed, err := s.middleware.CheckUserPermission(ctx, params) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return nil, status.Error(codes.NotFound, err.Error()) + } + return nil, status.Error(codes.Internal, err.Error()) + } + return &iamv1.CheckPermissionResponse{Allowed: allowed}, nil +} + +func (s *iamServer) Login(ctx context.Context, req *iamv1.MutationRequest) (*iamv1.StructResponse, error) { + body, err := decodeBody[auth.LoginReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.authAPI.Login(ctx, body) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) Register(ctx context.Context, req *iamv1.MutationRequest) (*iamv1.StructResponse, error) { + body, err := decodeBody[auth.RegisterReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.authAPI.Register(ctx, body) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) RefreshToken(ctx context.Context, req *iamv1.MutationRequest) (*iamv1.StructResponse, error) { + body, err := decodeBody[auth.TokenRefreshReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.authAPI.RefreshToken(ctx, body) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) Logout(ctx context.Context, req *iamv1.LogoutRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 || req.GetTokenId() == "" || req.GetExpiresAtUnix() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id, token_id, and expires_at_unix are required") + } + claims := &utils.Claims{ + UserID: int(req.GetUserId()), + RegisteredClaims: jwt.RegisteredClaims{ + ID: req.GetTokenId(), + ExpiresAt: jwt.NewNumericDate(time.Unix(req.GetExpiresAtUnix(), 0)), + }, + } + if err := s.authAPI.Logout(ctx, claims); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) ChangePassword(ctx context.Context, req *iamv1.UserBodyRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + body, err := decodeBody[auth.ChangePasswordReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := s.authAPI.ChangePassword(ctx, body, int(req.GetUserId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) GetProfile(ctx context.Context, req *iamv1.UserIDRequest) (*iamv1.StructResponse, error) { + if req.GetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + resp, err := s.authAPI.GetProfile(ctx, int(req.GetUserId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) CreateAPIKey(ctx context.Context, req *iamv1.UserBodyRequest) (*iamv1.StructResponse, error) { + if req.GetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + body, err := decodeBody[auth.CreateAPIKeyReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.authAPI.CreateAPIKey(ctx, int(req.GetUserId()), body) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) ListAPIKeys(ctx context.Context, req *iamv1.UserQueryRequest) (*iamv1.StructResponse, error) { + if req.GetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + query, err := decodeQuery[auth.ListAPIKeyReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.authAPI.ListAPIKeys(ctx, int(req.GetUserId()), query) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) GetAPIKey(ctx context.Context, req *iamv1.UserScopedIDRequest) (*iamv1.StructResponse, error) { + if req.GetUserId() <= 0 || req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and id are required") + } + resp, err := s.authAPI.GetAPIKey(ctx, int(req.GetUserId()), int(req.GetId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) DeleteAPIKey(ctx context.Context, req *iamv1.UserScopedIDRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 || req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and id are required") + } + if err := s.authAPI.DeleteAPIKey(ctx, int(req.GetUserId()), int(req.GetId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) DisableAPIKey(ctx context.Context, req *iamv1.UserScopedIDRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 || req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and id are required") + } + if err := s.authAPI.DisableAPIKey(ctx, int(req.GetUserId()), int(req.GetId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) EnableAPIKey(ctx context.Context, req *iamv1.UserScopedIDRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 || req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and id are required") + } + if err := s.authAPI.EnableAPIKey(ctx, int(req.GetUserId()), int(req.GetId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) RevokeAPIKey(ctx context.Context, req *iamv1.UserScopedIDRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 || req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and id are required") + } + if err := s.authAPI.RevokeAPIKey(ctx, int(req.GetUserId()), int(req.GetId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) RotateAPIKey(ctx context.Context, req *iamv1.UserScopedIDRequest) (*iamv1.StructResponse, error) { + if req.GetUserId() <= 0 || req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and id are required") + } + resp, err := s.authAPI.RotateAPIKey(ctx, int(req.GetUserId()), int(req.GetId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) CreateUser(ctx context.Context, req *iamv1.MutationRequest) (*iamv1.StructResponse, error) { + body, err := decodeBody[user.CreateUserReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.user.CreateUser(ctx, body) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) DeleteUser(ctx context.Context, req *iamv1.IDRequest) (*emptypb.Empty, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + if err := s.user.DeleteUser(ctx, int(req.GetId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) GetUser(ctx context.Context, req *iamv1.IDRequest) (*iamv1.StructResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + resp, err := s.user.GetUserDetail(ctx, int(req.GetId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) ListUsers(ctx context.Context, req *iamv1.QueryRequest) (*iamv1.StructResponse, error) { + query, err := decodeQuery[user.ListUserReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.user.ListUsers(ctx, query) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) UpdateUser(ctx context.Context, req *iamv1.UpdateByIDRequest) (*iamv1.StructResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + body, err := decodeBody[user.UpdateUserReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.user.UpdateUser(ctx, body, int(req.GetId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) AssignUserRole(ctx context.Context, req *iamv1.UserRoleBindingRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 || req.GetRoleId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and role_id are required") + } + if err := s.user.AssignRole(ctx, int(req.GetUserId()), int(req.GetRoleId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) RemoveUserRole(ctx context.Context, req *iamv1.UserRoleBindingRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 || req.GetRoleId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and role_id are required") + } + if err := s.user.RemoveRole(ctx, int(req.GetUserId()), int(req.GetRoleId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) AssignUserPermissions(ctx context.Context, req *iamv1.UserBodyRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + body, err := decodeBody[user.AssignUserPermissionReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := s.user.AssignPermissions(ctx, body, int(req.GetUserId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) RemoveUserPermissions(ctx context.Context, req *iamv1.UserBodyRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + body, err := decodeBody[user.RemoveUserPermissionReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := s.user.RemovePermissions(ctx, body, int(req.GetUserId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) AssignUserContainer(ctx context.Context, req *iamv1.UserResourceBindingRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 || req.GetResourceId() <= 0 || req.GetRoleId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id, resource_id, and role_id are required") + } + if err := s.user.AssignContainer(ctx, int(req.GetUserId()), int(req.GetResourceId()), int(req.GetRoleId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) RemoveUserContainer(ctx context.Context, req *iamv1.UserScopedIDRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 || req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and id are required") + } + if err := s.user.RemoveContainer(ctx, int(req.GetUserId()), int(req.GetId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) AssignUserDataset(ctx context.Context, req *iamv1.UserResourceBindingRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 || req.GetResourceId() <= 0 || req.GetRoleId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id, resource_id, and role_id are required") + } + if err := s.user.AssignDataset(ctx, int(req.GetUserId()), int(req.GetResourceId()), int(req.GetRoleId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) RemoveUserDataset(ctx context.Context, req *iamv1.UserScopedIDRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 || req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and id are required") + } + if err := s.user.RemoveDataset(ctx, int(req.GetUserId()), int(req.GetId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) AssignUserProject(ctx context.Context, req *iamv1.UserResourceBindingRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 || req.GetResourceId() <= 0 || req.GetRoleId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id, resource_id, and role_id are required") + } + if err := s.user.AssignProject(ctx, int(req.GetUserId()), int(req.GetResourceId()), int(req.GetRoleId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) RemoveUserProject(ctx context.Context, req *iamv1.UserScopedIDRequest) (*emptypb.Empty, error) { + if req.GetUserId() <= 0 || req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and id are required") + } + if err := s.user.RemoveProject(ctx, int(req.GetUserId()), int(req.GetId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) CreateRole(ctx context.Context, req *iamv1.MutationRequest) (*iamv1.StructResponse, error) { + body, err := decodeBody[rbac.CreateRoleReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.rbac.CreateRole(ctx, body) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) DeleteRole(ctx context.Context, req *iamv1.IDRequest) (*emptypb.Empty, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + if err := s.rbac.DeleteRole(ctx, int(req.GetId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) GetRole(ctx context.Context, req *iamv1.IDRequest) (*iamv1.StructResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + resp, err := s.rbac.GetRole(ctx, int(req.GetId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) ListRoles(ctx context.Context, req *iamv1.QueryRequest) (*iamv1.StructResponse, error) { + query, err := decodeQuery[rbac.ListRoleReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.rbac.ListRoles(ctx, query) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) UpdateRole(ctx context.Context, req *iamv1.UpdateByIDRequest) (*iamv1.StructResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + body, err := decodeBody[rbac.UpdateRoleReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.rbac.UpdateRole(ctx, body, int(req.GetId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) AssignRolePermissions(ctx context.Context, req *iamv1.RolePermissionsRequest) (*emptypb.Empty, error) { + if req.GetRoleId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "role_id is required") + } + if err := validatePositiveInt64s(req.GetPermissionIds(), "permission_ids"); err != nil { + return nil, err + } + if err := s.rbac.AssignRolePermissions(ctx, int64sToInts(req.GetPermissionIds()), int(req.GetRoleId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) RemoveRolePermissions(ctx context.Context, req *iamv1.RolePermissionsRequest) (*emptypb.Empty, error) { + if req.GetRoleId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "role_id is required") + } + if err := validatePositiveInt64s(req.GetPermissionIds(), "permission_ids"); err != nil { + return nil, err + } + if err := s.rbac.RemoveRolePermissions(ctx, int64sToInts(req.GetPermissionIds()), int(req.GetRoleId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) ListUsersFromRole(ctx context.Context, req *iamv1.IDRequest) (*iamv1.StructResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + resp, err := s.rbac.ListUsersFromRole(ctx, int(req.GetId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) GetPermission(ctx context.Context, req *iamv1.IDRequest) (*iamv1.StructResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + resp, err := s.rbac.GetPermission(ctx, int(req.GetId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) ListPermissions(ctx context.Context, req *iamv1.QueryRequest) (*iamv1.StructResponse, error) { + query, err := decodeQuery[rbac.ListPermissionReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.rbac.ListPermissions(ctx, query) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) ListRolesFromPermission(ctx context.Context, req *iamv1.IDRequest) (*iamv1.StructResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + resp, err := s.rbac.ListRolesFromPermission(ctx, int(req.GetId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) GetResource(ctx context.Context, req *iamv1.IDRequest) (*iamv1.StructResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + resp, err := s.rbac.GetResource(ctx, int(req.GetId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) ListResources(ctx context.Context, req *iamv1.QueryRequest) (*iamv1.StructResponse, error) { + query, err := decodeQuery[rbac.ListResourceReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.rbac.ListResources(ctx, query) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) ListResourcePermissions(ctx context.Context, req *iamv1.IDRequest) (*iamv1.StructResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + resp, err := s.rbac.ListResourcePermissions(ctx, int(req.GetId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) IsUserTeamAdmin(ctx context.Context, req *iamv1.UserTeamRequest) (*iamv1.BoolResponse, error) { + if req.GetUserId() <= 0 || req.GetTeamId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and team_id are required") + } + + allowed, err := s.middleware.IsUserTeamAdmin(ctx, int(req.GetUserId()), int(req.GetTeamId())) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return &iamv1.BoolResponse{Value: allowed}, nil +} + +func (s *iamServer) IsUserInTeam(ctx context.Context, req *iamv1.UserTeamRequest) (*iamv1.BoolResponse, error) { + if req.GetUserId() <= 0 || req.GetTeamId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and team_id are required") + } + + allowed, err := s.middleware.IsUserInTeam(ctx, int(req.GetUserId()), int(req.GetTeamId())) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return &iamv1.BoolResponse{Value: allowed}, nil +} + +func (s *iamServer) IsTeamPublic(ctx context.Context, req *iamv1.TeamRequest) (*iamv1.BoolResponse, error) { + if req.GetTeamId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "team_id is required") + } + + allowed, err := s.middleware.IsTeamPublic(ctx, int(req.GetTeamId())) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return &iamv1.BoolResponse{Value: allowed}, nil +} + +func (s *iamServer) IsUserProjectAdmin(ctx context.Context, req *iamv1.UserProjectRequest) (*iamv1.BoolResponse, error) { + if req.GetUserId() <= 0 || req.GetProjectId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and project_id are required") + } + + allowed, err := s.middleware.IsUserProjectAdmin(ctx, int(req.GetUserId()), int(req.GetProjectId())) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return &iamv1.BoolResponse{Value: allowed}, nil +} + +func (s *iamServer) IsUserInProject(ctx context.Context, req *iamv1.UserProjectRequest) (*iamv1.BoolResponse, error) { + if req.GetUserId() <= 0 || req.GetProjectId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id and project_id are required") + } + + allowed, err := s.middleware.IsUserInProject(ctx, int(req.GetUserId()), int(req.GetProjectId())) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return &iamv1.BoolResponse{Value: allowed}, nil +} + +func (s *iamServer) ExchangeAPIKeyToken(ctx context.Context, req *iamv1.ExchangeAPIKeyTokenRequest) (*iamv1.ExchangeAPIKeyTokenResponse, error) { + authReq := &auth.APIKeyTokenReq{ + KeyID: req.GetKeyId(), + Timestamp: req.GetTimestamp(), + Nonce: req.GetNonce(), + Signature: req.GetSignature(), + } + if err := authReq.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if req.GetMethod() == "" || req.GetPath() == "" { + return nil, status.Error(codes.InvalidArgument, "method and path are required") + } + + resp, err := s.auth.ExchangeAPIKeyToken(ctx, authReq, req.GetMethod(), req.GetPath()) + if err != nil { + return nil, mapIAMError(err) + } + return &iamv1.ExchangeAPIKeyTokenResponse{ + Token: resp.Token, + TokenType: resp.TokenType, + ExpiresAtUnix: resp.ExpiresAt.Unix(), + AuthType: resp.AuthType, + KeyId: resp.KeyID, + }, nil +} + +func (s *iamServer) CreateTeam(ctx context.Context, req *iamv1.CreateTeamRequest) (*iamv1.StructResponse, error) { + if req.GetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + body, err := decodeBody[team.CreateTeamReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.team.CreateTeam(ctx, body, int(req.GetUserId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) DeleteTeam(ctx context.Context, req *iamv1.TeamRequest) (*emptypb.Empty, error) { + if req.GetTeamId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "team_id is required") + } + if err := s.team.DeleteTeam(ctx, int(req.GetTeamId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) GetTeam(ctx context.Context, req *iamv1.TeamRequest) (*iamv1.StructResponse, error) { + if req.GetTeamId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "team_id is required") + } + resp, err := s.team.GetTeamDetail(ctx, int(req.GetTeamId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) ListTeams(ctx context.Context, req *iamv1.ListTeamsRequest) (*iamv1.StructResponse, error) { + if req.GetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + query, err := decodeQuery[team.ListTeamReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.team.ListTeams(ctx, query, int(req.GetUserId()), req.GetIsAdmin()) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) UpdateTeam(ctx context.Context, req *iamv1.UpdateTeamRequest) (*iamv1.StructResponse, error) { + if req.GetTeamId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "team_id is required") + } + body, err := decodeBody[team.UpdateTeamReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.team.UpdateTeam(ctx, body, int(req.GetTeamId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) ListTeamProjects(ctx context.Context, req *iamv1.ListTeamProjectsRequest) (*iamv1.StructResponse, error) { + if req.GetTeamId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "team_id is required") + } + query, err := decodeQuery[team.TeamProjectListReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.team.ListTeamProjects(ctx, query, int(req.GetTeamId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func (s *iamServer) AddTeamMember(ctx context.Context, req *iamv1.AddTeamMemberRequest) (*emptypb.Empty, error) { + if req.GetTeamId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "team_id is required") + } + body, err := decodeBody[team.AddTeamMemberReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := s.team.AddMember(ctx, body, int(req.GetTeamId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) RemoveTeamMember(ctx context.Context, req *iamv1.RemoveTeamMemberRequest) (*emptypb.Empty, error) { + if req.GetTeamId() <= 0 || req.GetCurrentUserId() <= 0 || req.GetTargetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "team_id, current_user_id, and target_user_id are required") + } + if err := s.team.RemoveMember(ctx, int(req.GetTeamId()), int(req.GetCurrentUserId()), int(req.GetTargetUserId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) UpdateTeamMemberRole(ctx context.Context, req *iamv1.UpdateTeamMemberRoleRequest) (*emptypb.Empty, error) { + if req.GetTeamId() <= 0 || req.GetTargetUserId() <= 0 || req.GetCurrentUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "team_id, target_user_id, and current_user_id are required") + } + body, err := decodeBody[team.UpdateTeamMemberRoleReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := s.team.UpdateMemberRole(ctx, body, int(req.GetTeamId()), int(req.GetTargetUserId()), int(req.GetCurrentUserId())); err != nil { + return nil, mapIAMError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *iamServer) ListTeamMembers(ctx context.Context, req *iamv1.ListTeamMembersRequest) (*iamv1.StructResponse, error) { + if req.GetTeamId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "team_id is required") + } + query, err := decodeQuery[team.ListTeamMemberReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.team.ListMembers(ctx, query, int(req.GetTeamId())) + if err != nil { + return nil, mapIAMError(err) + } + return encodeStruct(resp) +} + +func optionalID(value int64) *int { + if value <= 0 { + return nil + } + id := int(value) + return &id +} + +func mapIAMError(err error) error { + switch { + case errors.Is(err, consts.ErrBadRequest): + return status.Error(codes.InvalidArgument, err.Error()) + case errors.Is(err, consts.ErrAuthenticationFailed): + return status.Error(codes.Unauthenticated, err.Error()) + case errors.Is(err, consts.ErrPermissionDenied): + return status.Error(codes.PermissionDenied, err.Error()) + case errors.Is(err, consts.ErrNotFound): + return status.Error(codes.NotFound, err.Error()) + case errors.Is(err, consts.ErrAlreadyExists): + return status.Error(codes.AlreadyExists, err.Error()) + case err != nil: + return status.Error(codes.Internal, err.Error()) + default: + return nil + } +} + +func encodeStruct(value any) (*iamv1.StructResponse, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + payload := map[string]any{} + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + body, err := structpb.NewStruct(payload) + if err != nil { + return nil, err + } + return &iamv1.StructResponse{Data: body}, nil +} + +func decodeBody[T any](payload *structpb.Struct) (*T, error) { + return decodeQuery[T](payload) +} + +func decodeQuery[T any](payload *structpb.Struct) (*T, error) { + if payload == nil { + var zero T + return &zero, nil + } + data, err := json.Marshal(payload.AsMap()) + if err != nil { + return nil, err + } + var result T + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +func validatePositiveInt64s(items []int64, field string) error { + if len(items) == 0 { + return status.Errorf(codes.InvalidArgument, "%s is required", field) + } + for _, item := range items { + if item <= 0 { + return status.Errorf(codes.InvalidArgument, "%s must contain positive integers", field) + } + } + return nil +} + +func int64sToInts(items []int64) []int { + if len(items) == 0 { + return nil + } + result := make([]int, 0, len(items)) + for _, item := range items { + result = append(result, int(item)) + } + return result +} diff --git a/src/interface/grpc/iam/service_test.go b/src/interface/grpc/iam/service_test.go new file mode 100644 index 00000000..04b423f9 --- /dev/null +++ b/src/interface/grpc/iam/service_test.go @@ -0,0 +1,310 @@ +package grpciam + +import ( + "context" + "reflect" + "testing" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/middleware" + auth "aegis/module/auth" + team "aegis/module/team" + iamv1 "aegis/proto/iam/v1" + "aegis/utils" + + "google.golang.org/protobuf/types/known/structpb" +) + +type middlewareStub struct { + allowed bool + teamAdmin bool + teamMember bool + teamPublic bool + projectAdmin bool + projectMember bool +} + +func (middlewareStub) VerifyToken(context.Context, string) (*utils.Claims, error) { + return &utils.Claims{UserID: 1}, nil +} +func (middlewareStub) VerifyServiceToken(context.Context, string) (*utils.ServiceClaims, error) { + return &utils.ServiceClaims{ + TaskID: "task-1", + RegisteredClaims: utils.ServiceClaims{}.RegisteredClaims, + }, nil +} +func (m middlewareStub) CheckUserPermission(context.Context, *dto.CheckPermissionParams) (bool, error) { + return m.allowed, nil +} +func (m middlewareStub) IsUserTeamAdmin(context.Context, int, int) (bool, error) { + return m.teamAdmin, nil +} +func (m middlewareStub) IsUserInTeam(context.Context, int, int) (bool, error) { + return m.teamMember, nil +} +func (m middlewareStub) IsTeamPublic(context.Context, int) (bool, error) { return m.teamPublic, nil } +func (m middlewareStub) IsUserProjectAdmin(context.Context, int, int) (bool, error) { + return m.projectAdmin, nil +} +func (m middlewareStub) IsUserInProject(context.Context, int, int) (bool, error) { + return m.projectMember, nil +} +func (middlewareStub) LogFailedAction(string, string, string, string, int, int, consts.ResourceName) error { + return nil +} +func (middlewareStub) LogUserAction(string, string, string, string, int, int, consts.ResourceName) error { + return nil +} + +var _ middleware.Service = middlewareStub{} + +type teamHandlerStub struct { + createResp *team.TeamResp + detailResp *team.TeamDetailResp + listResp *dto.ListResp[team.TeamResp] + projectsResp *dto.ListResp[team.TeamProjectItem] + membersResp *dto.ListResp[team.TeamMemberResp] + updateResp *team.TeamResp + createCalled bool + listCalled bool + listProjectsCalled bool +} + +func (s *teamHandlerStub) CreateTeam(context.Context, *team.CreateTeamReq, int) (*team.TeamResp, error) { + s.createCalled = true + return s.createResp, nil +} +func (*teamHandlerStub) DeleteTeam(context.Context, int) error { return nil } +func (s *teamHandlerStub) GetTeamDetail(context.Context, int) (*team.TeamDetailResp, error) { + return s.detailResp, nil +} +func (s *teamHandlerStub) ListTeams(context.Context, *team.ListTeamReq, int, bool) (*dto.ListResp[team.TeamResp], error) { + s.listCalled = true + return s.listResp, nil +} +func (s *teamHandlerStub) UpdateTeam(context.Context, *team.UpdateTeamReq, int) (*team.TeamResp, error) { + return s.updateResp, nil +} +func (s *teamHandlerStub) ListTeamProjects(context.Context, *team.TeamProjectListReq, int) (*dto.ListResp[team.TeamProjectItem], error) { + s.listProjectsCalled = true + return s.projectsResp, nil +} +func (*teamHandlerStub) AddMember(context.Context, *team.AddTeamMemberReq, int) error { + return nil +} +func (*teamHandlerStub) RemoveMember(context.Context, int, int, int) error { return nil } +func (*teamHandlerStub) UpdateMemberRole(context.Context, *team.UpdateTeamMemberRoleReq, int, int, int) error { + return nil +} +func (s *teamHandlerStub) ListMembers(context.Context, *team.ListTeamMemberReq, int) (*dto.ListResp[team.TeamMemberResp], error) { + return s.membersResp, nil +} + +func TestIAMServerVerifyTokenUser(t *testing.T) { + token, expiresAt, err := utils.GenerateToken(7, "demo", "demo@example.com", true, false, []string{"user"}) + if err != nil { + t.Fatalf("GenerateToken() error = %v", err) + } + + authSvc := auth.NewService(nil, nil, nil, nil) + server := newIAMServer(authSvc, authSvc, &teamHandlerStub{}, nil, nil, middlewareStub{allowed: true}) + resp, err := server.VerifyToken(context.Background(), &iamv1.VerifyTokenRequest{Token: token}) + if err != nil { + t.Fatalf("VerifyToken() error = %v", err) + } + + if !resp.Valid || resp.TokenType != "user" || resp.UserId != 7 { + t.Fatalf("VerifyToken() unexpected response: %+v", resp) + } + if resp.ExpiresAtUnix != expiresAt.Unix() { + t.Fatalf("VerifyToken() expires_at_unix = %d, want %d", resp.ExpiresAtUnix, expiresAt.Unix()) + } +} + +func TestIAMServerVerifyTokenAPIKeyScopes(t *testing.T) { + token, _, err := utils.GenerateAPIKeyToken(7, "demo", "demo@example.com", true, false, []string{"user"}, 11, []string{"project:read", "execution:write"}) + if err != nil { + t.Fatalf("GenerateAPIKeyToken() error = %v", err) + } + + authSvc := auth.NewService(nil, nil, nil, nil) + server := newIAMServer(authSvc, authSvc, &teamHandlerStub{}, nil, nil, middlewareStub{allowed: true}) + resp, err := server.VerifyToken(context.Background(), &iamv1.VerifyTokenRequest{Token: token}) + if err != nil { + t.Fatalf("VerifyToken() error = %v", err) + } + + if resp.AuthType != "api_key" || resp.KeyId != 11 { + t.Fatalf("VerifyToken() unexpected api-key response: %+v", resp) + } + if !reflect.DeepEqual(resp.ApiKeyScopes, []string{"project:read", "execution:write"}) { + t.Fatalf("VerifyToken() api_key_scopes = %v", resp.ApiKeyScopes) + } +} + +func TestIAMServerCheckPermission(t *testing.T) { + authSvc := auth.NewService(nil, nil, nil, nil) + server := newIAMServer(authSvc, authSvc, &teamHandlerStub{}, nil, nil, middlewareStub{allowed: true}) + resp, err := server.CheckPermission(context.Background(), &iamv1.CheckPermissionRequest{ + UserId: 7, + Action: string(consts.ActionRead), + Scope: string(consts.ScopeAll), + ResourceName: string(consts.ResourceProject), + }) + if err != nil { + t.Fatalf("CheckPermission() error = %v", err) + } + if !resp.Allowed { + t.Fatalf("CheckPermission() allowed = false, want true") + } +} + +func TestIAMServerVerifyTokenService(t *testing.T) { + token, _, err := utils.GenerateServiceToken("task-123") + if err != nil { + t.Fatalf("GenerateServiceToken() error = %v", err) + } + + authSvc := auth.NewService(nil, nil, nil, nil) + server := newIAMServer(authSvc, authSvc, &teamHandlerStub{}, nil, nil, middlewareStub{allowed: true}) + resp, err := server.VerifyToken(context.Background(), &iamv1.VerifyTokenRequest{Token: token}) + if err != nil { + t.Fatalf("VerifyToken() error = %v", err) + } + if !resp.Valid || resp.TokenType != "service" || resp.TaskId != "task-123" { + t.Fatalf("VerifyToken() unexpected service response: %+v", resp) + } + if resp.ExpiresAtUnix <= time.Now().Unix() { + t.Fatalf("VerifyToken() service expiry = %d, want future timestamp", resp.ExpiresAtUnix) + } +} + +func TestIAMServerMembershipChecks(t *testing.T) { + authSvc := auth.NewService(nil, nil, nil, nil) + server := newIAMServer(authSvc, authSvc, &teamHandlerStub{}, nil, nil, middlewareStub{ + teamAdmin: true, + teamMember: true, + teamPublic: true, + projectAdmin: true, + projectMember: true, + }) + + t.Run("team admin", func(t *testing.T) { + resp, err := server.IsUserTeamAdmin(context.Background(), &iamv1.UserTeamRequest{UserId: 7, TeamId: 9}) + if err != nil { + t.Fatalf("IsUserTeamAdmin() error = %v", err) + } + if !resp.GetValue() { + t.Fatalf("IsUserTeamAdmin() value = false, want true") + } + }) + + t.Run("team member", func(t *testing.T) { + resp, err := server.IsUserInTeam(context.Background(), &iamv1.UserTeamRequest{UserId: 7, TeamId: 9}) + if err != nil { + t.Fatalf("IsUserInTeam() error = %v", err) + } + if !resp.GetValue() { + t.Fatalf("IsUserInTeam() value = false, want true") + } + }) + + t.Run("team public", func(t *testing.T) { + resp, err := server.IsTeamPublic(context.Background(), &iamv1.TeamRequest{TeamId: 9}) + if err != nil { + t.Fatalf("IsTeamPublic() error = %v", err) + } + if !resp.GetValue() { + t.Fatalf("IsTeamPublic() value = false, want true") + } + }) + + t.Run("project admin", func(t *testing.T) { + resp, err := server.IsUserProjectAdmin(context.Background(), &iamv1.UserProjectRequest{UserId: 7, ProjectId: 11}) + if err != nil { + t.Fatalf("IsUserProjectAdmin() error = %v", err) + } + if !resp.GetValue() { + t.Fatalf("IsUserProjectAdmin() value = false, want true") + } + }) + + t.Run("project member", func(t *testing.T) { + resp, err := server.IsUserInProject(context.Background(), &iamv1.UserProjectRequest{UserId: 7, ProjectId: 11}) + if err != nil { + t.Fatalf("IsUserInProject() error = %v", err) + } + if !resp.GetValue() { + t.Fatalf("IsUserInProject() value = false, want true") + } + }) +} + +func TestIAMServerTeamRPCs(t *testing.T) { + teamStub := &teamHandlerStub{ + createResp: &team.TeamResp{ID: 9, Name: "core"}, + detailResp: &team.TeamDetailResp{ + TeamResp: team.TeamResp{ID: 9, Name: "core"}, + UserCount: 2, + ProjectCount: 3, + }, + listResp: &dto.ListResp[team.TeamResp]{ + Items: []team.TeamResp{{ID: 9, Name: "core"}}, + Pagination: &dto.PaginationInfo{Page: 1, Size: 20, Total: 1, TotalPages: 1}, + }, + projectsResp: &dto.ListResp[team.TeamProjectItem]{ + Items: []team.TeamProjectItem{{ID: 11, Name: "proj-a"}}, + Pagination: &dto.PaginationInfo{Page: 1, Size: 20, Total: 1, TotalPages: 1}, + }, + } + authSvc := auth.NewService(nil, nil, nil, nil) + server := newIAMServer(authSvc, authSvc, teamStub, nil, nil, middlewareStub{}) + + createBody, _ := structpb.NewStruct(map[string]any{"name": "core"}) + createResp, err := server.CreateTeam(context.Background(), &iamv1.CreateTeamRequest{ + UserId: 7, + Body: createBody, + }) + if err != nil { + t.Fatalf("CreateTeam() error = %v", err) + } + if createResp.GetData().AsMap()["id"] != float64(9) || !teamStub.createCalled { + t.Fatalf("CreateTeam() unexpected response: %+v", createResp.GetData().AsMap()) + } + + listQuery, _ := structpb.NewStruct(map[string]any{"page": 1, "size": 20}) + listResp, err := server.ListTeams(context.Background(), &iamv1.ListTeamsRequest{ + UserId: 7, + IsAdmin: true, + Query: listQuery, + }) + if err != nil { + t.Fatalf("ListTeams() error = %v", err) + } + items, ok := listResp.GetData().AsMap()["items"].([]any) + if !ok || len(items) != 1 || !teamStub.listCalled { + t.Fatalf("ListTeams() unexpected response: %+v", listResp.GetData().AsMap()) + } + + getResp, err := server.GetTeam(context.Background(), &iamv1.TeamRequest{TeamId: 9}) + if err != nil { + t.Fatalf("GetTeam() error = %v", err) + } + if getResp.GetData().AsMap()["project_count"] != float64(3) { + t.Fatalf("GetTeam() unexpected response: %+v", getResp.GetData().AsMap()) + } + + projectResp, err := server.ListTeamProjects(context.Background(), &iamv1.ListTeamProjectsRequest{ + TeamId: 9, + Query: listQuery, + }) + if err != nil { + t.Fatalf("ListTeamProjects() error = %v", err) + } + projectItems, ok := projectResp.GetData().AsMap()["items"].([]any) + if !ok || len(projectItems) != 1 || !teamStub.listProjectsCalled { + t.Fatalf("ListTeamProjects() unexpected response: %+v", projectResp.GetData().AsMap()) + } +} diff --git a/src/interface/grpc/orchestrator/lifecycle.go b/src/interface/grpc/orchestrator/lifecycle.go new file mode 100644 index 00000000..dbbec843 --- /dev/null +++ b/src/interface/grpc/orchestrator/lifecycle.go @@ -0,0 +1,94 @@ +package grpcorchestrator + +import ( + "context" + "fmt" + "net" + + "aegis/config" + "aegis/httpx" + orchestratorv1 "aegis/proto/orchestrator/v1" + + "github.com/sirupsen/logrus" + "go.uber.org/fx" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" +) + +const defaultOrchestratorGRPCAddr = ":9092" + +type Lifecycle struct { + server *grpc.Server + addr string + listener net.Listener + StartFunc func(context.Context) error + StopFunc func() +} + +func newLifecycle(orchestratorServer *orchestratorServer) (*Lifecycle, error) { + grpcServer := grpc.NewServer(grpc.UnaryInterceptor(httpx.UnaryServerRequestIDInterceptor())) + orchestratorv1.RegisterOrchestratorServiceServer(grpcServer, orchestratorServer) + + healthServer := health.NewServer() + healthServer.SetServingStatus(orchestratorv1.OrchestratorService_ServiceDesc.ServiceName, grpc_health_v1.HealthCheckResponse_SERVING) + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) + + if config.GetBool("orchestrator.grpc.reflection") { + reflection.Register(grpcServer) + } + + addr := config.GetString("orchestrator.grpc.addr") + if addr == "" { + addr = defaultOrchestratorGRPCAddr + } + + return &Lifecycle{ + server: grpcServer, + addr: addr, + }, nil +} + +func (r *Lifecycle) start(ctx context.Context) error { + if r.StartFunc != nil { + return r.StartFunc(ctx) + } + + listener, err := net.Listen("tcp", r.addr) + if err != nil { + return fmt.Errorf("listen orchestrator grpc on %s: %w", r.addr, err) + } + r.listener = listener + + go func() { + logrus.Infof("Starting orchestrator gRPC server on %s", r.addr) + if err := r.server.Serve(listener); err != nil { + logrus.Errorf("orchestrator gRPC server error: %v", err) + } + }() + return nil +} + +func (r *Lifecycle) stop() { + if r.StopFunc != nil { + r.StopFunc() + return + } + if r.server != nil { + r.server.GracefulStop() + } +} + +func registerLifecycle(lc fx.Lifecycle, runner *Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + return runner.start(ctx) + }, + OnStop: func(ctx context.Context) error { + runner.stop() + return nil + }, + }) +} diff --git a/src/interface/grpc/orchestrator/module.go b/src/interface/grpc/orchestrator/module.go new file mode 100644 index 00000000..ae7555a4 --- /dev/null +++ b/src/interface/grpc/orchestrator/module.go @@ -0,0 +1,18 @@ +package grpcorchestrator + +import ( + project "aegis/module/project" + + "go.uber.org/fx" +) + +var Module = fx.Module("grpc_orchestrator", + fx.Provide( + project.NewRepository, + newProjectStatisticsReader, + newTaskQueueController, + newOrchestratorServer, + newLifecycle, + ), + fx.Invoke(registerLifecycle), +) diff --git a/src/interface/grpc/orchestrator/project_statistics.go b/src/interface/grpc/orchestrator/project_statistics.go new file mode 100644 index 00000000..963de0c6 --- /dev/null +++ b/src/interface/grpc/orchestrator/project_statistics.go @@ -0,0 +1,22 @@ +package grpcorchestrator + +import ( + "aegis/dto" + project "aegis/module/project" +) + +type projectStatisticsReader interface { + ListProjectStatistics([]int) (map[int]*dto.ProjectStatistics, error) +} + +type projectRepositoryStatisticsReader struct { + repo *project.Repository +} + +func newProjectStatisticsReader(repo *project.Repository) projectStatisticsReader { + return &projectRepositoryStatisticsReader{repo: repo} +} + +func (r *projectRepositoryStatisticsReader) ListProjectStatistics(projectIDs []int) (map[int]*dto.ProjectStatistics, error) { + return r.repo.ListProjectStatistics(projectIDs) +} diff --git a/src/interface/grpc/orchestrator/service.go b/src/interface/grpc/orchestrator/service.go new file mode 100644 index 00000000..518c623d --- /dev/null +++ b/src/interface/grpc/orchestrator/service.go @@ -0,0 +1,843 @@ +package grpcorchestrator + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "aegis/consts" + "aegis/dto" + redisinfra "aegis/infra/redis" + execution "aegis/module/execution" + group "aegis/module/group" + injection "aegis/module/injection" + metric "aegis/module/metric" + notification "aegis/module/notification" + task "aegis/module/task" + trace "aegis/module/trace" + orchestratorv1 "aegis/proto/orchestrator/v1" + "aegis/service/consumer" + + "github.com/google/uuid" + goredis "github.com/redis/go-redis/v9" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +const orchestratorServiceName = "orchestrator-service" + +type executionSubmitter interface { + SubmitAlgorithmExecution(context.Context, *execution.SubmitExecutionReq, string, int) (*execution.SubmitExecutionResp, error) + CreateExecutionRecord(context.Context, *execution.RuntimeCreateExecutionReq) (int, error) + UpdateExecutionState(context.Context, *execution.RuntimeUpdateExecutionStateReq) error + GetExecution(context.Context, int) (*execution.ExecutionDetailResp, error) + ListEvaluationExecutionsByDatapack(context.Context, *execution.EvaluationExecutionsByDatapackReq) ([]execution.EvaluationExecutionItem, error) + ListEvaluationExecutionsByDataset(context.Context, *execution.EvaluationExecutionsByDatasetReq) ([]execution.EvaluationExecutionItem, error) +} + +type injectionSubmitter interface { + SubmitFaultInjection(context.Context, *injection.SubmitInjectionReq, string, int, *int) (*injection.SubmitInjectionResp, error) + SubmitDatapackBuilding(context.Context, *injection.SubmitDatapackBuildingReq, string, int, *int) (*injection.SubmitDatapackBuildingResp, error) + CreateInjectionRecord(context.Context, *injection.RuntimeCreateInjectionReq) (*dto.InjectionItem, error) + UpdateInjectionState(context.Context, *injection.RuntimeUpdateInjectionStateReq) error + UpdateInjectionTimestamps(context.Context, *injection.RuntimeUpdateInjectionTimestampReq) (*dto.InjectionItem, error) +} + +type metricsReader interface { + GetInjectionMetrics(context.Context, *metric.GetMetricsReq) (*metric.InjectionMetrics, error) + GetExecutionMetrics(context.Context, *metric.GetMetricsReq) (*metric.ExecutionMetrics, error) +} + +type taskReader interface { + GetDetail(context.Context, string) (*task.TaskDetailResp, error) + PollLogs(context.Context, string, time.Time) (*task.TaskLogPollResp, error) + List(context.Context, *task.ListTaskReq) (*dto.ListResp[task.TaskResp], error) +} + +type traceReader interface { + GetTrace(context.Context, string) (*trace.TraceDetailResp, error) + ListTraces(context.Context, *trace.ListTraceReq) (*dto.ListResp[trace.TraceResp], error) + GetTraceStreamAlgorithms(context.Context, string) ([]dto.ContainerVersionItem, error) + ReadTraceStreamMessages(context.Context, string, string, int64, time.Duration) ([]goredis.XStream, error) +} + +type groupReader interface { + GetGroupStats(context.Context, *group.GetGroupStatsReq) (*group.GroupStats, error) + GetGroupTraceCount(string) (int64, error) + ReadGroupStreamMessages(context.Context, string, string, int64, time.Duration) ([]goredis.XStream, error) +} + +type notificationReader interface { + ReadStreamMessages(context.Context, string, string, int64, time.Duration) ([]goredis.XStream, error) +} + +type taskController interface { + CancelTask(context.Context, string) error + RetryTask(context.Context, string) (string, error) + ListDeadLetterTasks(context.Context, int64) ([]QueuedTaskResp, error) +} + +type taskQueueController struct { + redis *redisinfra.Gateway +} + +type QueuedTaskResp struct { + TaskID string `json:"task_id"` + Type string `json:"type"` + Queue string `json:"queue"` + TraceID string `json:"trace_id"` + GroupID string `json:"group_id"` + ProjectID int `json:"project_id"` + UserID int `json:"user_id"` + Immediate bool `json:"immediate"` + ExecuteTime int64 `json:"execute_time"` + RestartNum int `json:"restart_num"` + State string `json:"state"` +} + +func newTaskQueueController(redis *redisinfra.Gateway) taskController { + return &taskQueueController{redis: redis} +} + +func (c *taskQueueController) CancelTask(_ context.Context, taskID string) error { + return consumer.CancelTask(c.redis, taskID) +} + +func (c *taskQueueController) RetryTask(ctx context.Context, taskID string) (string, error) { + queue, _, task, err := c.findTask(ctx, taskID) + if err != nil { + return "", err + } + + switch queue { + case redisinfra.ReadyQueueKey: + return queue, nil + case redisinfra.DelayedQueueKey, redisinfra.DeadLetterKey: + if ok := c.redis.RemoveFromZSet(ctx, queue, taskID); !ok { + return "", fmt.Errorf("%w: task %s not found in %s", consts.ErrNotFound, taskID, queue) + } + default: + return "", fmt.Errorf("%w: unsupported queue %s", consts.ErrBadRequest, queue) + } + + if err := c.redis.DeleteTaskIndex(ctx, taskID); err != nil { + return "", fmt.Errorf("delete task index: %w", err) + } + + task.State = consts.TaskPending + data, err := json.Marshal(task) + if err != nil { + return "", err + } + if task.ExecuteTime > time.Now().Unix() && !task.Immediate { + if err := c.redis.SubmitDelayedTask(ctx, data, task.TaskID, task.ExecuteTime); err != nil { + return "", err + } + return redisinfra.DelayedQueueKey, nil + } + + task.Immediate = true + task.ExecuteTime = time.Now().Unix() + data, err = json.Marshal(task) + if err != nil { + return "", err + } + if err := c.redis.SubmitImmediateTask(ctx, data, task.TaskID); err != nil { + return "", err + } + return redisinfra.ReadyQueueKey, nil +} + +func (c *taskQueueController) ListDeadLetterTasks(ctx context.Context, limit int64) ([]QueuedTaskResp, error) { + if limit <= 0 { + limit = 100 + } + items, err := c.redis.ListDeadLetterTasks(ctx, limit) + if err != nil { + return nil, err + } + return decodeQueuedTasks(items, redisinfra.DeadLetterKey) +} + +func (c *taskQueueController) findTask(ctx context.Context, taskID string) (string, string, *dto.UnifiedTask, error) { + if taskID == "" { + return "", "", nil, fmt.Errorf("%w: task_id is required", consts.ErrBadRequest) + } + + if queue, err := c.redis.GetTaskQueue(ctx, taskID); err == nil && queue != "" { + if taskData, task, ok := c.findTaskInQueue(ctx, queue, taskID); ok { + return queue, taskData, task, nil + } + } + + for _, queue := range []string{redisinfra.ReadyQueueKey, redisinfra.DelayedQueueKey, redisinfra.DeadLetterKey} { + if taskData, task, ok := c.findTaskInQueue(ctx, queue, taskID); ok { + return queue, taskData, task, nil + } + } + + return "", "", nil, fmt.Errorf("%w: task %s not found", consts.ErrNotFound, taskID) +} + +func (c *taskQueueController) findTaskInQueue(ctx context.Context, queue, taskID string) (string, *dto.UnifiedTask, bool) { + var items []string + var err error + + switch queue { + case redisinfra.ReadyQueueKey: + items, err = c.redis.ListReadyTasks(ctx) + case redisinfra.DelayedQueueKey: + items, err = c.redis.ListDelayedTasks(ctx, 1000) + case redisinfra.DeadLetterKey: + items, err = c.redis.ListDeadLetterTasks(ctx, 1000) + default: + return "", nil, false + } + if err != nil { + return "", nil, false + } + + for _, item := range items { + var task dto.UnifiedTask + if json.Unmarshal([]byte(item), &task) == nil && task.TaskID == taskID { + return item, &task, true + } + } + return "", nil, false +} + +func decodeQueuedTasks(items []string, queue string) ([]QueuedTaskResp, error) { + result := make([]QueuedTaskResp, 0, len(items)) + for _, item := range items { + var task dto.UnifiedTask + if err := json.Unmarshal([]byte(item), &task); err != nil { + return nil, err + } + result = append(result, QueuedTaskResp{ + TaskID: task.TaskID, + Type: consts.GetTaskTypeName(task.Type), + Queue: queue, + TraceID: task.TraceID, + GroupID: task.GroupID, + ProjectID: task.ProjectID, + UserID: task.UserID, + Immediate: task.Immediate, + ExecuteTime: task.ExecuteTime, + RestartNum: task.ReStartNum, + State: consts.GetTaskStateName(task.State), + }) + } + return result, nil +} + +type orchestratorServer struct { + orchestratorv1.UnimplementedOrchestratorServiceServer + execution executionSubmitter + injection injectionSubmitter + metrics metricsReader + projects projectStatisticsReader + tasks taskController + taskRead taskReader + traceRead traceReader + groupRead groupReader + notify notificationReader +} + +func newOrchestratorServer( + execution *execution.Service, + injection *injection.Service, + metrics *metric.Service, + projects projectStatisticsReader, + tasks taskController, + taskRead *task.Service, + traceRead *trace.Service, + groupRead *group.Service, + notify *notification.Service, +) *orchestratorServer { + return &orchestratorServer{ + execution: execution, + injection: injection, + metrics: metrics, + projects: projects, + tasks: tasks, + taskRead: taskRead, + traceRead: traceRead, + groupRead: groupRead, + notify: notify, + } +} + +func (s *orchestratorServer) Ping(context.Context, *orchestratorv1.PingRequest) (*orchestratorv1.PingResponse, error) { + return &orchestratorv1.PingResponse{ + Service: orchestratorServiceName, + AppId: consts.AppID, + Status: "ok", + TimestampUnix: time.Now().Unix(), + }, nil +} + +func (s *orchestratorServer) SubmitExecution(ctx context.Context, req *orchestratorv1.SubmitExecutionRequest) (*orchestratorv1.SubmitExecutionResponse, error) { + if req.GetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + + body, err := decodeBody[execution.SubmitExecutionReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.execution.SubmitAlgorithmExecution(ctx, body, resolveGroupID(req.GetGroupId()), int(req.GetUserId())) + if err != nil { + return nil, mapOrchestratorError(err) + } + + items := make([]*orchestratorv1.SubmittedExecutionItem, 0, len(resp.Items)) + for _, item := range resp.Items { + pbItem := &orchestratorv1.SubmittedExecutionItem{ + Index: int64(item.Index), + TraceId: item.TraceID, + TaskId: item.TaskID, + AlgorithmId: int64(item.AlgorithmID), + AlgorithmVersionId: int64(item.AlgorithmVersionID), + } + if item.DatapackID != nil { + pbItem.HasDatapackId = true + pbItem.DatapackId = int64(*item.DatapackID) + } + if item.DatasetID != nil { + pbItem.HasDatasetId = true + pbItem.DatasetId = int64(*item.DatasetID) + } + items = append(items, pbItem) + } + + return &orchestratorv1.SubmitExecutionResponse{ + GroupId: resp.GroupID, + Items: items, + }, nil +} + +func (s *orchestratorServer) SubmitFaultInjection(ctx context.Context, req *orchestratorv1.SubmitFaultInjectionRequest) (*orchestratorv1.SubmitFaultInjectionResponse, error) { + if req.GetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + + body, err := decodeBody[injection.SubmitInjectionReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.injection.SubmitFaultInjection(ctx, body, resolveGroupID(req.GetGroupId()), int(req.GetUserId()), optionalID(req.GetProjectId())) + if err != nil { + return nil, mapOrchestratorError(err) + } + + items := make([]*orchestratorv1.SubmittedInjectionItem, 0, len(resp.Items)) + for _, item := range resp.Items { + items = append(items, &orchestratorv1.SubmittedInjectionItem{ + Index: int64(item.Index), + TraceId: item.TraceID, + TaskId: item.TaskID, + }) + } + + result := &orchestratorv1.SubmitFaultInjectionResponse{ + GroupId: resp.GroupID, + Items: items, + OriginalCount: int64(resp.OriginalCount), + } + if resp.Warnings != nil { + result.Warnings = &orchestratorv1.InjectionWarnings{ + DuplicateServicesInBatch: resp.Warnings.DuplicateServicesInBatch, + DuplicateBatchesInRequest: intsToInt64s( + resp.Warnings.DuplicateBatchesInRequest, + ), + BatchesExistInDatabase: intsToInt64s(resp.Warnings.BatchesExistInDatabase), + } + } + return result, nil +} + +func (s *orchestratorServer) SubmitDatapackBuilding(ctx context.Context, req *orchestratorv1.SubmitDatapackBuildingRequest) (*orchestratorv1.SubmitDatapackBuildingResponse, error) { + if req.GetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + + body, err := decodeBody[injection.SubmitDatapackBuildingReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.injection.SubmitDatapackBuilding(ctx, body, resolveGroupID(req.GetGroupId()), int(req.GetUserId()), optionalID(req.GetProjectId())) + if err != nil { + return nil, mapOrchestratorError(err) + } + + items := make([]*orchestratorv1.SubmittedBuildingItem, 0, len(resp.Items)) + for _, item := range resp.Items { + items = append(items, &orchestratorv1.SubmittedBuildingItem{ + Index: int64(item.Index), + TraceId: item.TraceID, + TaskId: item.TaskID, + }) + } + + return &orchestratorv1.SubmitDatapackBuildingResponse{ + GroupId: resp.GroupID, + Items: items, + }, nil +} + +func (s *orchestratorServer) CreateExecution(ctx context.Context, req *orchestratorv1.MutationRequest) (*orchestratorv1.StructResponse, error) { + body, err := decodeBody[execution.RuntimeCreateExecutionReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + executionID, err := s.execution.CreateExecutionRecord(ctx, body) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(map[string]any{"execution_id": executionID}) +} + +func (s *orchestratorServer) CreateInjection(ctx context.Context, req *orchestratorv1.MutationRequest) (*orchestratorv1.StructResponse, error) { + body, err := decodeBody[injection.RuntimeCreateInjectionReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.injection.CreateInjectionRecord(ctx, body) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(resp) +} + +func (s *orchestratorServer) UpdateExecutionState(ctx context.Context, req *orchestratorv1.MutationRequest) (*orchestratorv1.StructResponse, error) { + body, err := decodeBody[execution.RuntimeUpdateExecutionStateReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := s.execution.UpdateExecutionState(ctx, body); err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(map[string]any{"updated": true}) +} + +func (s *orchestratorServer) UpdateInjectionState(ctx context.Context, req *orchestratorv1.MutationRequest) (*orchestratorv1.StructResponse, error) { + body, err := decodeBody[injection.RuntimeUpdateInjectionStateReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := s.injection.UpdateInjectionState(ctx, body); err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(map[string]any{"updated": true}) +} + +func (s *orchestratorServer) UpdateInjectionTimestamps(ctx context.Context, req *orchestratorv1.MutationRequest) (*orchestratorv1.StructResponse, error) { + body, err := decodeBody[injection.RuntimeUpdateInjectionTimestampReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.injection.UpdateInjectionTimestamps(ctx, body) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(resp) +} + +func (s *orchestratorServer) CancelTask(ctx context.Context, req *orchestratorv1.CancelTaskRequest) (*orchestratorv1.CancelTaskResponse, error) { + if req.GetTaskId() == "" { + return nil, status.Error(codes.InvalidArgument, "task_id is required") + } + + if err := s.tasks.CancelTask(ctx, req.GetTaskId()); err != nil { + return nil, mapOrchestratorError(err) + } + return &orchestratorv1.CancelTaskResponse{Cancelled: true}, nil +} + +func (s *orchestratorServer) GetExecution(ctx context.Context, req *orchestratorv1.GetExecutionRequest) (*orchestratorv1.StructResponse, error) { + if req.GetExecutionId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "execution_id is required") + } + resp, err := s.execution.GetExecution(ctx, int(req.GetExecutionId())) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(resp) +} + +func (s *orchestratorServer) GetInjectionMetrics(ctx context.Context, req *orchestratorv1.MutationRequest) (*orchestratorv1.StructResponse, error) { + body, err := decodeBody[metric.GetMetricsReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if s.metrics == nil { + return nil, status.Error(codes.FailedPrecondition, "metrics service is not configured") + } + resp, err := s.metrics.GetInjectionMetrics(ctx, body) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(resp) +} + +func (s *orchestratorServer) GetExecutionMetrics(ctx context.Context, req *orchestratorv1.MutationRequest) (*orchestratorv1.StructResponse, error) { + body, err := decodeBody[metric.GetMetricsReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if s.metrics == nil { + return nil, status.Error(codes.FailedPrecondition, "metrics service is not configured") + } + resp, err := s.metrics.GetExecutionMetrics(ctx, body) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(resp) +} + +func (s *orchestratorServer) ListProjectStatistics(ctx context.Context, req *orchestratorv1.ListProjectStatisticsRequest) (*orchestratorv1.StructResponse, error) { + projectIDs := int64sToInts(req.GetProjectIds()) + for _, projectID := range projectIDs { + if projectID <= 0 { + return nil, status.Error(codes.InvalidArgument, "project_ids must be greater than 0") + } + } + resp, err := s.projects.ListProjectStatistics(projectIDs) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(resp) +} + +func (s *orchestratorServer) ListEvaluationExecutionsByDatapack(ctx context.Context, req *orchestratorv1.MutationRequest) (*orchestratorv1.StructResponse, error) { + body, err := decodeBody[execution.EvaluationExecutionsByDatapackReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.execution.ListEvaluationExecutionsByDatapack(ctx, body) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(map[string]any{"items": resp}) +} + +func (s *orchestratorServer) ListEvaluationExecutionsByDataset(ctx context.Context, req *orchestratorv1.MutationRequest) (*orchestratorv1.StructResponse, error) { + body, err := decodeBody[execution.EvaluationExecutionsByDatasetReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.execution.ListEvaluationExecutionsByDataset(ctx, body) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(map[string]any{"items": resp}) +} + +func (s *orchestratorServer) GetTask(ctx context.Context, req *orchestratorv1.GetTaskRequest) (*orchestratorv1.StructResponse, error) { + if req.GetTaskId() == "" { + return nil, status.Error(codes.InvalidArgument, "task_id is required") + } + resp, err := s.taskRead.GetDetail(ctx, req.GetTaskId()) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(resp) +} + +func (s *orchestratorServer) PollTaskLogs(ctx context.Context, req *orchestratorv1.PollTaskLogsRequest) (*orchestratorv1.StructResponse, error) { + if req.GetTaskId() == "" { + return nil, status.Error(codes.InvalidArgument, "task_id is required") + } + after := time.Time{} + if req.GetAfterUnixNano() > 0 { + after = time.Unix(0, req.GetAfterUnixNano()) + } + resp, err := s.taskRead.PollLogs(ctx, req.GetTaskId(), after) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(resp) +} + +func (s *orchestratorServer) ListTasks(ctx context.Context, req *orchestratorv1.ListTasksRequest) (*orchestratorv1.StructResponse, error) { + query, err := decodeQuery[task.ListTaskReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.taskRead.List(ctx, query) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(resp) +} + +func (s *orchestratorServer) GetTrace(ctx context.Context, req *orchestratorv1.GetTraceRequest) (*orchestratorv1.StructResponse, error) { + if req.GetTraceId() == "" { + return nil, status.Error(codes.InvalidArgument, "trace_id is required") + } + resp, err := s.traceRead.GetTrace(ctx, req.GetTraceId()) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(resp) +} + +func (s *orchestratorServer) ListTraces(ctx context.Context, req *orchestratorv1.ListTracesRequest) (*orchestratorv1.StructResponse, error) { + query, err := decodeQuery[trace.ListTraceReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + resp, err := s.traceRead.ListTraces(ctx, query) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(resp) +} + +func (s *orchestratorServer) GetGroupStats(ctx context.Context, req *orchestratorv1.GetGroupStatsRequest) (*orchestratorv1.StructResponse, error) { + if req.GetGroupId() == "" { + return nil, status.Error(codes.InvalidArgument, "group_id is required") + } + resp, err := s.groupRead.GetGroupStats(ctx, &group.GetGroupStatsReq{GroupID: req.GetGroupId()}) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(resp) +} + +func (s *orchestratorServer) GetTraceStreamState(ctx context.Context, req *orchestratorv1.GetTraceStreamStateRequest) (*orchestratorv1.StructResponse, error) { + if req.GetTraceId() == "" { + return nil, status.Error(codes.InvalidArgument, "trace_id is required") + } + algorithms, err := s.traceRead.GetTraceStreamAlgorithms(ctx, req.GetTraceId()) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(traceStreamStateResp{Algorithms: algorithms}) +} + +func (s *orchestratorServer) ReadTraceStreamMessages(ctx context.Context, req *orchestratorv1.ReadStreamMessagesRequest) (*orchestratorv1.StructResponse, error) { + if req.GetStreamKey() == "" { + return nil, status.Error(codes.InvalidArgument, "stream_key is required") + } + resp, err := s.traceRead.ReadTraceStreamMessages(ctx, req.GetStreamKey(), req.GetLastId(), req.GetCount(), time.Duration(req.GetBlockMillis())*time.Millisecond) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStreamMessages(resp) +} + +func (s *orchestratorServer) GetGroupStreamState(ctx context.Context, req *orchestratorv1.GetGroupStreamStateRequest) (*orchestratorv1.StructResponse, error) { + if req.GetGroupId() == "" { + return nil, status.Error(codes.InvalidArgument, "group_id is required") + } + totalTraces, err := s.groupRead.GetGroupTraceCount(req.GetGroupId()) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(groupStreamStateResp{TotalTraces: int(totalTraces)}) +} + +func (s *orchestratorServer) ReadGroupStreamMessages(ctx context.Context, req *orchestratorv1.ReadStreamMessagesRequest) (*orchestratorv1.StructResponse, error) { + if req.GetStreamKey() == "" { + return nil, status.Error(codes.InvalidArgument, "stream_key is required") + } + resp, err := s.groupRead.ReadGroupStreamMessages(ctx, req.GetStreamKey(), req.GetLastId(), req.GetCount(), time.Duration(req.GetBlockMillis())*time.Millisecond) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStreamMessages(resp) +} + +func (s *orchestratorServer) ReadNotificationStreamMessages(ctx context.Context, req *orchestratorv1.ReadStreamMessagesRequest) (*orchestratorv1.StructResponse, error) { + if req.GetStreamKey() == "" { + return nil, status.Error(codes.InvalidArgument, "stream_key is required") + } + if s.notify == nil { + return nil, status.Error(codes.FailedPrecondition, "notification service is not configured") + } + resp, err := s.notify.ReadStreamMessages(ctx, req.GetStreamKey(), req.GetLastId(), req.GetCount(), time.Duration(req.GetBlockMillis())*time.Millisecond) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStreamMessages(resp) +} + +func (s *orchestratorServer) ListDeadLetterTasks(ctx context.Context, req *orchestratorv1.ListDeadLetterTasksRequest) (*orchestratorv1.StructResponse, error) { + resp, err := s.tasks.ListDeadLetterTasks(ctx, req.GetLimit()) + if err != nil { + return nil, mapOrchestratorError(err) + } + return encodeStruct(map[string]any{"items": resp}) +} + +func (s *orchestratorServer) RetryTask(ctx context.Context, req *orchestratorv1.RetryTaskRequest) (*orchestratorv1.RetryTaskResponse, error) { + if req.GetTaskId() == "" { + return nil, status.Error(codes.InvalidArgument, "task_id is required") + } + queue, err := s.tasks.RetryTask(ctx, req.GetTaskId()) + if err != nil { + return nil, mapOrchestratorError(err) + } + return &orchestratorv1.RetryTaskResponse{Accepted: true, Queue: queue}, nil +} + +func resolveGroupID(groupID string) string { + if groupID != "" { + return groupID + } + return uuid.NewString() +} + +func optionalID(value int64) *int { + if value <= 0 { + return nil + } + id := int(value) + return &id +} + +func intsToInt64s(items []int) []int64 { + if len(items) == 0 { + return nil + } + result := make([]int64, 0, len(items)) + for _, item := range items { + result = append(result, int64(item)) + } + return result +} + +func int64sToInts(items []int64) []int { + if len(items) == 0 { + return nil + } + result := make([]int, 0, len(items)) + for _, item := range items { + result = append(result, int(item)) + } + return result +} + +func decodeBody[T any](body *structpb.Struct) (*T, error) { + if body == nil { + return nil, errors.New("body is required") + } + + data, err := json.Marshal(body.AsMap()) + if err != nil { + return nil, err + } + + var result T + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +func decodeQuery[T any](query *structpb.Struct) (*T, error) { + var result T + if query == nil { + return &result, nil + } + + data, err := json.Marshal(query.AsMap()) + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +func encodeStruct(value any) (*orchestratorv1.StructResponse, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + payload := map[string]any{} + if err := json.Unmarshal(data, &payload); err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + item, err := structpb.NewStruct(payload) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return &orchestratorv1.StructResponse{Data: item}, nil +} + +type traceStreamStateResp struct { + Algorithms []dto.ContainerVersionItem `json:"algorithms"` +} + +type groupStreamStateResp struct { + TotalTraces int `json:"total_traces"` +} + +type streamBatchResp struct { + Messages []streamMessageResp `json:"messages"` +} + +type streamMessageResp struct { + ID string `json:"id"` + Values map[string]any `json:"values"` +} + +func encodeStreamMessages(streams []goredis.XStream) (*orchestratorv1.StructResponse, error) { + messages := []streamMessageResp{} + if len(streams) > 0 { + messages = make([]streamMessageResp, 0, len(streams[0].Messages)) + for _, item := range streams[0].Messages { + messages = append(messages, streamMessageResp{ + ID: item.ID, + Values: item.Values, + }) + } + } + return encodeStruct(streamBatchResp{Messages: messages}) +} + +func mapOrchestratorError(err error) error { + switch { + case errors.Is(err, consts.ErrAuthenticationFailed): + return status.Error(codes.Unauthenticated, err.Error()) + case errors.Is(err, consts.ErrPermissionDenied): + return status.Error(codes.PermissionDenied, err.Error()) + case errors.Is(err, consts.ErrBadRequest): + return status.Error(codes.InvalidArgument, err.Error()) + case errors.Is(err, consts.ErrNotFound): + return status.Error(codes.NotFound, err.Error()) + case errors.Is(err, consts.ErrAlreadyExists): + return status.Error(codes.AlreadyExists, err.Error()) + case err != nil: + return status.Error(codes.Internal, err.Error()) + default: + return nil + } +} diff --git a/src/interface/grpc/orchestrator/service_test.go b/src/interface/grpc/orchestrator/service_test.go new file mode 100644 index 00000000..d60739c8 --- /dev/null +++ b/src/interface/grpc/orchestrator/service_test.go @@ -0,0 +1,888 @@ +package grpcorchestrator + +import ( + "context" + "errors" + "testing" + "time" + + "aegis/consts" + "aegis/dto" + execution "aegis/module/execution" + group "aegis/module/group" + injection "aegis/module/injection" + metric "aegis/module/metric" + task "aegis/module/task" + trace "aegis/module/trace" + orchestratorv1 "aegis/proto/orchestrator/v1" + + "github.com/redis/go-redis/v9" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +type executionSubmitterStub struct { + resp *execution.SubmitExecutionResp + id int + item *execution.ExecutionDetailResp + evaluationItems []execution.EvaluationExecutionItem + err error +} + +func (s executionSubmitterStub) SubmitAlgorithmExecution(_ context.Context, req *execution.SubmitExecutionReq, groupID string, userID int) (*execution.SubmitExecutionResp, error) { + if req.ProjectName == "" || groupID == "" || userID <= 0 { + return nil, errors.New("unexpected request") + } + return s.resp, s.err +} + +func (s executionSubmitterStub) CreateExecutionRecord(_ context.Context, req *execution.RuntimeCreateExecutionReq) (int, error) { + if req.TaskID == "" || req.AlgorithmVersionID <= 0 || req.DatapackID <= 0 { + return 0, errors.New("unexpected runtime execution request") + } + return s.id, s.err +} + +func (s executionSubmitterStub) UpdateExecutionState(_ context.Context, req *execution.RuntimeUpdateExecutionStateReq) error { + if req.ExecutionID <= 0 { + return errors.New("unexpected execution state request") + } + return s.err +} + +func (s executionSubmitterStub) GetExecution(_ context.Context, executionID int) (*execution.ExecutionDetailResp, error) { + if executionID <= 0 { + return nil, errors.New("missing execution id") + } + return s.item, s.err +} + +func (s executionSubmitterStub) ListEvaluationExecutionsByDatapack(_ context.Context, req *execution.EvaluationExecutionsByDatapackReq) ([]execution.EvaluationExecutionItem, error) { + if req.AlgorithmVersionID <= 0 || req.DatapackName == "" { + return nil, errors.New("unexpected datapack evaluation query") + } + return s.evaluationItems, s.err +} + +func (s executionSubmitterStub) ListEvaluationExecutionsByDataset(_ context.Context, req *execution.EvaluationExecutionsByDatasetReq) ([]execution.EvaluationExecutionItem, error) { + if req.AlgorithmVersionID <= 0 || req.DatasetVersionID <= 0 { + return nil, errors.New("unexpected dataset evaluation query") + } + return s.evaluationItems, s.err +} + +type injectionSubmitterStub struct { + injectionResp *injection.SubmitInjectionResp + buildResp *injection.SubmitDatapackBuildingResp + item *dto.InjectionItem + err error +} + +func (s injectionSubmitterStub) SubmitFaultInjection(_ context.Context, req *injection.SubmitInjectionReq, groupID string, userID int, projectID *int) (*injection.SubmitInjectionResp, error) { + if req.Pedestal == nil || req.Benchmark == nil || groupID == "" || userID <= 0 { + return nil, errors.New("unexpected injection request") + } + if projectID == nil || *projectID != 9 { + return nil, errors.New("unexpected project id") + } + return s.injectionResp, s.err +} + +func (s injectionSubmitterStub) SubmitDatapackBuilding(_ context.Context, req *injection.SubmitDatapackBuildingReq, groupID string, userID int, projectID *int) (*injection.SubmitDatapackBuildingResp, error) { + if len(req.Specs) == 0 || groupID == "" || userID <= 0 { + return nil, errors.New("unexpected datapack request") + } + if projectID == nil || *projectID != 5 { + return nil, errors.New("unexpected project id") + } + return s.buildResp, s.err +} + +func (s injectionSubmitterStub) CreateInjectionRecord(_ context.Context, req *injection.RuntimeCreateInjectionReq) (*dto.InjectionItem, error) { + if req.Name == "" || req.TaskID == "" { + return nil, errors.New("unexpected runtime injection request") + } + return s.item, s.err +} + +type metricsReaderStub struct { + injection *metric.InjectionMetrics + execution *metric.ExecutionMetrics + err error +} + +func (s metricsReaderStub) GetInjectionMetrics(_ context.Context, req *metric.GetMetricsReq) (*metric.InjectionMetrics, error) { + if req == nil { + return nil, errors.New("nil request") + } + return s.injection, s.err +} + +func (s metricsReaderStub) GetExecutionMetrics(_ context.Context, req *metric.GetMetricsReq) (*metric.ExecutionMetrics, error) { + if req == nil { + return nil, errors.New("nil request") + } + return s.execution, s.err +} + +func (s injectionSubmitterStub) UpdateInjectionState(_ context.Context, req *injection.RuntimeUpdateInjectionStateReq) error { + if req.Name == "" { + return errors.New("unexpected injection state request") + } + return s.err +} + +func (s injectionSubmitterStub) UpdateInjectionTimestamps(_ context.Context, req *injection.RuntimeUpdateInjectionTimestampReq) (*dto.InjectionItem, error) { + if req.Name == "" { + return nil, errors.New("unexpected injection timestamp request") + } + return s.item, s.err +} + +type taskControllerStub struct { + taskID string + queue string + listResp []QueuedTaskResp + err error +} + +func (s taskControllerStub) CancelTask(_ context.Context, taskID string) error { + if taskID == "" { + return errors.New("missing task id") + } + if s.taskID != "" && s.taskID != taskID { + return errors.New("unexpected task id") + } + return s.err +} + +func (s taskControllerStub) RetryTask(_ context.Context, taskID string) (string, error) { + if taskID == "" { + return "", errors.New("missing task id") + } + if s.taskID != "" && s.taskID != taskID { + return "", errors.New("unexpected task id") + } + return s.queue, s.err +} + +func (s taskControllerStub) ListDeadLetterTasks(_ context.Context, limit int64) ([]QueuedTaskResp, error) { + if limit == 0 { + return s.listResp, s.err + } + return s.listResp, s.err +} + +type taskReaderStub struct { + detail *task.TaskDetailResp + list *dto.ListResp[task.TaskResp] + err error +} + +func (s taskReaderStub) GetDetail(_ context.Context, taskID string) (*task.TaskDetailResp, error) { + if taskID == "" { + return nil, errors.New("missing task id") + } + return s.detail, s.err +} + +func (s taskReaderStub) PollLogs(_ context.Context, taskID string, _ time.Time) (*task.TaskLogPollResp, error) { + if taskID == "" { + return nil, errors.New("missing task id") + } + return &task.TaskLogPollResp{ + Logs: []dto.LogEntry{{TaskID: taskID, Line: "hello"}}, + Terminal: false, + State: consts.GetTaskStateName(consts.TaskPending), + CreatedAt: time.Unix(1710000000, 0), + }, s.err +} + +func (s taskReaderStub) List(_ context.Context, req *task.ListTaskReq) (*dto.ListResp[task.TaskResp], error) { + if req == nil { + return nil, errors.New("nil request") + } + return s.list, s.err +} + +type traceReaderStub struct { + detail *trace.TraceDetailResp + list *dto.ListResp[trace.TraceResp] + algorithms []dto.ContainerVersionItem + messages []redis.XStream + err error +} + +func (s traceReaderStub) GetTrace(_ context.Context, traceID string) (*trace.TraceDetailResp, error) { + if traceID == "" { + return nil, errors.New("missing trace id") + } + return s.detail, s.err +} + +func (s traceReaderStub) ListTraces(_ context.Context, req *trace.ListTraceReq) (*dto.ListResp[trace.TraceResp], error) { + if req == nil { + return nil, errors.New("nil request") + } + return s.list, s.err +} + +func (s traceReaderStub) GetTraceStreamAlgorithms(_ context.Context, traceID string) ([]dto.ContainerVersionItem, error) { + if traceID == "" { + return nil, errors.New("missing trace id") + } + return s.algorithms, s.err +} + +func (s traceReaderStub) ReadTraceStreamMessages(_ context.Context, streamKey, _ string, _ int64, _ time.Duration) ([]redis.XStream, error) { + if streamKey == "" { + return nil, errors.New("missing stream key") + } + return s.messages, s.err +} + +type groupReaderStub struct { + stats *group.GroupStats + count int64 + messages []redis.XStream + err error +} + +func (s groupReaderStub) GetGroupStats(_ context.Context, req *group.GetGroupStatsReq) (*group.GroupStats, error) { + if req == nil || req.GroupID == "" { + return nil, errors.New("missing group id") + } + return s.stats, s.err +} + +func (s groupReaderStub) GetGroupTraceCount(groupID string) (int64, error) { + if groupID == "" { + return 0, errors.New("missing group id") + } + if s.count == 0 { + return 1, s.err + } + return s.count, s.err +} + +func (s groupReaderStub) ReadGroupStreamMessages(_ context.Context, streamKey, _ string, _ int64, _ time.Duration) ([]redis.XStream, error) { + if streamKey == "" { + return nil, errors.New("missing stream key") + } + return s.messages, s.err +} + +type notificationReaderStub struct { + messages []redis.XStream + err error +} + +func (s notificationReaderStub) ReadStreamMessages(_ context.Context, streamKey, _ string, _ int64, _ time.Duration) ([]redis.XStream, error) { + if streamKey == "" { + return nil, errors.New("missing stream key") + } + return s.messages, s.err +} + +func TestOrchestratorServerSubmitExecution(t *testing.T) { + server := &orchestratorServer{ + execution: executionSubmitterStub{resp: &execution.SubmitExecutionResp{ + GroupID: "group-1", + Items: []execution.SubmitExecutionItem{{ + Index: 0, + TraceID: "trace-1", + TaskID: "task-1", + AlgorithmID: 11, + AlgorithmVersionID: 12, + }}, + }}, + injection: injectionSubmitterStub{}, + metrics: metricsReaderStub{}, + tasks: taskControllerStub{}, + taskRead: taskReaderStub{}, + traceRead: traceReaderStub{}, + groupRead: groupReaderStub{}, + notify: notificationReaderStub{}, + } + + body, err := structpb.NewStruct(map[string]any{ + "project_name": "demo", + "specs": []any{ + map[string]any{ + "algorithm": map[string]any{ + "name": "algo", + "version": "1.0.0", + }, + "datapack": "dp-1", + }, + }, + }) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + + resp, err := server.SubmitExecution(context.Background(), &orchestratorv1.SubmitExecutionRequest{ + GroupId: "group-1", + UserId: 7, + Body: body, + }) + if err != nil { + t.Fatalf("SubmitExecution() error = %v", err) + } + if resp.GroupId != "group-1" || len(resp.Items) != 1 || resp.Items[0].TaskId != "task-1" { + t.Fatalf("SubmitExecution() unexpected response: %+v", resp) + } +} + +func TestOrchestratorServerSubmitFaultInjection(t *testing.T) { + server := &orchestratorServer{ + execution: executionSubmitterStub{}, + injection: injectionSubmitterStub{injectionResp: &injection.SubmitInjectionResp{ + GroupID: "group-2", + OriginalCount: 1, + Items: []injection.SubmitInjectionItem{{ + Index: 0, + TraceID: "trace-2", + TaskID: "task-2", + }}, + Warnings: &injection.InjectionWarnings{ + DuplicateServicesInBatch: []string{"svc-a"}, + }, + }}, + metrics: metricsReaderStub{}, + tasks: taskControllerStub{}, + taskRead: taskReaderStub{}, + traceRead: traceReaderStub{}, + groupRead: groupReaderStub{}, + notify: notificationReaderStub{}, + } + + body, err := structpb.NewStruct(map[string]any{ + "project_name": "demo", + "pedestal": map[string]any{ + "name": "pedestal", + "version": "1.0.0", + }, + "benchmark": map[string]any{ + "name": "bench", + "version": "1.0.0", + }, + "interval": 10, + "pre_duration": 5, + "specs": []any{ + []any{ + map[string]any{ + "type": "PodChaos", + "name": "fault-a", + }, + }, + }, + }) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + + resp, err := server.SubmitFaultInjection(context.Background(), &orchestratorv1.SubmitFaultInjectionRequest{ + GroupId: "group-2", + UserId: 8, + ProjectId: 9, + Body: body, + }) + if err != nil { + t.Fatalf("SubmitFaultInjection() error = %v", err) + } + if resp.GroupId != "group-2" || len(resp.Items) != 1 || resp.Warnings == nil { + t.Fatalf("SubmitFaultInjection() unexpected response: %+v", resp) + } +} + +func TestOrchestratorServerRuntimeMutations(t *testing.T) { + injectionItem := &dto.InjectionItem{ID: 33, Name: "dp-1"} + server := &orchestratorServer{ + execution: executionSubmitterStub{ + id: 22, + item: &execution.ExecutionDetailResp{ + ExecutionResp: execution.ExecutionResp{ID: 22}, + }, + evaluationItems: []execution.EvaluationExecutionItem{{ + Datapack: "dp-1", + ExecutionRef: execution.ExecutionRef{ + ExecutionID: 22, + }, + }}, + }, + injection: injectionSubmitterStub{item: injectionItem}, + metrics: metricsReaderStub{ + injection: &metric.InjectionMetrics{TotalCount: 3}, + execution: &metric.ExecutionMetrics{TotalCount: 4}, + }, + tasks: taskControllerStub{}, + taskRead: taskReaderStub{}, + traceRead: traceReaderStub{}, + groupRead: groupReaderStub{}, + notify: notificationReaderStub{}, + } + + body, err := structpb.NewStruct(map[string]any{ + "task_id": "task-1", + "algorithm_version_id": 10, + "datapack_id": 11, + }) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + resp, err := server.CreateExecution(context.Background(), &orchestratorv1.MutationRequest{Body: body}) + if err != nil { + t.Fatalf("CreateExecution() error = %v", err) + } + if resp.GetData().AsMap()["execution_id"] != float64(22) { + t.Fatalf("CreateExecution() unexpected response: %+v", resp.GetData().AsMap()) + } + + injectionBody, err := structpb.NewStruct(map[string]any{ + "name": "dp-1", + "task_id": "task-2", + "display_config": "{}", + "engine_config": "[]", + "groundtruth_source": "auto", + "pre_duration": 5, + "state": consts.GetDatapackStateName(consts.DatapackInitial), + }) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + created, err := server.CreateInjection(context.Background(), &orchestratorv1.MutationRequest{Body: injectionBody}) + if err != nil { + t.Fatalf("CreateInjection() error = %v", err) + } + if created.GetData().AsMap()["name"] != "dp-1" { + t.Fatalf("CreateInjection() unexpected response: %+v", created.GetData().AsMap()) + } + + updateExecBody, _ := structpb.NewStruct(map[string]any{"execution_id": 22, "state": consts.GetExecutionStateName(consts.ExecutionSuccess)}) + if _, err := server.UpdateExecutionState(context.Background(), &orchestratorv1.MutationRequest{Body: updateExecBody}); err != nil { + t.Fatalf("UpdateExecutionState() error = %v", err) + } + + updateInjectionBody, _ := structpb.NewStruct(map[string]any{"name": "dp-1", "state": consts.GetDatapackStateName(consts.DatapackInjectSuccess)}) + if _, err := server.UpdateInjectionState(context.Background(), &orchestratorv1.MutationRequest{Body: updateInjectionBody}); err != nil { + t.Fatalf("UpdateInjectionState() error = %v", err) + } + + updateTimestampBody, _ := structpb.NewStruct(map[string]any{ + "name": "dp-1", + "start_time": time.Now().Format(time.RFC3339Nano), + "end_time": time.Now().Add(time.Minute).Format(time.RFC3339Nano), + }) + if _, err := server.UpdateInjectionTimestamps(context.Background(), &orchestratorv1.MutationRequest{Body: updateTimestampBody}); err != nil { + t.Fatalf("UpdateInjectionTimestamps() error = %v", err) + } + + got, err := server.GetExecution(context.Background(), &orchestratorv1.GetExecutionRequest{ExecutionId: 22}) + if err != nil { + t.Fatalf("GetExecution() error = %v", err) + } + if got.GetData().AsMap()["id"] != float64(22) { + t.Fatalf("GetExecution() unexpected response: %+v", got.GetData().AsMap()) + } + + metricQuery, _ := structpb.NewStruct(map[string]any{}) + injectionMetricsResp, err := server.GetInjectionMetrics(context.Background(), &orchestratorv1.MutationRequest{Body: metricQuery}) + if err != nil { + t.Fatalf("GetInjectionMetrics() error = %v", err) + } + if injectionMetricsResp.GetData().AsMap()["total_count"] != float64(3) { + t.Fatalf("GetInjectionMetrics() unexpected response: %+v", injectionMetricsResp.GetData().AsMap()) + } + + executionMetricsResp, err := server.GetExecutionMetrics(context.Background(), &orchestratorv1.MutationRequest{Body: metricQuery}) + if err != nil { + t.Fatalf("GetExecutionMetrics() error = %v", err) + } + if executionMetricsResp.GetData().AsMap()["total_count"] != float64(4) { + t.Fatalf("GetExecutionMetrics() unexpected response: %+v", executionMetricsResp.GetData().AsMap()) + } + + datapackQuery, _ := structpb.NewStruct(map[string]any{ + "algorithm_version_id": 11, + "datapack_name": "dp-1", + }) + datapackResp, err := server.ListEvaluationExecutionsByDatapack(context.Background(), &orchestratorv1.MutationRequest{Body: datapackQuery}) + if err != nil { + t.Fatalf("ListEvaluationExecutionsByDatapack() error = %v", err) + } + datapackItems, ok := datapackResp.GetData().AsMap()["items"].([]any) + if !ok || len(datapackItems) != 1 { + t.Fatalf("ListEvaluationExecutionsByDatapack() unexpected response: %+v", datapackResp.GetData().AsMap()) + } + + datasetQuery, _ := structpb.NewStruct(map[string]any{ + "algorithm_version_id": 11, + "dataset_version_id": 7, + }) + datasetResp, err := server.ListEvaluationExecutionsByDataset(context.Background(), &orchestratorv1.MutationRequest{Body: datasetQuery}) + if err != nil { + t.Fatalf("ListEvaluationExecutionsByDataset() error = %v", err) + } + datasetItems, ok := datasetResp.GetData().AsMap()["items"].([]any) + if !ok || len(datasetItems) != 1 { + t.Fatalf("ListEvaluationExecutionsByDataset() unexpected response: %+v", datasetResp.GetData().AsMap()) + } +} + +func TestOrchestratorServerCancelTaskNotFound(t *testing.T) { + server := &orchestratorServer{ + execution: executionSubmitterStub{}, + injection: injectionSubmitterStub{}, + metrics: metricsReaderStub{}, + tasks: taskControllerStub{taskID: "task-404", err: consts.ErrNotFound}, + taskRead: taskReaderStub{}, + traceRead: traceReaderStub{}, + groupRead: groupReaderStub{}, + notify: notificationReaderStub{}, + } + + _, err := server.CancelTask(context.Background(), &orchestratorv1.CancelTaskRequest{TaskId: "task-404"}) + if err == nil { + t.Fatal("CancelTask() error = nil, want error") + } + if status.Code(err) != codes.NotFound { + t.Fatalf("CancelTask() code = %s, want %s", status.Code(err), codes.NotFound) + } +} + +func TestOrchestratorServerListDeadLetterTasks(t *testing.T) { + server := &orchestratorServer{ + execution: executionSubmitterStub{}, + injection: injectionSubmitterStub{}, + metrics: metricsReaderStub{}, + tasks: taskControllerStub{listResp: []QueuedTaskResp{{ + TaskID: "task-dead", + Queue: "task:dead", + Type: consts.GetTaskTypeName(consts.TaskTypeRunAlgorithm), + }}}, + taskRead: taskReaderStub{}, + traceRead: traceReaderStub{}, + groupRead: groupReaderStub{}, + notify: notificationReaderStub{}, + } + + resp, err := server.ListDeadLetterTasks(context.Background(), &orchestratorv1.ListDeadLetterTasksRequest{Limit: 10}) + if err != nil { + t.Fatalf("ListDeadLetterTasks() error = %v", err) + } + items, ok := resp.GetData().AsMap()["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("ListDeadLetterTasks() unexpected response: %+v", resp.GetData().AsMap()) + } +} + +func TestOrchestratorServerRetryTask(t *testing.T) { + server := &orchestratorServer{ + execution: executionSubmitterStub{}, + injection: injectionSubmitterStub{}, + metrics: metricsReaderStub{}, + tasks: taskControllerStub{taskID: "task-dead", queue: "task:ready"}, + taskRead: taskReaderStub{}, + traceRead: traceReaderStub{}, + groupRead: groupReaderStub{}, + notify: notificationReaderStub{}, + } + + resp, err := server.RetryTask(context.Background(), &orchestratorv1.RetryTaskRequest{TaskId: "task-dead"}) + if err != nil { + t.Fatalf("RetryTask() error = %v", err) + } + if !resp.GetAccepted() || resp.GetQueue() != "task:ready" { + t.Fatalf("RetryTask() unexpected response: %+v", resp) + } +} + +func TestOrchestratorServerGetTask(t *testing.T) { + server := &orchestratorServer{ + execution: executionSubmitterStub{}, + injection: injectionSubmitterStub{}, + metrics: metricsReaderStub{}, + tasks: taskControllerStub{}, + taskRead: taskReaderStub{detail: &task.TaskDetailResp{ + TaskResp: task.TaskResp{ + ID: "task-1", + Type: consts.GetTaskTypeName(consts.TaskTypeRunAlgorithm), + State: consts.GetTaskStateName(consts.TaskPending), + }, + }}, + traceRead: traceReaderStub{}, + groupRead: groupReaderStub{}, + notify: notificationReaderStub{}, + } + + resp, err := server.GetTask(context.Background(), &orchestratorv1.GetTaskRequest{TaskId: "task-1"}) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if resp.GetData().AsMap()["id"] != "task-1" { + t.Fatalf("GetTask() unexpected response: %+v", resp.GetData().AsMap()) + } +} + +func TestOrchestratorServerPollTaskLogs(t *testing.T) { + server := &orchestratorServer{ + execution: executionSubmitterStub{}, + injection: injectionSubmitterStub{}, + metrics: metricsReaderStub{}, + tasks: taskControllerStub{}, + taskRead: taskReaderStub{}, + traceRead: traceReaderStub{}, + groupRead: groupReaderStub{}, + notify: notificationReaderStub{}, + } + + resp, err := server.PollTaskLogs(context.Background(), &orchestratorv1.PollTaskLogsRequest{TaskId: "task-1"}) + if err != nil { + t.Fatalf("PollTaskLogs() error = %v", err) + } + items, ok := resp.GetData().AsMap()["logs"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("PollTaskLogs() unexpected response: %+v", resp.GetData().AsMap()) + } +} + +func TestOrchestratorServerListTasks(t *testing.T) { + server := &orchestratorServer{ + execution: executionSubmitterStub{}, + injection: injectionSubmitterStub{}, + metrics: metricsReaderStub{}, + tasks: taskControllerStub{}, + taskRead: taskReaderStub{list: &dto.ListResp[task.TaskResp]{ + Items: []task.TaskResp{{ID: "task-1"}}, + Pagination: &dto.PaginationInfo{ + Page: 1, Size: 20, Total: 1, TotalPages: 1, + }, + }}, + traceRead: traceReaderStub{}, + groupRead: groupReaderStub{}, + notify: notificationReaderStub{}, + } + + query, err := structpb.NewStruct(map[string]any{"page": 1, "size": 20}) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + + resp, err := server.ListTasks(context.Background(), &orchestratorv1.ListTasksRequest{Query: query}) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + items, ok := resp.GetData().AsMap()["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("ListTasks() unexpected response: %+v", resp.GetData().AsMap()) + } +} + +func TestOrchestratorServerGetTrace(t *testing.T) { + server := &orchestratorServer{ + execution: executionSubmitterStub{}, + injection: injectionSubmitterStub{}, + metrics: metricsReaderStub{}, + tasks: taskControllerStub{}, + taskRead: taskReaderStub{}, + traceRead: traceReaderStub{detail: &trace.TraceDetailResp{ + TraceResp: trace.TraceResp{ + ID: "trace-1", + Type: "full_pipeline", + GroupID: "group-1", + State: consts.GetTraceStateName(consts.TracePending), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + StartTime: time.Now(), + }, + }}, + groupRead: groupReaderStub{}, + notify: notificationReaderStub{}, + } + + resp, err := server.GetTrace(context.Background(), &orchestratorv1.GetTraceRequest{TraceId: "trace-1"}) + if err != nil { + t.Fatalf("GetTrace() error = %v", err) + } + if resp.GetData().AsMap()["id"] != "trace-1" { + t.Fatalf("GetTrace() unexpected response: %+v", resp.GetData().AsMap()) + } +} + +func TestOrchestratorServerListTraces(t *testing.T) { + server := &orchestratorServer{ + execution: executionSubmitterStub{}, + injection: injectionSubmitterStub{}, + metrics: metricsReaderStub{}, + tasks: taskControllerStub{}, + taskRead: taskReaderStub{}, + traceRead: traceReaderStub{list: &dto.ListResp[trace.TraceResp]{ + Items: []trace.TraceResp{{ID: "trace-1"}}, + Pagination: &dto.PaginationInfo{ + Page: 1, Size: 20, Total: 1, TotalPages: 1, + }, + }}, + groupRead: groupReaderStub{}, + notify: notificationReaderStub{}, + } + + query, err := structpb.NewStruct(map[string]any{"page": 1, "size": 20}) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + + resp, err := server.ListTraces(context.Background(), &orchestratorv1.ListTracesRequest{Query: query}) + if err != nil { + t.Fatalf("ListTraces() error = %v", err) + } + items, ok := resp.GetData().AsMap()["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("ListTraces() unexpected response: %+v", resp.GetData().AsMap()) + } +} + +func TestOrchestratorServerGetGroupStats(t *testing.T) { + server := &orchestratorServer{ + execution: executionSubmitterStub{}, + injection: injectionSubmitterStub{}, + metrics: metricsReaderStub{}, + tasks: taskControllerStub{}, + taskRead: taskReaderStub{}, + traceRead: traceReaderStub{}, + groupRead: groupReaderStub{stats: &group.GroupStats{ + TotalTraces: 3, + AvgDuration: 4.5, + }}, + notify: notificationReaderStub{}, + } + + resp, err := server.GetGroupStats(context.Background(), &orchestratorv1.GetGroupStatsRequest{ + GroupId: "d7a4ed4b-1c91-4cdb-8af8-5520fa8d0ce0", + }) + if err != nil { + t.Fatalf("GetGroupStats() error = %v", err) + } + if resp.GetData().AsMap()["total_traces"] != float64(3) { + t.Fatalf("GetGroupStats() unexpected response: %+v", resp.GetData().AsMap()) + } + if resp.GetData().AsMap()["avg_duration"] != 4.5 { + t.Fatalf("GetGroupStats() unexpected response: %+v", resp.GetData().AsMap()) + } +} + +func TestOrchestratorServerTraceAndGroupStreamRPCs(t *testing.T) { + traceMessages := []redis.XStream{{ + Stream: "trace:trace-1:log", + Messages: []redis.XMessage{{ + ID: "1-0", + Values: map[string]any{ + "type": "info", + }, + }}, + }} + groupMessages := []redis.XStream{{ + Stream: "group:group-1:log", + Messages: []redis.XMessage{{ + ID: "2-0", + Values: map[string]any{ + "trace_id": "trace-1", + }, + }}, + }} + + server := &orchestratorServer{ + execution: executionSubmitterStub{}, + injection: injectionSubmitterStub{}, + metrics: metricsReaderStub{}, + tasks: taskControllerStub{}, + taskRead: taskReaderStub{}, + traceRead: traceReaderStub{ + algorithms: []dto.ContainerVersionItem{{ContainerName: "algo-a"}}, + messages: traceMessages, + }, + groupRead: groupReaderStub{ + count: 3, + messages: groupMessages, + }, + notify: notificationReaderStub{}, + } + + traceState, err := server.GetTraceStreamState(context.Background(), &orchestratorv1.GetTraceStreamStateRequest{TraceId: "trace-1"}) + if err != nil { + t.Fatalf("GetTraceStreamState() error = %v", err) + } + algorithms, ok := traceState.GetData().AsMap()["algorithms"].([]any) + if !ok || len(algorithms) != 1 { + t.Fatalf("GetTraceStreamState() unexpected response: %+v", traceState.GetData().AsMap()) + } + + traceResp, err := server.ReadTraceStreamMessages(context.Background(), &orchestratorv1.ReadStreamMessagesRequest{ + StreamKey: "trace:trace-1:log", + LastId: "0", + Count: 10, + }) + if err != nil { + t.Fatalf("ReadTraceStreamMessages() error = %v", err) + } + traceItems, ok := traceResp.GetData().AsMap()["messages"].([]any) + if !ok || len(traceItems) != 1 { + t.Fatalf("ReadTraceStreamMessages() unexpected response: %+v", traceResp.GetData().AsMap()) + } + + groupState, err := server.GetGroupStreamState(context.Background(), &orchestratorv1.GetGroupStreamStateRequest{GroupId: "group-1"}) + if err != nil { + t.Fatalf("GetGroupStreamState() error = %v", err) + } + if groupState.GetData().AsMap()["total_traces"] != float64(3) { + t.Fatalf("GetGroupStreamState() unexpected response: %+v", groupState.GetData().AsMap()) + } + + groupResp, err := server.ReadGroupStreamMessages(context.Background(), &orchestratorv1.ReadStreamMessagesRequest{ + StreamKey: "group:group-1:log", + LastId: "0", + Count: 10, + }) + if err != nil { + t.Fatalf("ReadGroupStreamMessages() error = %v", err) + } + groupItems, ok := groupResp.GetData().AsMap()["messages"].([]any) + if !ok || len(groupItems) != 1 { + t.Fatalf("ReadGroupStreamMessages() unexpected response: %+v", groupResp.GetData().AsMap()) + } +} + +func TestOrchestratorServerReadNotificationStreamMessages(t *testing.T) { + server := &orchestratorServer{ + execution: executionSubmitterStub{}, + injection: injectionSubmitterStub{}, + metrics: metricsReaderStub{}, + tasks: taskControllerStub{}, + taskRead: taskReaderStub{}, + traceRead: traceReaderStub{}, + groupRead: groupReaderStub{}, + notify: notificationReaderStub{messages: []redis.XStream{{ + Stream: consts.NotificationStreamKey, + Messages: []redis.XMessage{{ + ID: "3-0", + Values: map[string]any{ + "type": "execution", + }, + }}, + }}}, + } + + resp, err := server.ReadNotificationStreamMessages(context.Background(), &orchestratorv1.ReadStreamMessagesRequest{ + StreamKey: consts.NotificationStreamKey, + LastId: "0", + Count: 10, + }) + if err != nil { + t.Fatalf("ReadNotificationStreamMessages() error = %v", err) + } + items, ok := resp.GetData().AsMap()["messages"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("ReadNotificationStreamMessages() unexpected response: %+v", resp.GetData().AsMap()) + } +} diff --git a/src/interface/grpc/resource/lifecycle.go b/src/interface/grpc/resource/lifecycle.go new file mode 100644 index 00000000..dcf5ea7a --- /dev/null +++ b/src/interface/grpc/resource/lifecycle.go @@ -0,0 +1,94 @@ +package grpcresource + +import ( + "context" + "fmt" + "net" + + "aegis/config" + "aegis/httpx" + resourcev1 "aegis/proto/resource/v1" + + "github.com/sirupsen/logrus" + "go.uber.org/fx" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" +) + +const defaultResourceGRPCAddr = ":9093" + +type Lifecycle struct { + server *grpc.Server + addr string + listener net.Listener + StartFunc func(context.Context) error + StopFunc func() +} + +func newLifecycle(resourceServer *resourceServer) (*Lifecycle, error) { + grpcServer := grpc.NewServer(grpc.UnaryInterceptor(httpx.UnaryServerRequestIDInterceptor())) + resourcev1.RegisterResourceServiceServer(grpcServer, resourceServer) + + healthServer := health.NewServer() + healthServer.SetServingStatus(resourcev1.ResourceService_ServiceDesc.ServiceName, grpc_health_v1.HealthCheckResponse_SERVING) + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) + + if config.GetBool("resource.grpc.reflection") { + reflection.Register(grpcServer) + } + + addr := config.GetString("resource.grpc.addr") + if addr == "" { + addr = defaultResourceGRPCAddr + } + + return &Lifecycle{ + server: grpcServer, + addr: addr, + }, nil +} + +func (r *Lifecycle) start(ctx context.Context) error { + if r.StartFunc != nil { + return r.StartFunc(ctx) + } + + listener, err := net.Listen("tcp", r.addr) + if err != nil { + return fmt.Errorf("listen resource grpc on %s: %w", r.addr, err) + } + r.listener = listener + + go func() { + logrus.Infof("Starting resource gRPC server on %s", r.addr) + if err := r.server.Serve(listener); err != nil { + logrus.Errorf("resource gRPC server error: %v", err) + } + }() + return nil +} + +func (r *Lifecycle) stop() { + if r.StopFunc != nil { + r.StopFunc() + return + } + if r.server != nil { + r.server.GracefulStop() + } +} + +func registerLifecycle(lc fx.Lifecycle, runner *Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + return runner.start(ctx) + }, + OnStop: func(ctx context.Context) error { + runner.stop() + return nil + }, + }) +} diff --git a/src/interface/grpc/resource/module.go b/src/interface/grpc/resource/module.go new file mode 100644 index 00000000..6b32ec3a --- /dev/null +++ b/src/interface/grpc/resource/module.go @@ -0,0 +1,11 @@ +package grpcresource + +import "go.uber.org/fx" + +var Module = fx.Module("grpc_resource", + fx.Provide( + newResourceServer, + newLifecycle, + ), + fx.Invoke(registerLifecycle), +) diff --git a/src/interface/grpc/resource/service.go b/src/interface/grpc/resource/service.go new file mode 100644 index 00000000..0969a239 --- /dev/null +++ b/src/interface/grpc/resource/service.go @@ -0,0 +1,543 @@ +package grpcresource + +import ( + "context" + "encoding/json" + "errors" + "time" + + "aegis/consts" + "aegis/dto" + chaossystem "aegis/module/chaossystem" + container "aegis/module/container" + dataset "aegis/module/dataset" + evaluation "aegis/module/evaluation" + label "aegis/module/label" + project "aegis/module/project" + resourcev1 "aegis/proto/resource/v1" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/structpb" +) + +const resourceServiceName = "resource-service" + +type projectReader interface { + GetProjectDetail(context.Context, int) (*project.ProjectDetailResp, error) + ListProjects(context.Context, *project.ListProjectReq) (*dto.ListResp[project.ProjectResp], error) +} + +type containerReader interface { + GetContainer(context.Context, int) (*container.ContainerDetailResp, error) + ListContainers(context.Context, *container.ListContainerReq) (*dto.ListResp[container.ContainerResp], error) +} + +type datasetReader interface { + GetDataset(context.Context, int) (*dataset.DatasetDetailResp, error) + ListDatasets(context.Context, *dataset.ListDatasetReq) (*dto.ListResp[dataset.DatasetResp], error) +} + +type evaluationReader interface { + ListDatapackEvaluationResults(context.Context, *evaluation.BatchEvaluateDatapackReq, int) (*evaluation.BatchEvaluateDatapackResp, error) + ListDatasetEvaluationResults(context.Context, *evaluation.BatchEvaluateDatasetReq, int) (*evaluation.BatchEvaluateDatasetResp, error) + ListEvaluations(context.Context, *evaluation.ListEvaluationReq) (*dto.ListResp[evaluation.EvaluationResp], error) + GetEvaluation(context.Context, int) (*evaluation.EvaluationResp, error) + DeleteEvaluation(context.Context, int) error +} + +type labelReader interface { + BatchDelete(context.Context, []int) error + Create(context.Context, *label.CreateLabelReq) (*label.LabelResp, error) + Delete(context.Context, int) error + GetDetail(context.Context, int) (*label.LabelDetailResp, error) + List(context.Context, *label.ListLabelReq) (*dto.ListResp[label.LabelResp], error) + Update(context.Context, *label.UpdateLabelReq, int) (*label.LabelResp, error) +} + +type chaosSystemReader interface { + ListSystems(context.Context, *chaossystem.ListChaosSystemReq) (*dto.ListResp[chaossystem.ChaosSystemResp], error) + GetSystem(context.Context, int) (*chaossystem.ChaosSystemResp, error) + CreateSystem(context.Context, *chaossystem.CreateChaosSystemReq) (*chaossystem.ChaosSystemResp, error) + UpdateSystem(context.Context, int, *chaossystem.UpdateChaosSystemReq) (*chaossystem.ChaosSystemResp, error) + DeleteSystem(context.Context, int) error + UpsertMetadata(context.Context, int, *chaossystem.BulkUpsertSystemMetadataReq) error + ListMetadata(context.Context, int, string) ([]chaossystem.SystemMetadataResp, error) +} + +type chaosSystemMetadataListResponse struct { + Items []chaossystem.SystemMetadataResp `json:"items"` +} + +type resourceServer struct { + resourcev1.UnimplementedResourceServiceServer + projects projectReader + containers containerReader + datasets datasetReader + labels labelReader + chaosSystems chaosSystemReader + evaluations evaluationReader +} + +func newResourceServer( + projects *project.Service, + containers *container.Service, + datasets *dataset.Service, + labels label.HandlerService, + chaosSystems chaossystem.HandlerService, + evaluations *evaluation.Service, +) *resourceServer { + return &resourceServer{ + projects: projects, + containers: containers, + datasets: datasets, + labels: labels, + chaosSystems: chaosSystems, + evaluations: evaluations, + } +} + +func (s *resourceServer) Ping(context.Context, *resourcev1.PingRequest) (*resourcev1.PingResponse, error) { + return &resourcev1.PingResponse{ + Service: resourceServiceName, + AppId: consts.AppID, + Status: "ok", + TimestampUnix: time.Now().Unix(), + }, nil +} + +func (s *resourceServer) ListProjects(ctx context.Context, req *resourcev1.ListProjectsRequest) (*resourcev1.ResourceListResponse, error) { + query, err := decodeQuery[project.ListProjectReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.projects.ListProjects(ctx, query) + if err != nil { + return nil, mapResourceError(err) + } + return encodeListResponse(resp) +} + +func (s *resourceServer) GetProject(ctx context.Context, req *resourcev1.GetResourceRequest) (*resourcev1.ResourceItemResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + + resp, err := s.projects.GetProjectDetail(ctx, int(req.GetId())) + if err != nil { + return nil, mapResourceError(err) + } + return encodeItemResponse(resp) +} + +func (s *resourceServer) ListContainers(ctx context.Context, req *resourcev1.ListContainersRequest) (*resourcev1.ResourceListResponse, error) { + query, err := decodeQuery[container.ListContainerReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.containers.ListContainers(ctx, query) + if err != nil { + return nil, mapResourceError(err) + } + return encodeListResponse(resp) +} + +func (s *resourceServer) GetContainer(ctx context.Context, req *resourcev1.GetResourceRequest) (*resourcev1.ResourceItemResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + + resp, err := s.containers.GetContainer(ctx, int(req.GetId())) + if err != nil { + return nil, mapResourceError(err) + } + return encodeItemResponse(resp) +} + +func (s *resourceServer) ListDatasets(ctx context.Context, req *resourcev1.ListDatasetsRequest) (*resourcev1.ResourceListResponse, error) { + query, err := decodeQuery[dataset.ListDatasetReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.datasets.ListDatasets(ctx, query) + if err != nil { + return nil, mapResourceError(err) + } + return encodeListResponse(resp) +} + +func (s *resourceServer) GetDataset(ctx context.Context, req *resourcev1.GetResourceRequest) (*resourcev1.ResourceItemResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + + resp, err := s.datasets.GetDataset(ctx, int(req.GetId())) + if err != nil { + return nil, mapResourceError(err) + } + return encodeItemResponse(resp) +} + +func (s *resourceServer) CreateLabel(ctx context.Context, req *resourcev1.MutationRequest) (*resourcev1.ResourceItemResponse, error) { + body, err := decodeQuery[label.CreateLabelReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.labels.Create(ctx, body) + if err != nil { + return nil, mapResourceError(err) + } + return encodeItemResponse(resp) +} + +func (s *resourceServer) GetLabel(ctx context.Context, req *resourcev1.GetResourceRequest) (*resourcev1.ResourceItemResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + + resp, err := s.labels.GetDetail(ctx, int(req.GetId())) + if err != nil { + return nil, mapResourceError(err) + } + return encodeItemResponse(resp) +} + +func (s *resourceServer) ListLabels(ctx context.Context, req *resourcev1.QueryRequest) (*resourcev1.ResourceListResponse, error) { + query, err := decodeQuery[label.ListLabelReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.labels.List(ctx, query) + if err != nil { + return nil, mapResourceError(err) + } + return encodeListResponse(resp) +} + +func (s *resourceServer) UpdateLabel(ctx context.Context, req *resourcev1.UpdateByIDRequest) (*resourcev1.ResourceItemResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + + body, err := decodeQuery[label.UpdateLabelReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := body.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.labels.Update(ctx, body, int(req.GetId())) + if err != nil { + return nil, mapResourceError(err) + } + return encodeItemResponse(resp) +} + +func (s *resourceServer) DeleteLabel(ctx context.Context, req *resourcev1.GetResourceRequest) (*emptypb.Empty, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + if err := s.labels.Delete(ctx, int(req.GetId())); err != nil { + return nil, mapResourceError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *resourceServer) BatchDeleteLabels(ctx context.Context, req *resourcev1.BatchDeleteRequest) (*emptypb.Empty, error) { + if err := validatePositiveInt64s(req.GetIds(), "ids"); err != nil { + return nil, err + } + if err := s.labels.BatchDelete(ctx, int64sToInts(req.GetIds())); err != nil { + return nil, mapResourceError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *resourceServer) ListChaosSystems(ctx context.Context, req *resourcev1.QueryRequest) (*resourcev1.ResourceListResponse, error) { + query, err := decodeQuery[chaossystem.ListChaosSystemReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.chaosSystems.ListSystems(ctx, query) + if err != nil { + return nil, mapResourceError(err) + } + return encodeListResponse(resp) +} + +func (s *resourceServer) GetChaosSystem(ctx context.Context, req *resourcev1.GetResourceRequest) (*resourcev1.ResourceItemResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + + resp, err := s.chaosSystems.GetSystem(ctx, int(req.GetId())) + if err != nil { + return nil, mapResourceError(err) + } + return encodeItemResponse(resp) +} + +func (s *resourceServer) CreateChaosSystem(ctx context.Context, req *resourcev1.MutationRequest) (*resourcev1.ResourceItemResponse, error) { + body, err := decodeQuery[chaossystem.CreateChaosSystemReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.chaosSystems.CreateSystem(ctx, body) + if err != nil { + return nil, mapResourceError(err) + } + return encodeItemResponse(resp) +} + +func (s *resourceServer) UpdateChaosSystem(ctx context.Context, req *resourcev1.UpdateByIDRequest) (*resourcev1.ResourceItemResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + + body, err := decodeQuery[chaossystem.UpdateChaosSystemReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.chaosSystems.UpdateSystem(ctx, int(req.GetId()), body) + if err != nil { + return nil, mapResourceError(err) + } + return encodeItemResponse(resp) +} + +func (s *resourceServer) DeleteChaosSystem(ctx context.Context, req *resourcev1.GetResourceRequest) (*emptypb.Empty, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + if err := s.chaosSystems.DeleteSystem(ctx, int(req.GetId())); err != nil { + return nil, mapResourceError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *resourceServer) UpsertChaosSystemMetadata(ctx context.Context, req *resourcev1.UpdateByIDRequest) (*emptypb.Empty, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + + body, err := decodeQuery[chaossystem.BulkUpsertSystemMetadataReq](req.GetBody()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := s.chaosSystems.UpsertMetadata(ctx, int(req.GetId()), body); err != nil { + return nil, mapResourceError(err) + } + return &emptypb.Empty{}, nil +} + +func (s *resourceServer) ListChaosSystemMetadata(ctx context.Context, req *resourcev1.IDQueryRequest) (*resourcev1.ResourceItemResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + + query, err := decodeQuery[struct { + Type string `json:"type"` + }](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.chaosSystems.ListMetadata(ctx, int(req.GetId()), query.Type) + if err != nil { + return nil, mapResourceError(err) + } + return encodeItemResponse(chaosSystemMetadataListResponse{Items: resp}) +} + +func (s *resourceServer) ListDatapackEvaluationResults(ctx context.Context, req *resourcev1.ListDatapackEvaluationsRequest) (*resourcev1.ResourceItemResponse, error) { + if req.GetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + + query, err := decodeQuery[evaluation.BatchEvaluateDatapackReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.evaluations.ListDatapackEvaluationResults(ctx, query, int(req.GetUserId())) + if err != nil { + return nil, mapResourceError(err) + } + return encodeItemResponse(resp) +} + +func (s *resourceServer) ListDatasetEvaluationResults(ctx context.Context, req *resourcev1.ListDatasetEvaluationsRequest) (*resourcev1.ResourceItemResponse, error) { + if req.GetUserId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + + query, err := decodeQuery[evaluation.BatchEvaluateDatasetReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.evaluations.ListDatasetEvaluationResults(ctx, query, int(req.GetUserId())) + if err != nil { + return nil, mapResourceError(err) + } + return encodeItemResponse(resp) +} + +func (s *resourceServer) ListEvaluations(ctx context.Context, req *resourcev1.ListEvaluationsRequest) (*resourcev1.ResourceListResponse, error) { + query, err := decodeQuery[evaluation.ListEvaluationReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.evaluations.ListEvaluations(ctx, query) + if err != nil { + return nil, mapResourceError(err) + } + return encodeListResponse(resp) +} + +func (s *resourceServer) GetEvaluation(ctx context.Context, req *resourcev1.GetResourceRequest) (*resourcev1.ResourceItemResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + + resp, err := s.evaluations.GetEvaluation(ctx, int(req.GetId())) + if err != nil { + return nil, mapResourceError(err) + } + return encodeItemResponse(resp) +} + +func (s *resourceServer) DeleteEvaluation(ctx context.Context, req *resourcev1.GetResourceRequest) (*emptypb.Empty, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + if err := s.evaluations.DeleteEvaluation(ctx, int(req.GetId())); err != nil { + return nil, mapResourceError(err) + } + return &emptypb.Empty{}, nil +} + +func decodeQuery[T any](query *structpb.Struct) (*T, error) { + var result T + if query == nil { + return &result, nil + } + + data, err := json.Marshal(query.AsMap()) + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +func encodeItemResponse(value any) (*resourcev1.ResourceItemResponse, error) { + item, err := toStruct(value) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return &resourcev1.ResourceItemResponse{Data: item}, nil +} + +func encodeListResponse(value any) (*resourcev1.ResourceListResponse, error) { + item, err := toStruct(value) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return &resourcev1.ResourceListResponse{Data: item}, nil +} + +func toStruct(value any) (*structpb.Struct, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + + payload := map[string]any{} + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + return structpb.NewStruct(payload) +} + +func mapResourceError(err error) error { + switch { + case errors.Is(err, consts.ErrAuthenticationFailed): + return status.Error(codes.Unauthenticated, err.Error()) + case errors.Is(err, consts.ErrPermissionDenied): + return status.Error(codes.PermissionDenied, err.Error()) + case errors.Is(err, consts.ErrBadRequest): + return status.Error(codes.InvalidArgument, err.Error()) + case errors.Is(err, consts.ErrNotFound): + return status.Error(codes.NotFound, err.Error()) + case errors.Is(err, consts.ErrAlreadyExists): + return status.Error(codes.AlreadyExists, err.Error()) + case err != nil: + return status.Error(codes.Internal, err.Error()) + default: + return nil + } +} + +func validatePositiveInt64s(items []int64, field string) error { + if len(items) == 0 { + return status.Errorf(codes.InvalidArgument, "%s is required", field) + } + for _, item := range items { + if item <= 0 { + return status.Errorf(codes.InvalidArgument, "%s must contain positive integers", field) + } + } + return nil +} + +func int64sToInts(items []int64) []int { + if len(items) == 0 { + return nil + } + result := make([]int, 0, len(items)) + for _, item := range items { + result = append(result, int(item)) + } + return result +} diff --git a/src/interface/grpc/resource/service_test.go b/src/interface/grpc/resource/service_test.go new file mode 100644 index 00000000..c3bec150 --- /dev/null +++ b/src/interface/grpc/resource/service_test.go @@ -0,0 +1,465 @@ +package grpcresource + +import ( + "context" + "errors" + "testing" + + "aegis/consts" + "aegis/dto" + chaossystem "aegis/module/chaossystem" + container "aegis/module/container" + dataset "aegis/module/dataset" + evaluation "aegis/module/evaluation" + label "aegis/module/label" + project "aegis/module/project" + resourcev1 "aegis/proto/resource/v1" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +type projectReaderStub struct { + listResp *dto.ListResp[project.ProjectResp] + getResp *project.ProjectDetailResp + err error +} + +func (s projectReaderStub) GetProjectDetail(_ context.Context, projectID int) (*project.ProjectDetailResp, error) { + if projectID <= 0 { + return nil, errors.New("invalid id") + } + return s.getResp, s.err +} + +func (s projectReaderStub) ListProjects(_ context.Context, req *project.ListProjectReq) (*dto.ListResp[project.ProjectResp], error) { + if req == nil { + return nil, errors.New("nil request") + } + return s.listResp, s.err +} + +type containerReaderStub struct { + listResp *dto.ListResp[container.ContainerResp] + getResp *container.ContainerDetailResp + err error +} + +func (s containerReaderStub) GetContainer(_ context.Context, containerID int) (*container.ContainerDetailResp, error) { + if containerID <= 0 { + return nil, errors.New("invalid id") + } + return s.getResp, s.err +} + +func (s containerReaderStub) ListContainers(_ context.Context, req *container.ListContainerReq) (*dto.ListResp[container.ContainerResp], error) { + if req == nil { + return nil, errors.New("nil request") + } + return s.listResp, s.err +} + +type datasetReaderStub struct { + listResp *dto.ListResp[dataset.DatasetResp] + getResp *dataset.DatasetDetailResp + err error +} + +func (s datasetReaderStub) GetDataset(_ context.Context, datasetID int) (*dataset.DatasetDetailResp, error) { + if datasetID <= 0 { + return nil, errors.New("invalid id") + } + return s.getResp, s.err +} + +func (s datasetReaderStub) ListDatasets(_ context.Context, req *dataset.ListDatasetReq) (*dto.ListResp[dataset.DatasetResp], error) { + if req == nil { + return nil, errors.New("nil request") + } + return s.listResp, s.err +} + +type evaluationReaderStub struct { + datapackResp *evaluation.BatchEvaluateDatapackResp + datasetResp *evaluation.BatchEvaluateDatasetResp + listResp *dto.ListResp[evaluation.EvaluationResp] + getResp *evaluation.EvaluationResp + err error +} + +func (s evaluationReaderStub) ListDatapackEvaluationResults(_ context.Context, req *evaluation.BatchEvaluateDatapackReq, userID int) (*evaluation.BatchEvaluateDatapackResp, error) { + if req == nil || userID <= 0 { + return nil, errors.New("invalid request") + } + return s.datapackResp, s.err +} + +func (s evaluationReaderStub) ListDatasetEvaluationResults(_ context.Context, req *evaluation.BatchEvaluateDatasetReq, userID int) (*evaluation.BatchEvaluateDatasetResp, error) { + if req == nil || userID <= 0 { + return nil, errors.New("invalid request") + } + return s.datasetResp, s.err +} + +func (s evaluationReaderStub) ListEvaluations(_ context.Context, req *evaluation.ListEvaluationReq) (*dto.ListResp[evaluation.EvaluationResp], error) { + if req == nil { + return nil, errors.New("nil request") + } + return s.listResp, s.err +} + +func (s evaluationReaderStub) GetEvaluation(_ context.Context, id int) (*evaluation.EvaluationResp, error) { + if id <= 0 { + return nil, errors.New("invalid id") + } + return s.getResp, s.err +} + +func (s evaluationReaderStub) DeleteEvaluation(_ context.Context, id int) error { + if id <= 0 { + return errors.New("invalid id") + } + return s.err +} + +type labelReaderStub struct { + listResp *dto.ListResp[label.LabelResp] + getResp *label.LabelDetailResp + itemResp *label.LabelResp + err error +} + +func (s labelReaderStub) BatchDelete(_ context.Context, ids []int) error { + if len(ids) == 0 { + return errors.New("ids required") + } + return s.err +} + +func (s labelReaderStub) Create(_ context.Context, req *label.CreateLabelReq) (*label.LabelResp, error) { + if req == nil { + return nil, errors.New("nil request") + } + return s.itemResp, s.err +} + +func (s labelReaderStub) Delete(_ context.Context, id int) error { + if id <= 0 { + return errors.New("invalid id") + } + return s.err +} + +func (s labelReaderStub) GetDetail(_ context.Context, id int) (*label.LabelDetailResp, error) { + if id <= 0 { + return nil, errors.New("invalid id") + } + return s.getResp, s.err +} + +func (s labelReaderStub) List(_ context.Context, req *label.ListLabelReq) (*dto.ListResp[label.LabelResp], error) { + if req == nil { + return nil, errors.New("nil request") + } + return s.listResp, s.err +} + +func (s labelReaderStub) Update(_ context.Context, req *label.UpdateLabelReq, id int) (*label.LabelResp, error) { + if req == nil || id <= 0 { + return nil, errors.New("invalid request") + } + return s.itemResp, s.err +} + +type chaosSystemReaderStub struct { + listResp *dto.ListResp[chaossystem.ChaosSystemResp] + getResp *chaossystem.ChaosSystemResp + metadataResp []chaossystem.SystemMetadataResp + err error +} + +func (s chaosSystemReaderStub) ListSystems(_ context.Context, req *chaossystem.ListChaosSystemReq) (*dto.ListResp[chaossystem.ChaosSystemResp], error) { + if req == nil { + return nil, errors.New("nil request") + } + return s.listResp, s.err +} + +func (s chaosSystemReaderStub) GetSystem(_ context.Context, id int) (*chaossystem.ChaosSystemResp, error) { + if id <= 0 { + return nil, errors.New("invalid id") + } + return s.getResp, s.err +} + +func (s chaosSystemReaderStub) CreateSystem(_ context.Context, req *chaossystem.CreateChaosSystemReq) (*chaossystem.ChaosSystemResp, error) { + if req == nil { + return nil, errors.New("nil request") + } + return s.getResp, s.err +} + +func (s chaosSystemReaderStub) UpdateSystem(_ context.Context, id int, req *chaossystem.UpdateChaosSystemReq) (*chaossystem.ChaosSystemResp, error) { + if id <= 0 || req == nil { + return nil, errors.New("invalid request") + } + return s.getResp, s.err +} + +func (s chaosSystemReaderStub) DeleteSystem(_ context.Context, id int) error { + if id <= 0 { + return errors.New("invalid id") + } + return s.err +} + +func (s chaosSystemReaderStub) UpsertMetadata(_ context.Context, id int, req *chaossystem.BulkUpsertSystemMetadataReq) error { + if id <= 0 || req == nil { + return errors.New("invalid request") + } + return s.err +} + +func (s chaosSystemReaderStub) ListMetadata(_ context.Context, id int, _ string) ([]chaossystem.SystemMetadataResp, error) { + if id <= 0 { + return nil, errors.New("invalid id") + } + return s.metadataResp, s.err +} + +func TestResourceServerListProjects(t *testing.T) { + server := &resourceServer{ + projects: projectReaderStub{listResp: &dto.ListResp[project.ProjectResp]{ + Items: []project.ProjectResp{{ID: 1, Name: "demo"}}, + Pagination: &dto.PaginationInfo{ + Page: 1, Size: 20, Total: 1, TotalPages: 1, + }, + }}, + containers: containerReaderStub{}, + datasets: datasetReaderStub{}, + labels: labelReaderStub{}, + chaosSystems: chaosSystemReaderStub{}, + evaluations: evaluationReaderStub{}, + } + + query, err := structpb.NewStruct(map[string]any{"page": 1, "size": 20}) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + + resp, err := server.ListProjects(context.Background(), &resourcev1.ListProjectsRequest{Query: query}) + if err != nil { + t.Fatalf("ListProjects() error = %v", err) + } + if got := resp.GetData().AsMap()["items"]; got == nil { + t.Fatalf("ListProjects() missing items in response: %+v", resp.GetData().AsMap()) + } +} + +func TestResourceServerGetDatasetNotFound(t *testing.T) { + server := &resourceServer{ + projects: projectReaderStub{}, + containers: containerReaderStub{}, + datasets: datasetReaderStub{err: consts.ErrNotFound}, + labels: labelReaderStub{}, + chaosSystems: chaosSystemReaderStub{}, + evaluations: evaluationReaderStub{}, + } + + _, err := server.GetDataset(context.Background(), &resourcev1.GetResourceRequest{Id: 8}) + if err == nil { + t.Fatal("GetDataset() error = nil, want error") + } + if status.Code(err) != codes.NotFound { + t.Fatalf("GetDataset() code = %s, want %s", status.Code(err), codes.NotFound) + } +} + +func TestResourceServerListContainersInvalidQuery(t *testing.T) { + server := &resourceServer{ + projects: projectReaderStub{}, + containers: containerReaderStub{}, + datasets: datasetReaderStub{}, + labels: labelReaderStub{}, + chaosSystems: chaosSystemReaderStub{}, + evaluations: evaluationReaderStub{}, + } + + query, err := structpb.NewStruct(map[string]any{"page": -1}) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + + _, err = server.ListContainers(context.Background(), &resourcev1.ListContainersRequest{Query: query}) + if err == nil { + t.Fatal("ListContainers() error = nil, want error") + } + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("ListContainers() code = %s, want %s", status.Code(err), codes.InvalidArgument) + } +} + +func TestResourceServerListDatapackEvaluationsRequiresUserID(t *testing.T) { + server := &resourceServer{ + projects: projectReaderStub{}, + containers: containerReaderStub{}, + datasets: datasetReaderStub{}, + labels: labelReaderStub{}, + chaosSystems: chaosSystemReaderStub{}, + evaluations: evaluationReaderStub{}, + } + + query, err := structpb.NewStruct(map[string]any{ + "specs": []any{ + map[string]any{ + "algorithm": map[string]any{"name": "algo", "version": "v1.0.0"}, + "datapack": "pack-a", + }, + }, + }) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + + _, err = server.ListDatapackEvaluationResults(context.Background(), &resourcev1.ListDatapackEvaluationsRequest{Query: query}) + if err == nil { + t.Fatal("ListDatapackEvaluationResults() error = nil, want error") + } + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("ListDatapackEvaluationResults() code = %s, want %s", status.Code(err), codes.InvalidArgument) + } +} + +func TestResourceServerListEvaluations(t *testing.T) { + server := &resourceServer{ + projects: projectReaderStub{}, + containers: containerReaderStub{}, + datasets: datasetReaderStub{}, + labels: labelReaderStub{}, + chaosSystems: chaosSystemReaderStub{}, + evaluations: evaluationReaderStub{listResp: &dto.ListResp[evaluation.EvaluationResp]{ + Items: []evaluation.EvaluationResp{{ID: 3, EvalType: consts.EvalTypeDataset}}, + Pagination: &dto.PaginationInfo{ + Page: 1, Size: 20, Total: 1, TotalPages: 1, + }, + }}, + } + + query, err := structpb.NewStruct(map[string]any{"page": 1, "size": 20}) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + + resp, err := server.ListEvaluations(context.Background(), &resourcev1.ListEvaluationsRequest{Query: query}) + if err != nil { + t.Fatalf("ListEvaluations() error = %v", err) + } + if got := resp.GetData().AsMap()["items"]; got == nil { + t.Fatalf("ListEvaluations() missing items in response: %+v", resp.GetData().AsMap()) + } +} + +func TestResourceServerListLabels(t *testing.T) { + server := &resourceServer{ + projects: projectReaderStub{}, + containers: containerReaderStub{}, + datasets: datasetReaderStub{}, + labels: labelReaderStub{listResp: &dto.ListResp[label.LabelResp]{ + Items: []label.LabelResp{{ID: 9, Key: "env", Value: "prod"}}, + Pagination: &dto.PaginationInfo{ + Page: 1, Size: 20, Total: 1, TotalPages: 1, + }, + }}, + chaosSystems: chaosSystemReaderStub{}, + evaluations: evaluationReaderStub{}, + } + + query, err := structpb.NewStruct(map[string]any{"page": 1, "size": 20}) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + + resp, err := server.ListLabels(context.Background(), &resourcev1.QueryRequest{Query: query}) + if err != nil { + t.Fatalf("ListLabels() error = %v", err) + } + if got := resp.GetData().AsMap()["items"]; got == nil { + t.Fatalf("ListLabels() missing items in response: %+v", resp.GetData().AsMap()) + } +} + +func TestResourceServerBatchDeleteLabelsRequiresIDs(t *testing.T) { + server := &resourceServer{ + projects: projectReaderStub{}, + containers: containerReaderStub{}, + datasets: datasetReaderStub{}, + labels: labelReaderStub{}, + chaosSystems: chaosSystemReaderStub{}, + evaluations: evaluationReaderStub{}, + } + + _, err := server.BatchDeleteLabels(context.Background(), &resourcev1.BatchDeleteRequest{}) + if err == nil { + t.Fatal("BatchDeleteLabels() error = nil, want error") + } + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("BatchDeleteLabels() code = %s, want %s", status.Code(err), codes.InvalidArgument) + } +} + +func TestResourceServerListChaosSystems(t *testing.T) { + server := &resourceServer{ + projects: projectReaderStub{}, + containers: containerReaderStub{}, + datasets: datasetReaderStub{}, + labels: labelReaderStub{}, + chaosSystems: chaosSystemReaderStub{listResp: &dto.ListResp[chaossystem.ChaosSystemResp]{ + Items: []chaossystem.ChaosSystemResp{{ID: 4, Name: "k8s"}}, + Pagination: &dto.PaginationInfo{ + Page: 1, Size: 20, Total: 1, TotalPages: 1, + }, + }}, + evaluations: evaluationReaderStub{}, + } + + query, err := structpb.NewStruct(map[string]any{"page": 1, "size": 20}) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + + resp, err := server.ListChaosSystems(context.Background(), &resourcev1.QueryRequest{Query: query}) + if err != nil { + t.Fatalf("ListChaosSystems() error = %v", err) + } + if got := resp.GetData().AsMap()["items"]; got == nil { + t.Fatalf("ListChaosSystems() missing items in response: %+v", resp.GetData().AsMap()) + } +} + +func TestResourceServerListChaosSystemMetadata(t *testing.T) { + server := &resourceServer{ + projects: projectReaderStub{}, + containers: containerReaderStub{}, + datasets: datasetReaderStub{}, + labels: labelReaderStub{}, + chaosSystems: chaosSystemReaderStub{metadataResp: []chaossystem.SystemMetadataResp{ + {ID: 1, SystemName: "k8s", MetadataType: "service", ServiceName: "api"}, + }}, + evaluations: evaluationReaderStub{}, + } + + query, err := structpb.NewStruct(map[string]any{"type": "service"}) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + + resp, err := server.ListChaosSystemMetadata(context.Background(), &resourcev1.IDQueryRequest{Id: 4, Query: query}) + if err != nil { + t.Fatalf("ListChaosSystemMetadata() error = %v", err) + } + items, ok := resp.GetData().AsMap()["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("ListChaosSystemMetadata() unexpected response: %+v", resp.GetData().AsMap()) + } +} diff --git a/src/interface/grpc/runtime/lifecycle.go b/src/interface/grpc/runtime/lifecycle.go new file mode 100644 index 00000000..52e90274 --- /dev/null +++ b/src/interface/grpc/runtime/lifecycle.go @@ -0,0 +1,94 @@ +package grpcruntime + +import ( + "context" + "fmt" + "net" + + "aegis/config" + "aegis/httpx" + runtimev1 "aegis/proto/runtime/v1" + + "github.com/sirupsen/logrus" + "go.uber.org/fx" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" +) + +const defaultRuntimeGRPCAddr = ":9094" + +type Lifecycle struct { + server *grpc.Server + addr string + listener net.Listener + StartFunc func(context.Context) error + StopFunc func() +} + +func newLifecycle(runtimeServer *runtimeServer) (*Lifecycle, error) { + grpcServer := grpc.NewServer(grpc.UnaryInterceptor(httpx.UnaryServerRequestIDInterceptor())) + runtimev1.RegisterRuntimeServiceServer(grpcServer, runtimeServer) + + healthServer := health.NewServer() + healthServer.SetServingStatus(runtimev1.RuntimeService_ServiceDesc.ServiceName, grpc_health_v1.HealthCheckResponse_SERVING) + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) + + if config.GetBool("runtime_worker.grpc.reflection") { + reflection.Register(grpcServer) + } + + addr := config.GetString("runtime_worker.grpc.addr") + if addr == "" { + addr = defaultRuntimeGRPCAddr + } + + return &Lifecycle{ + server: grpcServer, + addr: addr, + }, nil +} + +func (r *Lifecycle) start(ctx context.Context) error { + if r.StartFunc != nil { + return r.StartFunc(ctx) + } + + listener, err := net.Listen("tcp", r.addr) + if err != nil { + return fmt.Errorf("listen runtime grpc on %s: %w", r.addr, err) + } + r.listener = listener + + go func() { + logrus.Infof("Starting runtime gRPC server on %s", r.addr) + if err := r.server.Serve(listener); err != nil { + logrus.Errorf("runtime gRPC server error: %v", err) + } + }() + return nil +} + +func (r *Lifecycle) stop() { + if r.StopFunc != nil { + r.StopFunc() + return + } + if r.server != nil { + r.server.GracefulStop() + } +} + +func registerLifecycle(lc fx.Lifecycle, runner *Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + return runner.start(ctx) + }, + OnStop: func(ctx context.Context) error { + runner.stop() + return nil + }, + }) +} diff --git a/src/interface/grpc/runtime/module.go b/src/interface/grpc/runtime/module.go new file mode 100644 index 00000000..0aecb71c --- /dev/null +++ b/src/interface/grpc/runtime/module.go @@ -0,0 +1,11 @@ +package grpcruntime + +import "go.uber.org/fx" + +var Module = fx.Module("grpc_runtime", + fx.Provide( + newRuntimeServer, + newLifecycle, + ), + fx.Invoke(registerLifecycle), +) diff --git a/src/interface/grpc/runtime/service.go b/src/interface/grpc/runtime/service.go new file mode 100644 index 00000000..0ebf4648 --- /dev/null +++ b/src/interface/grpc/runtime/service.go @@ -0,0 +1,231 @@ +package grpcruntime + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "aegis/consts" + "aegis/dto" + buildkit "aegis/infra/buildkit" + helm "aegis/infra/helm" + k8s "aegis/infra/k8s" + redis "aegis/infra/redis" + task "aegis/module/task" + runtimev1 "aegis/proto/runtime/v1" + "aegis/service/consumer" + + "go.uber.org/fx" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + "gorm.io/gorm" +) + +type runtimeServerParams struct { + fx.In + + DB *gorm.DB + RedisGateway *redis.Gateway + K8sGateway *k8s.Gateway + BuildKit *buildkit.Gateway + Helm *helm.Gateway + RestartLimiter *consumer.TokenBucketRateLimiter `name:"restart_limiter"` + BuildLimiter *consumer.TokenBucketRateLimiter `name:"build_limiter"` + AlgoLimiter *consumer.TokenBucketRateLimiter `name:"algo_limiter"` +} + +type runtimeServer struct { + runtimev1.UnimplementedRuntimeServiceServer + snapshots *consumer.RuntimeSnapshotService + redis *redis.Gateway +} + +func newRuntimeServer(params runtimeServerParams) *runtimeServer { + return &runtimeServer{ + snapshots: consumer.NewRuntimeSnapshotService( + params.DB, + params.RedisGateway, + params.K8sGateway, + params.BuildKit, + params.Helm, + params.RestartLimiter, + params.BuildLimiter, + params.AlgoLimiter, + ), + redis: params.RedisGateway, + } +} + +func (s *runtimeServer) Ping(ctx context.Context, _ *runtimev1.PingRequest) (*runtimev1.PingResponse, error) { + status := s.snapshots.RuntimeStatus(ctx) + return &runtimev1.PingResponse{ + Service: status.ServiceName, + AppId: status.AppID, + Status: "ok", + TimestampUnix: time.Now().Unix(), + }, nil +} + +func (s *runtimeServer) GetRuntimeStatus(ctx context.Context, _ *runtimev1.RuntimeStatusRequest) (*runtimev1.RuntimeStatusResponse, error) { + status := s.snapshots.RuntimeStatus(ctx) + return &runtimev1.RuntimeStatusResponse{ + Service: status.ServiceName, + Mode: status.Mode, + AppId: status.AppID, + StartedAtUnix: status.StartedAt.Unix(), + UptimeSeconds: status.UptimeSeconds, + DbAvailable: status.DB.Available, + DbHealthy: status.DB.Healthy, + DbError: status.DB.Error, + RedisAvailable: status.Redis.Available, + RedisHealthy: status.Redis.Healthy, + RedisError: status.Redis.Error, + K8SAvailable: status.K8s.Available, + K8SHealthy: status.K8s.Healthy, + K8SError: status.K8s.Error, + BuildkitAvailable: status.BuildKit.Available, + BuildkitHealthy: status.BuildKit.Healthy, + BuildkitError: status.BuildKit.Error, + HelmAvailable: status.Helm.Available, + HelmHealthy: status.Helm.Healthy, + HelmError: status.Helm.Error, + }, nil +} + +func (s *runtimeServer) GetQueueStatus(ctx context.Context, _ *runtimev1.QueueStatusRequest) (*runtimev1.QueueStatusResponse, error) { + stats, err := s.snapshots.QueueStatus(ctx) + if err != nil { + return nil, err + } + return &runtimev1.QueueStatusResponse{ + ReadyCount: stats.ReadyCount, + DelayedCount: stats.DelayedCount, + DeadCount: stats.DeadCount, + IndexedCount: stats.IndexedCount, + ConcurrencyCount: stats.ConcurrencyCount, + }, nil +} + +func (s *runtimeServer) GetLimiterStatus(ctx context.Context, _ *runtimev1.LimiterStatusRequest) (*runtimev1.LimiterStatusResponse, error) { + snapshots := s.snapshots.LimiterStatus(ctx) + items := make([]*runtimev1.LimiterStatus, 0, len(snapshots)) + for _, snapshot := range snapshots { + item := &runtimev1.LimiterStatus{ + ServiceName: snapshot.ServiceName, + BucketKey: snapshot.BucketKey, + MaxTokens: int64(snapshot.MaxTokens), + WaitTimeoutSeconds: int64(snapshot.WaitTimeout.Seconds()), + InUseTokens: snapshot.InUseTokens, + } + if snapshot.InUseTokensLoadErr != nil { + item.Error = snapshot.InUseTokensLoadErr.Error() + } + items = append(items, item) + } + return &runtimev1.LimiterStatusResponse{Items: items}, nil +} + +func (s *runtimeServer) GetNamespaceLocks(ctx context.Context, _ *runtimev1.PingRequest) (*runtimev1.StructResponse, error) { + items, err := listNamespaceLocks(ctx, s.redis) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return encodeStruct(items) +} + +func (s *runtimeServer) GetQueuedTasks(ctx context.Context, _ *runtimev1.PingRequest) (*runtimev1.StructResponse, error) { + items, err := listQueuedTasks(ctx, s.redis) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return encodeStruct(items) +} + +func listNamespaceLocks(ctx context.Context, redis *redis.Gateway) (map[string]map[string]any, error) { + namespaces, err := redis.SetMembers(ctx, consts.NamespacesKey) + if err != nil { + return nil, err + } + + items := make(map[string]map[string]any, len(namespaces)) + for _, namespace := range namespaces { + nsKey := fmt.Sprintf(consts.NamespaceKeyPattern, namespace) + values, err := redis.HashGetAll(ctx, nsKey) + if err != nil { + return nil, err + } + entry := make(map[string]any, len(values)) + for key, value := range values { + entry[key] = value + } + items[namespace] = entry + } + return items, nil +} + +func listQueuedTasks(ctx context.Context, redis *redis.Gateway) (map[string]any, error) { + readyItems, err := redis.ListReadyTasks(ctx) + if err != nil { + return nil, err + } + delayedItems, err := redis.ListDelayedTasks(ctx, 1000) + if err != nil { + return nil, err + } + + readyTasks, err := decodeQueuedTasks(readyItems) + if err != nil { + return nil, err + } + delayedTasks, err := decodeQueuedTasks(delayedItems) + if err != nil { + return nil, err + } + + return map[string]any{ + "ready_tasks": readyTasks, + "delayed_tasks": delayedTasks, + }, nil +} + +func decodeQueuedTasks(items []string) ([]task.TaskResp, error) { + result := make([]task.TaskResp, 0, len(items)) + for _, item := range items { + var queuedTask dto.UnifiedTask + if err := json.Unmarshal([]byte(item), &queuedTask); err != nil { + return nil, err + } + result = append(result, task.TaskResp{ + ID: queuedTask.TaskID, + Type: consts.GetTaskTypeName(queuedTask.Type), + Immediate: queuedTask.Immediate, + ExecuteTime: queuedTask.ExecuteTime, + CronExpr: queuedTask.CronExpr, + TraceID: queuedTask.TraceID, + GroupID: queuedTask.GroupID, + State: consts.GetTaskStateName(queuedTask.State), + Status: consts.GetStatusTypeName(consts.CommonEnabled), + ProjectID: queuedTask.ProjectID, + }) + } + return result, nil +} + +func encodeStruct(value any) (*runtimev1.StructResponse, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + + payload := map[string]any{} + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + item, err := structpb.NewStruct(payload) + if err != nil { + return nil, err + } + return &runtimev1.StructResponse{Data: item}, nil +} diff --git a/src/interface/grpc/runtime/service_test.go b/src/interface/grpc/runtime/service_test.go new file mode 100644 index 00000000..2f283d26 --- /dev/null +++ b/src/interface/grpc/runtime/service_test.go @@ -0,0 +1,52 @@ +package grpcruntime + +import ( + "context" + "testing" + "time" + + "aegis/consts" + runtimev1 "aegis/proto/runtime/v1" + "aegis/service/consumer" +) + +func TestRuntimeServerStatusEndpoints(t *testing.T) { + originalStart := consts.InitialTime + originalAppID := consts.AppID + startedAt := time.Unix(1_700_000_000, 0) + consts.InitialTime = &startedAt + consts.AppID = "app-test" + t.Cleanup(func() { + consts.InitialTime = originalStart + consts.AppID = originalAppID + }) + + server := &runtimeServer{ + snapshots: consumer.NewRuntimeSnapshotService(nil, nil, nil, nil, nil, nil, nil, nil), + } + + pingResp, err := server.Ping(context.Background(), &runtimev1.PingRequest{}) + if err != nil { + t.Fatalf("Ping() error = %v", err) + } + if pingResp.Service != consumer.RuntimeServiceName { + t.Fatalf("Ping() service = %q, want %q", pingResp.Service, consumer.RuntimeServiceName) + } + if pingResp.AppId != "app-test" { + t.Fatalf("Ping() app id = %q, want %q", pingResp.AppId, "app-test") + } + + statusResp, err := server.GetRuntimeStatus(context.Background(), &runtimev1.RuntimeStatusRequest{}) + if err != nil { + t.Fatalf("GetRuntimeStatus() error = %v", err) + } + if statusResp.Service != consumer.RuntimeServiceName { + t.Fatalf("GetRuntimeStatus() service = %q, want %q", statusResp.Service, consumer.RuntimeServiceName) + } + if statusResp.Mode != "runtime-worker" { + t.Fatalf("GetRuntimeStatus() mode = %q, want %q", statusResp.Mode, "runtime-worker") + } + if statusResp.DbAvailable || statusResp.RedisAvailable || statusResp.K8SAvailable || statusResp.BuildkitAvailable || statusResp.HelmAvailable { + t.Fatalf("GetRuntimeStatus() unexpected dependency availability: %+v", statusResp) + } +} diff --git a/src/interface/grpc/system/lifecycle.go b/src/interface/grpc/system/lifecycle.go new file mode 100644 index 00000000..cfc0f865 --- /dev/null +++ b/src/interface/grpc/system/lifecycle.go @@ -0,0 +1,94 @@ +package grpcsystem + +import ( + "context" + "fmt" + "net" + + "aegis/config" + "aegis/httpx" + systemv1 "aegis/proto/system/v1" + + "github.com/sirupsen/logrus" + "go.uber.org/fx" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" +) + +const defaultSystemGRPCAddr = ":9095" + +type Lifecycle struct { + server *grpc.Server + addr string + listener net.Listener + StartFunc func(context.Context) error + StopFunc func() +} + +func newLifecycle(systemServer *systemServer) (*Lifecycle, error) { + grpcServer := grpc.NewServer(grpc.UnaryInterceptor(httpx.UnaryServerRequestIDInterceptor())) + systemv1.RegisterSystemServiceServer(grpcServer, systemServer) + + healthServer := health.NewServer() + healthServer.SetServingStatus(systemv1.SystemService_ServiceDesc.ServiceName, grpc_health_v1.HealthCheckResponse_SERVING) + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) + + if config.GetBool("system.grpc.reflection") { + reflection.Register(grpcServer) + } + + addr := config.GetString("system.grpc.addr") + if addr == "" { + addr = defaultSystemGRPCAddr + } + + return &Lifecycle{ + server: grpcServer, + addr: addr, + }, nil +} + +func (r *Lifecycle) start(ctx context.Context) error { + if r.StartFunc != nil { + return r.StartFunc(ctx) + } + + listener, err := net.Listen("tcp", r.addr) + if err != nil { + return fmt.Errorf("listen system grpc on %s: %w", r.addr, err) + } + r.listener = listener + + go func() { + logrus.Infof("Starting system gRPC server on %s", r.addr) + if err := r.server.Serve(listener); err != nil { + logrus.Errorf("system gRPC server error: %v", err) + } + }() + return nil +} + +func (r *Lifecycle) stop() { + if r.StopFunc != nil { + r.StopFunc() + return + } + if r.server != nil { + r.server.GracefulStop() + } +} + +func registerLifecycle(lc fx.Lifecycle, runner *Lifecycle) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + return runner.start(ctx) + }, + OnStop: func(ctx context.Context) error { + runner.stop() + return nil + }, + }) +} diff --git a/src/interface/grpc/system/module.go b/src/interface/grpc/system/module.go new file mode 100644 index 00000000..1e89a851 --- /dev/null +++ b/src/interface/grpc/system/module.go @@ -0,0 +1,11 @@ +package grpcsystem + +import "go.uber.org/fx" + +var Module = fx.Module("grpc_system", + fx.Provide( + newSystemServer, + newLifecycle, + ), + fx.Invoke(registerLifecycle), +) diff --git a/src/interface/grpc/system/service.go b/src/interface/grpc/system/service.go new file mode 100644 index 00000000..4468127a --- /dev/null +++ b/src/interface/grpc/system/service.go @@ -0,0 +1,233 @@ +package grpcsystem + +import ( + "context" + "encoding/json" + "errors" + "time" + + "aegis/consts" + "aegis/dto" + system "aegis/module/system" + systemmetric "aegis/module/systemmetric" + systemv1 "aegis/proto/system/v1" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +const systemServiceName = "system-service" + +type systemReader interface { + GetHealth(context.Context) (*system.HealthCheckResp, error) + GetMetrics(context.Context) (*system.MonitoringMetricsResp, error) + GetSystemInfo(context.Context) (*system.SystemInfo, error) + ListNamespaceLocks(context.Context) (*system.ListNamespaceLockResp, error) + ListQueuedTasks(context.Context) (*system.QueuedTasksResp, error) + GetAuditLog(context.Context, int) (*system.AuditLogDetailResp, error) + ListAuditLogs(context.Context, *system.ListAuditLogReq) (*dto.ListResp[system.AuditLogResp], error) + GetConfig(context.Context, int) (*system.ConfigDetailResp, error) + ListConfigs(context.Context, *system.ListConfigReq) (*dto.ListResp[system.ConfigResp], error) +} + +type metricsReader interface { + GetSystemMetrics(context.Context) (*systemmetric.SystemMetricsResp, error) + GetSystemMetricsHistory(context.Context) (*systemmetric.SystemMetricsHistoryResp, error) +} + +type systemServer struct { + systemv1.UnimplementedSystemServiceServer + system systemReader + metrics metricsReader +} + +func newSystemServer(system *system.Service, metrics *systemmetric.Service) *systemServer { + return &systemServer{ + system: system, + metrics: metrics, + } +} + +func (s *systemServer) Ping(context.Context, *systemv1.PingRequest) (*systemv1.PingResponse, error) { + return &systemv1.PingResponse{ + Service: systemServiceName, + AppId: consts.AppID, + Status: "ok", + TimestampUnix: time.Now().Unix(), + }, nil +} + +func (s *systemServer) GetHealth(ctx context.Context, _ *systemv1.PingRequest) (*systemv1.ResourceItemResponse, error) { + resp, err := s.system.GetHealth(ctx) + if err != nil { + return nil, mapSystemError(err) + } + return encodeItemResponse(resp) +} + +func (s *systemServer) GetMetrics(ctx context.Context, _ *systemv1.PingRequest) (*systemv1.ResourceItemResponse, error) { + resp, err := s.system.GetMetrics(ctx) + if err != nil { + return nil, mapSystemError(err) + } + return encodeItemResponse(resp) +} + +func (s *systemServer) GetSystemInfo(ctx context.Context, _ *systemv1.PingRequest) (*systemv1.ResourceItemResponse, error) { + resp, err := s.system.GetSystemInfo(ctx) + if err != nil { + return nil, mapSystemError(err) + } + return encodeItemResponse(resp) +} + +func (s *systemServer) ListConfigs(ctx context.Context, req *systemv1.ListConfigsRequest) (*systemv1.ResourceListResponse, error) { + query, err := decodeQuery[system.ListConfigReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.system.ListConfigs(ctx, query) + if err != nil { + return nil, mapSystemError(err) + } + return encodeListResponse(resp) +} + +func (s *systemServer) GetConfig(ctx context.Context, req *systemv1.GetResourceRequest) (*systemv1.ResourceItemResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + resp, err := s.system.GetConfig(ctx, int(req.GetId())) + if err != nil { + return nil, mapSystemError(err) + } + return encodeItemResponse(resp) +} + +func (s *systemServer) ListAuditLogs(ctx context.Context, req *systemv1.ListAuditLogsRequest) (*systemv1.ResourceListResponse, error) { + query, err := decodeQuery[system.ListAuditLogReq](req.GetQuery()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if err := query.Validate(); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + resp, err := s.system.ListAuditLogs(ctx, query) + if err != nil { + return nil, mapSystemError(err) + } + return encodeListResponse(resp) +} + +func (s *systemServer) GetAuditLog(ctx context.Context, req *systemv1.GetResourceRequest) (*systemv1.ResourceItemResponse, error) { + if req.GetId() <= 0 { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + resp, err := s.system.GetAuditLog(ctx, int(req.GetId())) + if err != nil { + return nil, mapSystemError(err) + } + return encodeItemResponse(resp) +} + +func (s *systemServer) ListNamespaceLocks(ctx context.Context, _ *systemv1.PingRequest) (*systemv1.ResourceItemResponse, error) { + resp, err := s.system.ListNamespaceLocks(ctx) + if err != nil { + return nil, mapSystemError(err) + } + return encodeItemResponse(resp) +} + +func (s *systemServer) ListQueuedTasks(ctx context.Context, _ *systemv1.PingRequest) (*systemv1.ResourceItemResponse, error) { + resp, err := s.system.ListQueuedTasks(ctx) + if err != nil { + return nil, mapSystemError(err) + } + return encodeItemResponse(resp) +} + +func (s *systemServer) GetSystemMetrics(ctx context.Context, _ *systemv1.PingRequest) (*systemv1.ResourceItemResponse, error) { + resp, err := s.metrics.GetSystemMetrics(ctx) + if err != nil { + return nil, mapSystemError(err) + } + return encodeItemResponse(resp) +} + +func (s *systemServer) GetSystemMetricsHistory(ctx context.Context, _ *systemv1.PingRequest) (*systemv1.ResourceItemResponse, error) { + resp, err := s.metrics.GetSystemMetricsHistory(ctx) + if err != nil { + return nil, mapSystemError(err) + } + return encodeItemResponse(resp) +} + +func decodeQuery[T any](query *structpb.Struct) (*T, error) { + var result T + if query == nil { + return &result, nil + } + + data, err := json.Marshal(query.AsMap()) + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +func encodeItemResponse(value any) (*systemv1.ResourceItemResponse, error) { + item, err := toStruct(value) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return &systemv1.ResourceItemResponse{Data: item}, nil +} + +func encodeListResponse(value any) (*systemv1.ResourceListResponse, error) { + item, err := toStruct(value) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return &systemv1.ResourceListResponse{Data: item}, nil +} + +func toStruct(value any) (*structpb.Struct, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + + payload := map[string]any{} + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + return structpb.NewStruct(payload) +} + +func mapSystemError(err error) error { + switch { + case errors.Is(err, consts.ErrAuthenticationFailed): + return status.Error(codes.Unauthenticated, err.Error()) + case errors.Is(err, consts.ErrPermissionDenied): + return status.Error(codes.PermissionDenied, err.Error()) + case errors.Is(err, consts.ErrBadRequest): + return status.Error(codes.InvalidArgument, err.Error()) + case errors.Is(err, consts.ErrNotFound): + return status.Error(codes.NotFound, err.Error()) + case errors.Is(err, consts.ErrAlreadyExists): + return status.Error(codes.AlreadyExists, err.Error()) + case err != nil: + return status.Error(codes.Internal, err.Error()) + default: + return nil + } +} diff --git a/src/interface/grpc/system/service_test.go b/src/interface/grpc/system/service_test.go new file mode 100644 index 00000000..82571e01 --- /dev/null +++ b/src/interface/grpc/system/service_test.go @@ -0,0 +1,171 @@ +package grpcsystem + +import ( + "context" + "errors" + "testing" + "time" + + "aegis/consts" + "aegis/dto" + system "aegis/module/system" + systemmetric "aegis/module/systemmetric" + systemv1 "aegis/proto/system/v1" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +type systemReaderStub struct { + health *system.HealthCheckResp + metrics *system.MonitoringMetricsResp + info *system.SystemInfo + locks *system.ListNamespaceLockResp + queued *system.QueuedTasksResp + audit *system.AuditLogDetailResp + audits *dto.ListResp[system.AuditLogResp] + config *system.ConfigDetailResp + configs *dto.ListResp[system.ConfigResp] + err error +} + +func (s systemReaderStub) GetHealth(context.Context) (*system.HealthCheckResp, error) { + return s.health, s.err +} +func (s systemReaderStub) GetMetrics(context.Context) (*system.MonitoringMetricsResp, error) { + return s.metrics, s.err +} +func (s systemReaderStub) GetSystemInfo(context.Context) (*system.SystemInfo, error) { + return s.info, s.err +} +func (s systemReaderStub) ListNamespaceLocks(context.Context) (*system.ListNamespaceLockResp, error) { + return s.locks, s.err +} +func (s systemReaderStub) ListQueuedTasks(context.Context) (*system.QueuedTasksResp, error) { + return s.queued, s.err +} +func (s systemReaderStub) GetAuditLog(_ context.Context, id int) (*system.AuditLogDetailResp, error) { + if id <= 0 { + return nil, errors.New("invalid id") + } + return s.audit, s.err +} +func (s systemReaderStub) ListAuditLogs(context.Context, *system.ListAuditLogReq) (*dto.ListResp[system.AuditLogResp], error) { + return s.audits, s.err +} +func (s systemReaderStub) GetConfig(_ context.Context, id int) (*system.ConfigDetailResp, error) { + if id <= 0 { + return nil, errors.New("invalid id") + } + return s.config, s.err +} +func (s systemReaderStub) ListConfigs(context.Context, *system.ListConfigReq) (*dto.ListResp[system.ConfigResp], error) { + return s.configs, s.err +} + +type metricsReaderStub struct { + current *systemmetric.SystemMetricsResp + history *systemmetric.SystemMetricsHistoryResp + err error +} + +func (s metricsReaderStub) GetSystemMetrics(context.Context) (*systemmetric.SystemMetricsResp, error) { + return s.current, s.err +} +func (s metricsReaderStub) GetSystemMetricsHistory(context.Context) (*systemmetric.SystemMetricsHistoryResp, error) { + return s.history, s.err +} + +func TestSystemServerGetHealth(t *testing.T) { + server := &systemServer{ + system: systemReaderStub{ + health: &system.HealthCheckResp{ + Status: "healthy", + Timestamp: time.Now(), + Version: "v1", + Uptime: "1m", + Services: map[string]system.ServiceInfo{ + "redis": {Status: "healthy"}, + }, + }, + metrics: &system.MonitoringMetricsResp{}, + info: &system.SystemInfo{}, + }, + metrics: metricsReaderStub{}, + } + + resp, err := server.GetHealth(context.Background(), &systemv1.PingRequest{}) + if err != nil { + t.Fatalf("GetHealth() error = %v", err) + } + if resp.GetData().AsMap()["status"] != "healthy" { + t.Fatalf("GetHealth() unexpected response: %+v", resp.GetData().AsMap()) + } +} + +func TestSystemServerListConfigs(t *testing.T) { + server := &systemServer{ + system: systemReaderStub{ + configs: &dto.ListResp[system.ConfigResp]{ + Items: []system.ConfigResp{{ID: 1, Key: "demo.key"}}, + Pagination: &dto.PaginationInfo{ + Page: 1, Size: 20, Total: 1, TotalPages: 1, + }, + }, + metrics: &system.MonitoringMetricsResp{}, + info: &system.SystemInfo{}, + }, + metrics: metricsReaderStub{}, + } + + query, err := structpb.NewStruct(map[string]any{"page": 1, "size": 20}) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + + resp, err := server.ListConfigs(context.Background(), &systemv1.ListConfigsRequest{Query: query}) + if err != nil { + t.Fatalf("ListConfigs() error = %v", err) + } + if resp.GetData().AsMap()["items"] == nil { + t.Fatalf("ListConfigs() unexpected response: %+v", resp.GetData().AsMap()) + } +} + +func TestSystemServerGetAuditLogNotFound(t *testing.T) { + server := &systemServer{ + system: systemReaderStub{err: consts.ErrNotFound}, + metrics: metricsReaderStub{}, + } + + _, err := server.GetAuditLog(context.Background(), &systemv1.GetResourceRequest{Id: 1}) + if err == nil { + t.Fatal("GetAuditLog() error = nil, want error") + } + if status.Code(err) != codes.NotFound { + t.Fatalf("GetAuditLog() code = %s, want %s", status.Code(err), codes.NotFound) + } +} + +func TestSystemServerGetSystemMetricsHistory(t *testing.T) { + server := &systemServer{ + system: systemReaderStub{ + metrics: &system.MonitoringMetricsResp{}, + info: &system.SystemInfo{}, + }, + metrics: metricsReaderStub{ + history: &systemmetric.SystemMetricsHistoryResp{ + CPU: []systemmetric.MetricValue{{Value: 1}}, + }, + }, + } + + resp, err := server.GetSystemMetricsHistory(context.Background(), &systemv1.PingRequest{}) + if err != nil { + t.Fatalf("GetSystemMetricsHistory() error = %v", err) + } + if resp.GetData().AsMap()["cpu"] == nil { + t.Fatalf("GetSystemMetricsHistory() unexpected response: %+v", resp.GetData().AsMap()) + } +} diff --git a/src/interface/http/module.go b/src/interface/http/module.go new file mode 100644 index 00000000..d9cc5ba5 --- /dev/null +++ b/src/interface/http/module.go @@ -0,0 +1,17 @@ +package httpapi + +import ( + "aegis/middleware" + "aegis/router" + + "go.uber.org/fx" +) + +var Module = fx.Module("http", + fx.Provide( + middleware.NewService, + router.New, + NewServer, + ), + fx.Invoke(registerServerLifecycle), +) diff --git a/src/interface/http/server.go b/src/interface/http/server.go new file mode 100644 index 00000000..fc54863b --- /dev/null +++ b/src/interface/http/server.go @@ -0,0 +1,40 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "go.uber.org/fx" +) + +type ServerConfig struct { + Addr string +} + +func NewServer(config ServerConfig, engine *gin.Engine) *http.Server { + return &http.Server{ + Addr: config.Addr, + Handler: engine, + } +} + +func registerServerLifecycle(lc fx.Lifecycle, server *http.Server) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + go func() { + logrus.Infof("Starting HTTP server on %s", server.Addr) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logrus.Errorf("HTTP server error: %v", err) + } + }() + return nil + }, + OnStop: func(ctx context.Context) error { + logrus.Info("Stopping HTTP server") + return server.Shutdown(ctx) + }, + }) +} diff --git a/src/interface/receiver/module.go b/src/interface/receiver/module.go new file mode 100644 index 00000000..868f3b45 --- /dev/null +++ b/src/interface/receiver/module.go @@ -0,0 +1,76 @@ +package receiver + +import ( + "context" + + "aegis/config" + redis "aegis/infra/redis" + "aegis/service/logreceiver" + + "github.com/sirupsen/logrus" + "go.uber.org/fx" +) + +var Module = fx.Module("receiver", + fx.Provide(newLifecycle), + fx.Invoke(registerLifecycle), +) + +type Lifecycle struct { + receiver *logreceiver.OTLPLogReceiver + StartFunc func(context.Context) error + StopFunc func() +} + +func newLifecycle(redisGateway *redis.Gateway) *Lifecycle { + otlpPort := config.GetInt("otlp_receiver.port") + if otlpPort == 0 { + otlpPort = logreceiver.DefaultPort + } + return &Lifecycle{ + receiver: logreceiver.NewOTLPLogReceiver(otlpPort, 0, redisGateway), + } +} + +func (r *Lifecycle) start(ctx context.Context) error { + if r.StartFunc != nil { + return r.StartFunc(ctx) + } + go func() { + if err := r.receiver.Start(ctx); err != nil { + logrus.Errorf("OTLP log receiver error: %v", err) + } + }() + return nil +} + +func (r *Lifecycle) stop() { + if r.StopFunc != nil { + r.StopFunc() + return + } + if r.receiver != nil { + r.receiver.Shutdown() + } +} + +func registerLifecycle(lc fx.Lifecycle, runner *Lifecycle) { + var ( + receiverCtx context.Context + cancel context.CancelFunc + ) + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + receiverCtx, cancel = context.WithCancel(context.WithoutCancel(ctx)) + return runner.start(receiverCtx) + }, + OnStop: func(ctx context.Context) error { + if cancel != nil { + cancel() + } + runner.stop() + return nil + }, + }) +} diff --git a/src/interface/worker/module.go b/src/interface/worker/module.go new file mode 100644 index 00000000..66f3ad00 --- /dev/null +++ b/src/interface/worker/module.go @@ -0,0 +1,118 @@ +package worker + +import ( + "context" + + buildkit "aegis/infra/buildkit" + etcd "aegis/infra/etcd" + helm "aegis/infra/helm" + k8s "aegis/infra/k8s" + redis "aegis/infra/redis" + commonservice "aegis/service/common" + "aegis/service/consumer" + "aegis/service/initialization" + + "go.uber.org/fx" + "gorm.io/gorm" +) + +var Module = fx.Module("worker", + fx.Provide(newLifecycle), + fx.Invoke(registerLifecycle), +) + +type Params struct { + fx.In + + DB *gorm.DB + RedisGateway *redis.Gateway + BuildKit *buildkit.Gateway + Helm *helm.Gateway + K8sGateway *k8s.Gateway + Controller *k8s.Controller + Etcd *etcd.Gateway + Monitor consumer.NamespaceMonitor + RestartLimiter *consumer.TokenBucketRateLimiter `name:"restart_limiter"` + BuildLimiter *consumer.TokenBucketRateLimiter `name:"build_limiter"` + AlgoLimiter *consumer.TokenBucketRateLimiter `name:"algo_limiter"` + BatchManager *consumer.FaultBatchManager + ExecutionOwner consumer.ExecutionOwner + InjectionOwner consumer.InjectionOwner +} + +type Lifecycle struct { + params Params + StartFunc func(context.Context) error + StopFunc func() +} + +func newLifecycle(params Params) *Lifecycle { + return &Lifecycle{params: params} +} + +func (r *Lifecycle) start(ctx context.Context) error { + if r.StartFunc != nil { + return r.StartFunc(ctx) + } + params := r.params + if err := initialization.InitializeConsumer( + ctx, + params.DB, + params.Controller, + params.Monitor, + params.RedisGateway, + commonservice.NewConfigUpdateListener(ctx, params.DB, params.Etcd), + params.RestartLimiter, + params.BuildLimiter, + params.AlgoLimiter, + ); err != nil { + return err + } + if err := params.RedisGateway.InitConcurrencyLock(ctx); err != nil { + return err + } + + go consumer.StartScheduler(ctx, params.RedisGateway) + go consumer.ConsumeTasks(ctx, consumer.RuntimeDeps{ + DB: params.DB, + Monitor: params.Monitor, + RestartRateLimiter: params.RestartLimiter, + BuildRateLimiter: params.BuildLimiter, + AlgorithmRateLimiter: params.AlgoLimiter, + RedisGateway: params.RedisGateway, + K8sGateway: params.K8sGateway, + BuildKitGateway: params.BuildKit, + HelmGateway: params.Helm, + FaultBatchManager: params.BatchManager, + ExecutionOwner: params.ExecutionOwner, + InjectionOwner: params.InjectionOwner, + }) + return nil +} + +func (r *Lifecycle) stop() { + if r.StopFunc != nil { + r.StopFunc() + } +} + +func registerLifecycle(lc fx.Lifecycle, runner *Lifecycle) { + var ( + workerCtx context.Context + cancel context.CancelFunc + ) + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + workerCtx, cancel = context.WithCancel(context.WithoutCancel(ctx)) + return runner.start(workerCtx) + }, + OnStop: func(ctx context.Context) error { + if cancel != nil { + cancel() + } + runner.stop() + return nil + }, + }) +} diff --git a/src/internalclient/iamclient/client.go b/src/internalclient/iamclient/client.go new file mode 100644 index 00000000..1733a74b --- /dev/null +++ b/src/internalclient/iamclient/client.go @@ -0,0 +1,1036 @@ +package iamclient + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "aegis/config" + "aegis/consts" + "aegis/dto" + "aegis/httpx" + "aegis/middleware" + auth "aegis/module/auth" + rbac "aegis/module/rbac" + team "aegis/module/team" + user "aegis/module/user" + iamv1 "aegis/proto/iam/v1" + "aegis/utils" + + "github.com/golang-jwt/jwt/v5" + "go.uber.org/fx" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +type Client struct { + target string + conn *grpc.ClientConn + rpc iamv1.IAMServiceClient +} + +func NewClient(lc fx.Lifecycle) (*Client, error) { + target := config.GetString("clients.iam.target") + if target == "" { + target = config.GetString("iam.grpc.target") + } + if target == "" { + return &Client{}, nil + } + + conn, err := grpc.NewClient( + target, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(httpx.UnaryClientRequestIDInterceptor()), + ) + if err != nil { + return nil, fmt.Errorf("create iam grpc client: %w", err) + } + + client := &Client{ + target: target, + conn: conn, + rpc: iamv1.NewIAMServiceClient(conn), + } + + lc.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + return conn.Close() + }, + }) + + return client, nil +} + +func (c *Client) Enabled() bool { + return c != nil && c.rpc != nil +} + +func (c *Client) VerifyToken(ctx context.Context, token string) (*utils.Claims, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + + resp, err := c.rpc.VerifyToken(ctx, &iamv1.VerifyTokenRequest{Token: token}) + if err != nil { + return nil, mapRPCError(err) + } + if resp.GetTokenType() != "user" { + return nil, fmt.Errorf("token is not a user token") + } + return &utils.Claims{ + UserID: int(resp.GetUserId()), + Username: resp.GetUsername(), + Email: resp.GetEmail(), + IsActive: resp.GetIsActive(), + IsAdmin: resp.GetIsAdmin(), + Roles: resp.GetRoles(), + AuthType: resp.GetAuthType(), + APIKeyID: int(resp.GetKeyId()), + APIKeyScopes: resp.GetApiKeyScopes(), + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Unix(resp.GetExpiresAtUnix(), 0)), + }, + }, nil +} + +func (c *Client) VerifyServiceToken(ctx context.Context, token string) (*utils.ServiceClaims, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + + resp, err := c.rpc.VerifyToken(ctx, &iamv1.VerifyTokenRequest{Token: token}) + if err != nil { + return nil, mapRPCError(err) + } + if resp.GetTokenType() != "service" { + return nil, fmt.Errorf("token is not a service token") + } + return &utils.ServiceClaims{ + TaskID: resp.GetTaskId(), + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Unix(resp.GetExpiresAtUnix(), 0)), + }, + }, nil +} + +func (c *Client) CheckUserPermission(ctx context.Context, params *dto.CheckPermissionParams) (bool, error) { + if !c.Enabled() { + return false, fmt.Errorf("iam grpc client is not configured") + } + if params == nil { + return false, fmt.Errorf("permission params are nil") + } + + req := &iamv1.CheckPermissionRequest{ + UserId: int64(params.UserID), + Action: string(params.Action), + Scope: string(params.Scope), + ResourceName: string(params.ResourceName), + } + if params.TeamID != nil { + req.TeamId = int64(*params.TeamID) + } + if params.ProjectID != nil { + req.ProjectId = int64(*params.ProjectID) + } + if params.ContainerID != nil { + req.ContainerId = int64(*params.ContainerID) + } + if params.DatasetID != nil { + req.DatasetId = int64(*params.DatasetID) + } + + resp, err := c.rpc.CheckPermission(ctx, req) + if err != nil { + return false, mapRPCError(err) + } + return resp.GetAllowed(), nil +} + +func (c *Client) IsUserTeamAdmin(ctx context.Context, userID, teamID int) (bool, error) { + if !c.Enabled() { + return false, fmt.Errorf("iam grpc client is not configured") + } + + resp, err := c.rpc.IsUserTeamAdmin(ctx, &iamv1.UserTeamRequest{ + UserId: int64(userID), + TeamId: int64(teamID), + }) + if err != nil { + return false, mapRPCError(err) + } + return resp.GetValue(), nil +} + +func (c *Client) IsUserInTeam(ctx context.Context, userID, teamID int) (bool, error) { + if !c.Enabled() { + return false, fmt.Errorf("iam grpc client is not configured") + } + + resp, err := c.rpc.IsUserInTeam(ctx, &iamv1.UserTeamRequest{ + UserId: int64(userID), + TeamId: int64(teamID), + }) + if err != nil { + return false, mapRPCError(err) + } + return resp.GetValue(), nil +} + +func (c *Client) IsTeamPublic(ctx context.Context, teamID int) (bool, error) { + if !c.Enabled() { + return false, fmt.Errorf("iam grpc client is not configured") + } + + resp, err := c.rpc.IsTeamPublic(ctx, &iamv1.TeamRequest{TeamId: int64(teamID)}) + if err != nil { + return false, mapRPCError(err) + } + return resp.GetValue(), nil +} + +func (c *Client) IsUserProjectAdmin(ctx context.Context, userID, projectID int) (bool, error) { + if !c.Enabled() { + return false, fmt.Errorf("iam grpc client is not configured") + } + + resp, err := c.rpc.IsUserProjectAdmin(ctx, &iamv1.UserProjectRequest{ + UserId: int64(userID), + ProjectId: int64(projectID), + }) + if err != nil { + return false, mapRPCError(err) + } + return resp.GetValue(), nil +} + +func (c *Client) IsUserInProject(ctx context.Context, userID, projectID int) (bool, error) { + if !c.Enabled() { + return false, fmt.Errorf("iam grpc client is not configured") + } + + resp, err := c.rpc.IsUserInProject(ctx, &iamv1.UserProjectRequest{ + UserId: int64(userID), + ProjectId: int64(projectID), + }) + if err != nil { + return false, mapRPCError(err) + } + return resp.GetValue(), nil +} + +func (c *Client) Login(ctx context.Context, req *auth.LoginReq) (*auth.LoginResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode login request: %w", err) + } + resp, err := c.rpc.Login(ctx, &iamv1.MutationRequest{Body: body}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[auth.LoginResp](resp.GetData()) +} + +func (c *Client) Register(ctx context.Context, req *auth.RegisterReq) (*auth.UserInfo, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode register request: %w", err) + } + resp, err := c.rpc.Register(ctx, &iamv1.MutationRequest{Body: body}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[auth.UserInfo](resp.GetData()) +} + +func (c *Client) RefreshToken(ctx context.Context, req *auth.TokenRefreshReq) (*auth.TokenRefreshResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode refresh token request: %w", err) + } + resp, err := c.rpc.RefreshToken(ctx, &iamv1.MutationRequest{Body: body}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[auth.TokenRefreshResp](resp.GetData()) +} + +func (c *Client) Logout(ctx context.Context, claims *utils.Claims) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + if claims == nil || claims.ExpiresAt == nil || claims.ID == "" { + return fmt.Errorf("logout claims are incomplete") + } + _, err := c.rpc.Logout(ctx, &iamv1.LogoutRequest{ + UserId: int64(claims.UserID), + TokenId: claims.ID, + ExpiresAtUnix: claims.ExpiresAt.Unix(), + }) + return mapRPCError(err) +} + +func (c *Client) ChangePassword(ctx context.Context, req *auth.ChangePasswordReq, userID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return fmt.Errorf("encode change password request: %w", err) + } + _, err = c.rpc.ChangePassword(ctx, &iamv1.UserBodyRequest{ + UserId: int64(userID), + Body: body, + }) + return mapRPCError(err) +} + +func (c *Client) GetProfile(ctx context.Context, userID int) (*auth.UserProfileResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + resp, err := c.rpc.GetProfile(ctx, &iamv1.UserIDRequest{UserId: int64(userID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[auth.UserProfileResp](resp.GetData()) +} + +func (c *Client) CreateAPIKey(ctx context.Context, userID int, req *auth.CreateAPIKeyReq) (*auth.APIKeyWithSecretResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode create api key request: %w", err) + } + resp, err := c.rpc.CreateAPIKey(ctx, &iamv1.UserBodyRequest{ + UserId: int64(userID), + Body: body, + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[auth.APIKeyWithSecretResp](resp.GetData()) +} + +func (c *Client) ListAPIKeys(ctx context.Context, userID int, req *auth.ListAPIKeyReq) (*auth.ListAPIKeyResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode list api keys request: %w", err) + } + resp, err := c.rpc.ListAPIKeys(ctx, &iamv1.UserQueryRequest{ + UserId: int64(userID), + Query: query, + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[auth.ListAPIKeyResp](resp.GetData()) +} + +func (c *Client) GetAPIKey(ctx context.Context, userID, accessKeyID int) (*auth.APIKeyInfo, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + resp, err := c.rpc.GetAPIKey(ctx, &iamv1.UserScopedIDRequest{ + UserId: int64(userID), + Id: int64(accessKeyID), + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[auth.APIKeyInfo](resp.GetData()) +} + +func (c *Client) DeleteAPIKey(ctx context.Context, userID, accessKeyID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.DeleteAPIKey(ctx, &iamv1.UserScopedIDRequest{ + UserId: int64(userID), + Id: int64(accessKeyID), + }) + return mapRPCError(err) +} + +func (c *Client) DisableAPIKey(ctx context.Context, userID, accessKeyID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.DisableAPIKey(ctx, &iamv1.UserScopedIDRequest{ + UserId: int64(userID), + Id: int64(accessKeyID), + }) + return mapRPCError(err) +} + +func (c *Client) EnableAPIKey(ctx context.Context, userID, accessKeyID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.EnableAPIKey(ctx, &iamv1.UserScopedIDRequest{ + UserId: int64(userID), + Id: int64(accessKeyID), + }) + return mapRPCError(err) +} + +func (c *Client) RevokeAPIKey(ctx context.Context, userID, accessKeyID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.RevokeAPIKey(ctx, &iamv1.UserScopedIDRequest{ + UserId: int64(userID), + Id: int64(accessKeyID), + }) + return mapRPCError(err) +} + +func (c *Client) RotateAPIKey(ctx context.Context, userID, accessKeyID int) (*auth.APIKeyWithSecretResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + resp, err := c.rpc.RotateAPIKey(ctx, &iamv1.UserScopedIDRequest{ + UserId: int64(userID), + Id: int64(accessKeyID), + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[auth.APIKeyWithSecretResp](resp.GetData()) +} + +func (c *Client) ExchangeAPIKeyToken(ctx context.Context, req *auth.APIKeyTokenReq, method, path string) (*auth.APIKeyTokenResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + resp, err := c.rpc.ExchangeAPIKeyToken(ctx, &iamv1.ExchangeAPIKeyTokenRequest{ + KeyId: req.KeyID, + Timestamp: req.Timestamp, + Nonce: req.Nonce, + Signature: req.Signature, + Method: method, + Path: path, + }) + if err != nil { + return nil, mapRPCError(err) + } + return &auth.APIKeyTokenResp{ + Token: resp.GetToken(), + TokenType: resp.GetTokenType(), + ExpiresAt: time.Unix(resp.GetExpiresAtUnix(), 0), + AuthType: resp.GetAuthType(), + KeyID: resp.GetKeyId(), + }, nil +} + +func (c *Client) CreateUser(ctx context.Context, req *user.CreateUserReq) (*user.UserResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode create user request: %w", err) + } + resp, err := c.rpc.CreateUser(ctx, &iamv1.MutationRequest{Body: body}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[user.UserResp](resp.GetData()) +} + +func (c *Client) DeleteUser(ctx context.Context, userID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.DeleteUser(ctx, &iamv1.IDRequest{Id: int64(userID)}) + return mapRPCError(err) +} + +func (c *Client) GetUser(ctx context.Context, userID int) (*user.UserDetailResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + resp, err := c.rpc.GetUser(ctx, &iamv1.IDRequest{Id: int64(userID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[user.UserDetailResp](resp.GetData()) +} + +func (c *Client) ListUsers(ctx context.Context, req *user.ListUserReq) (*dto.ListResp[user.UserResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode list users request: %w", err) + } + resp, err := c.rpc.ListUsers(ctx, &iamv1.QueryRequest{Query: query}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[user.UserResp]](resp.GetData()) +} + +func (c *Client) UpdateUser(ctx context.Context, req *user.UpdateUserReq, userID int) (*user.UserResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode update user request: %w", err) + } + resp, err := c.rpc.UpdateUser(ctx, &iamv1.UpdateByIDRequest{ + Id: int64(userID), + Body: body, + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[user.UserResp](resp.GetData()) +} + +func (c *Client) AssignUserRole(ctx context.Context, userID, roleID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.AssignUserRole(ctx, &iamv1.UserRoleBindingRequest{ + UserId: int64(userID), + RoleId: int64(roleID), + }) + return mapRPCError(err) +} + +func (c *Client) RemoveUserRole(ctx context.Context, userID, roleID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.RemoveUserRole(ctx, &iamv1.UserRoleBindingRequest{ + UserId: int64(userID), + RoleId: int64(roleID), + }) + return mapRPCError(err) +} + +func (c *Client) AssignUserPermissions(ctx context.Context, userID int, req *user.AssignUserPermissionReq) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return fmt.Errorf("encode assign user permissions request: %w", err) + } + _, err = c.rpc.AssignUserPermissions(ctx, &iamv1.UserBodyRequest{ + UserId: int64(userID), + Body: body, + }) + return mapRPCError(err) +} + +func (c *Client) RemoveUserPermissions(ctx context.Context, userID int, req *user.RemoveUserPermissionReq) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return fmt.Errorf("encode remove user permissions request: %w", err) + } + _, err = c.rpc.RemoveUserPermissions(ctx, &iamv1.UserBodyRequest{ + UserId: int64(userID), + Body: body, + }) + return mapRPCError(err) +} + +func (c *Client) AssignUserContainer(ctx context.Context, userID, containerID, roleID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.AssignUserContainer(ctx, &iamv1.UserResourceBindingRequest{ + UserId: int64(userID), + ResourceId: int64(containerID), + RoleId: int64(roleID), + }) + return mapRPCError(err) +} + +func (c *Client) RemoveUserContainer(ctx context.Context, userID, containerID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.RemoveUserContainer(ctx, &iamv1.UserScopedIDRequest{ + UserId: int64(userID), + Id: int64(containerID), + }) + return mapRPCError(err) +} + +func (c *Client) AssignUserDataset(ctx context.Context, userID, datasetID, roleID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.AssignUserDataset(ctx, &iamv1.UserResourceBindingRequest{ + UserId: int64(userID), + ResourceId: int64(datasetID), + RoleId: int64(roleID), + }) + return mapRPCError(err) +} + +func (c *Client) RemoveUserDataset(ctx context.Context, userID, datasetID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.RemoveUserDataset(ctx, &iamv1.UserScopedIDRequest{ + UserId: int64(userID), + Id: int64(datasetID), + }) + return mapRPCError(err) +} + +func (c *Client) AssignUserProject(ctx context.Context, userID, projectID, roleID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.AssignUserProject(ctx, &iamv1.UserResourceBindingRequest{ + UserId: int64(userID), + ResourceId: int64(projectID), + RoleId: int64(roleID), + }) + return mapRPCError(err) +} + +func (c *Client) RemoveUserProject(ctx context.Context, userID, projectID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.RemoveUserProject(ctx, &iamv1.UserScopedIDRequest{ + UserId: int64(userID), + Id: int64(projectID), + }) + return mapRPCError(err) +} + +func (c *Client) CreateRole(ctx context.Context, req *rbac.CreateRoleReq) (*rbac.RoleResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode create role request: %w", err) + } + resp, err := c.rpc.CreateRole(ctx, &iamv1.MutationRequest{Body: body}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[rbac.RoleResp](resp.GetData()) +} + +func (c *Client) DeleteRole(ctx context.Context, roleID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.DeleteRole(ctx, &iamv1.IDRequest{Id: int64(roleID)}) + return mapRPCError(err) +} + +func (c *Client) GetRole(ctx context.Context, roleID int) (*rbac.RoleDetailResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + resp, err := c.rpc.GetRole(ctx, &iamv1.IDRequest{Id: int64(roleID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[rbac.RoleDetailResp](resp.GetData()) +} + +func (c *Client) ListRoles(ctx context.Context, req *rbac.ListRoleReq) (*dto.ListResp[rbac.RoleResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode list roles request: %w", err) + } + resp, err := c.rpc.ListRoles(ctx, &iamv1.QueryRequest{Query: query}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[rbac.RoleResp]](resp.GetData()) +} + +func (c *Client) UpdateRole(ctx context.Context, req *rbac.UpdateRoleReq, roleID int) (*rbac.RoleResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode update role request: %w", err) + } + resp, err := c.rpc.UpdateRole(ctx, &iamv1.UpdateByIDRequest{ + Id: int64(roleID), + Body: body, + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[rbac.RoleResp](resp.GetData()) +} + +func (c *Client) AssignRolePermissions(ctx context.Context, roleID int, permissionIDs []int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.AssignRolePermissions(ctx, &iamv1.RolePermissionsRequest{ + RoleId: int64(roleID), + PermissionIds: intsToInt64s(permissionIDs), + }) + return mapRPCError(err) +} + +func (c *Client) RemoveRolePermissions(ctx context.Context, roleID int, permissionIDs []int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.RemoveRolePermissions(ctx, &iamv1.RolePermissionsRequest{ + RoleId: int64(roleID), + PermissionIds: intsToInt64s(permissionIDs), + }) + return mapRPCError(err) +} + +func (c *Client) ListUsersFromRole(ctx context.Context, roleID int) ([]rbac.UserListItem, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + resp, err := c.rpc.ListUsersFromRole(ctx, &iamv1.IDRequest{Id: int64(roleID)}) + if err != nil { + return nil, mapRPCError(err) + } + data, err := decodeStruct[[]rbac.UserListItem](resp.GetData()) + if err != nil { + return nil, err + } + return *data, nil +} + +func (c *Client) GetPermission(ctx context.Context, permissionID int) (*rbac.PermissionDetailResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + resp, err := c.rpc.GetPermission(ctx, &iamv1.IDRequest{Id: int64(permissionID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[rbac.PermissionDetailResp](resp.GetData()) +} + +func (c *Client) ListPermissions(ctx context.Context, req *rbac.ListPermissionReq) (*dto.ListResp[rbac.PermissionResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode list permissions request: %w", err) + } + resp, err := c.rpc.ListPermissions(ctx, &iamv1.QueryRequest{Query: query}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[rbac.PermissionResp]](resp.GetData()) +} + +func (c *Client) ListRolesFromPermission(ctx context.Context, permissionID int) ([]rbac.RoleResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + resp, err := c.rpc.ListRolesFromPermission(ctx, &iamv1.IDRequest{Id: int64(permissionID)}) + if err != nil { + return nil, mapRPCError(err) + } + data, err := decodeStruct[[]rbac.RoleResp](resp.GetData()) + if err != nil { + return nil, err + } + return *data, nil +} + +func (c *Client) GetResource(ctx context.Context, resourceID int) (*rbac.ResourceResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + resp, err := c.rpc.GetResource(ctx, &iamv1.IDRequest{Id: int64(resourceID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[rbac.ResourceResp](resp.GetData()) +} + +func (c *Client) ListResources(ctx context.Context, req *rbac.ListResourceReq) (*dto.ListResp[rbac.ResourceResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode list resources request: %w", err) + } + resp, err := c.rpc.ListResources(ctx, &iamv1.QueryRequest{Query: query}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[rbac.ResourceResp]](resp.GetData()) +} + +func (c *Client) ListResourcePermissions(ctx context.Context, resourceID int) ([]rbac.PermissionResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + resp, err := c.rpc.ListResourcePermissions(ctx, &iamv1.IDRequest{Id: int64(resourceID)}) + if err != nil { + return nil, mapRPCError(err) + } + data, err := decodeStruct[[]rbac.PermissionResp](resp.GetData()) + if err != nil { + return nil, err + } + return *data, nil +} + +func (c *Client) CreateTeam(ctx context.Context, req *team.CreateTeamReq, userID int) (*team.TeamResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode create team request: %w", err) + } + resp, err := c.rpc.CreateTeam(ctx, &iamv1.CreateTeamRequest{ + UserId: int64(userID), + Body: body, + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[team.TeamResp](resp.GetData()) +} + +func (c *Client) DeleteTeam(ctx context.Context, teamID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.DeleteTeam(ctx, &iamv1.TeamRequest{TeamId: int64(teamID)}) + return mapRPCError(err) +} + +func (c *Client) GetTeam(ctx context.Context, teamID int) (*team.TeamDetailResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + resp, err := c.rpc.GetTeam(ctx, &iamv1.TeamRequest{TeamId: int64(teamID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[team.TeamDetailResp](resp.GetData()) +} + +func (c *Client) ListTeams(ctx context.Context, req *team.ListTeamReq, userID int, isAdmin bool) (*dto.ListResp[team.TeamResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode list teams request: %w", err) + } + resp, err := c.rpc.ListTeams(ctx, &iamv1.ListTeamsRequest{ + UserId: int64(userID), + IsAdmin: isAdmin, + Query: query, + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[team.TeamResp]](resp.GetData()) +} + +func (c *Client) UpdateTeam(ctx context.Context, req *team.UpdateTeamReq, teamID int) (*team.TeamResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode update team request: %w", err) + } + resp, err := c.rpc.UpdateTeam(ctx, &iamv1.UpdateTeamRequest{ + TeamId: int64(teamID), + Body: body, + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[team.TeamResp](resp.GetData()) +} + +func (c *Client) ListTeamProjects(ctx context.Context, req *team.TeamProjectListReq, teamID int) (*dto.ListResp[team.TeamProjectItem], error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode list team projects request: %w", err) + } + resp, err := c.rpc.ListTeamProjects(ctx, &iamv1.ListTeamProjectsRequest{ + TeamId: int64(teamID), + Query: query, + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[team.TeamProjectItem]](resp.GetData()) +} + +func (c *Client) AddTeamMember(ctx context.Context, req *team.AddTeamMemberReq, teamID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return fmt.Errorf("encode add team member request: %w", err) + } + _, err = c.rpc.AddTeamMember(ctx, &iamv1.AddTeamMemberRequest{ + TeamId: int64(teamID), + Body: body, + }) + return mapRPCError(err) +} + +func (c *Client) RemoveTeamMember(ctx context.Context, teamID, currentUserID, targetUserID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + _, err := c.rpc.RemoveTeamMember(ctx, &iamv1.RemoveTeamMemberRequest{ + TeamId: int64(teamID), + CurrentUserId: int64(currentUserID), + TargetUserId: int64(targetUserID), + }) + return mapRPCError(err) +} + +func (c *Client) UpdateTeamMemberRole(ctx context.Context, req *team.UpdateTeamMemberRoleReq, teamID, targetUserID, currentUserID int) error { + if !c.Enabled() { + return fmt.Errorf("iam grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return fmt.Errorf("encode update team member role request: %w", err) + } + _, err = c.rpc.UpdateTeamMemberRole(ctx, &iamv1.UpdateTeamMemberRoleRequest{ + TeamId: int64(teamID), + TargetUserId: int64(targetUserID), + CurrentUserId: int64(currentUserID), + Body: body, + }) + return mapRPCError(err) +} + +func (c *Client) ListTeamMembers(ctx context.Context, req *team.ListTeamMemberReq, teamID int) (*dto.ListResp[team.TeamMemberResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("iam grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode list team members request: %w", err) + } + resp, err := c.rpc.ListTeamMembers(ctx, &iamv1.ListTeamMembersRequest{ + TeamId: int64(teamID), + Query: query, + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[team.TeamMemberResp]](resp.GetData()) +} + +var _ middleware.TokenVerifier = (*Client)(nil) + +func toStructPB(value any) (*structpb.Struct, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + payload := map[string]any{} + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + return structpb.NewStruct(payload) +} + +func decodeStruct[T any](payload *structpb.Struct) (*T, error) { + if payload == nil { + return nil, fmt.Errorf("iam payload is nil") + } + data, err := json.Marshal(payload.AsMap()) + if err != nil { + return nil, err + } + var result T + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +func intsToInt64s(items []int) []int64 { + if len(items) == 0 { + return nil + } + result := make([]int64, 0, len(items)) + for _, item := range items { + result = append(result, int64(item)) + } + return result +} + +func mapRPCError(err error) error { + if err == nil { + return nil + } + st, ok := status.FromError(err) + if !ok { + return err + } + + switch st.Code() { + case codes.Unauthenticated: + return fmt.Errorf("%w: %s", consts.ErrAuthenticationFailed, st.Message()) + case codes.PermissionDenied: + return fmt.Errorf("%w: %s", consts.ErrPermissionDenied, st.Message()) + case codes.InvalidArgument: + return fmt.Errorf("%w: %s", consts.ErrBadRequest, st.Message()) + case codes.NotFound: + return fmt.Errorf("%w: %s", consts.ErrNotFound, st.Message()) + case codes.AlreadyExists: + return fmt.Errorf("%w: %s", consts.ErrAlreadyExists, st.Message()) + default: + return fmt.Errorf("iam rpc failed: %w", err) + } +} diff --git a/src/internalclient/iamclient/module.go b/src/internalclient/iamclient/module.go new file mode 100644 index 00000000..c764af5f --- /dev/null +++ b/src/internalclient/iamclient/module.go @@ -0,0 +1,7 @@ +package iamclient + +import "go.uber.org/fx" + +var Module = fx.Module("iam_client", + fx.Provide(NewClient), +) diff --git a/src/internalclient/orchestratorclient/client.go b/src/internalclient/orchestratorclient/client.go new file mode 100644 index 00000000..a4cadb5a --- /dev/null +++ b/src/internalclient/orchestratorclient/client.go @@ -0,0 +1,664 @@ +package orchestratorclient + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "aegis/config" + "aegis/consts" + "aegis/dto" + "aegis/httpx" + execution "aegis/module/execution" + group "aegis/module/group" + injection "aegis/module/injection" + metric "aegis/module/metric" + task "aegis/module/task" + trace "aegis/module/trace" + orchestratorv1 "aegis/proto/orchestrator/v1" + + "github.com/redis/go-redis/v9" + "go.uber.org/fx" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +type Client struct { + target string + conn *grpc.ClientConn + rpc orchestratorv1.OrchestratorServiceClient +} + +func NewClient(lc fx.Lifecycle) (*Client, error) { + target := config.GetString("clients.orchestrator.target") + if target == "" { + target = config.GetString("orchestrator.grpc.target") + } + if target == "" { + return &Client{}, nil + } + + conn, err := grpc.NewClient( + target, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(httpx.UnaryClientRequestIDInterceptor()), + ) + if err != nil { + return nil, fmt.Errorf("create orchestrator grpc client: %w", err) + } + + client := &Client{ + target: target, + conn: conn, + rpc: orchestratorv1.NewOrchestratorServiceClient(conn), + } + + lc.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + return conn.Close() + }, + }) + + return client, nil +} + +func (c *Client) Enabled() bool { + return c != nil && c.rpc != nil +} + +func (c *Client) SubmitExecution(ctx context.Context, req *execution.SubmitExecutionReq, groupID string, userID int) (*execution.SubmitExecutionResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode submit execution request: %w", err) + } + + resp, err := c.rpc.SubmitExecution(ctx, &orchestratorv1.SubmitExecutionRequest{ + GroupId: groupID, + UserId: int64(userID), + Body: body, + }) + if err != nil { + return nil, mapRPCError(err) + } + + items := make([]execution.SubmitExecutionItem, 0, len(resp.GetItems())) + for _, item := range resp.GetItems() { + mapped := execution.SubmitExecutionItem{ + Index: int(item.GetIndex()), + TraceID: item.GetTraceId(), + TaskID: item.GetTaskId(), + AlgorithmID: int(item.GetAlgorithmId()), + AlgorithmVersionID: int(item.GetAlgorithmVersionId()), + } + if item.GetHasDatapackId() { + value := int(item.GetDatapackId()) + mapped.DatapackID = &value + } + if item.GetHasDatasetId() { + value := int(item.GetDatasetId()) + mapped.DatasetID = &value + } + items = append(items, mapped) + } + + return &execution.SubmitExecutionResp{ + GroupID: resp.GetGroupId(), + Items: items, + }, nil +} + +func (c *Client) SubmitFaultInjection(ctx context.Context, req *injection.SubmitInjectionReq, groupID string, userID int, projectID *int) (*injection.SubmitInjectionResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode submit fault injection request: %w", err) + } + + pbReq := &orchestratorv1.SubmitFaultInjectionRequest{ + GroupId: groupID, + UserId: int64(userID), + Body: body, + } + if projectID != nil { + pbReq.ProjectId = int64(*projectID) + } + + resp, err := c.rpc.SubmitFaultInjection(ctx, pbReq) + if err != nil { + return nil, mapRPCError(err) + } + + items := make([]injection.SubmitInjectionItem, 0, len(resp.GetItems())) + for _, item := range resp.GetItems() { + items = append(items, injection.SubmitInjectionItem{ + Index: int(item.GetIndex()), + TraceID: item.GetTraceId(), + TaskID: item.GetTaskId(), + }) + } + + result := &injection.SubmitInjectionResp{ + GroupID: resp.GetGroupId(), + Items: items, + OriginalCount: int(resp.GetOriginalCount()), + } + if warnings := resp.GetWarnings(); warnings != nil { + result.Warnings = &injection.InjectionWarnings{ + DuplicateServicesInBatch: warnings.GetDuplicateServicesInBatch(), + DuplicateBatchesInRequest: int64sToInts(warnings.GetDuplicateBatchesInRequest()), + BatchesExistInDatabase: int64sToInts(warnings.GetBatchesExistInDatabase()), + } + } + return result, nil +} + +func (c *Client) SubmitDatapackBuilding(ctx context.Context, req *injection.SubmitDatapackBuildingReq, groupID string, userID int, projectID *int) (*injection.SubmitDatapackBuildingResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode submit datapack building request: %w", err) + } + + pbReq := &orchestratorv1.SubmitDatapackBuildingRequest{ + GroupId: groupID, + UserId: int64(userID), + Body: body, + } + if projectID != nil { + pbReq.ProjectId = int64(*projectID) + } + + resp, err := c.rpc.SubmitDatapackBuilding(ctx, pbReq) + if err != nil { + return nil, mapRPCError(err) + } + + items := make([]injection.SubmitBuildingItem, 0, len(resp.GetItems())) + for _, item := range resp.GetItems() { + items = append(items, injection.SubmitBuildingItem{ + Index: int(item.GetIndex()), + TraceID: item.GetTraceId(), + TaskID: item.GetTaskId(), + }) + } + + return &injection.SubmitDatapackBuildingResp{ + GroupID: resp.GetGroupId(), + Items: items, + }, nil +} + +func (c *Client) CreateExecution(ctx context.Context, req *execution.RuntimeCreateExecutionReq) (int, error) { + if !c.Enabled() { + return 0, fmt.Errorf("orchestrator grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return 0, fmt.Errorf("encode create execution request: %w", err) + } + resp, err := c.rpc.CreateExecution(ctx, &orchestratorv1.MutationRequest{Body: body}) + if err != nil { + return 0, mapRPCError(err) + } + data := resp.GetData().AsMap() + executionID, ok := data["execution_id"].(float64) + if !ok { + return 0, fmt.Errorf("orchestrator payload missing execution_id") + } + return int(executionID), nil +} + +func (c *Client) CreateInjection(ctx context.Context, req *injection.RuntimeCreateInjectionReq) (*dto.InjectionItem, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode create injection request: %w", err) + } + resp, err := c.rpc.CreateInjection(ctx, &orchestratorv1.MutationRequest{Body: body}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.InjectionItem](resp.GetData()) +} + +func (c *Client) UpdateExecutionState(ctx context.Context, req *execution.RuntimeUpdateExecutionStateReq) error { + if !c.Enabled() { + return fmt.Errorf("orchestrator grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return fmt.Errorf("encode update execution state request: %w", err) + } + _, err = c.rpc.UpdateExecutionState(ctx, &orchestratorv1.MutationRequest{Body: body}) + if err != nil { + return mapRPCError(err) + } + return nil +} + +func (c *Client) UpdateInjectionState(ctx context.Context, req *injection.RuntimeUpdateInjectionStateReq) error { + if !c.Enabled() { + return fmt.Errorf("orchestrator grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return fmt.Errorf("encode update injection state request: %w", err) + } + _, err = c.rpc.UpdateInjectionState(ctx, &orchestratorv1.MutationRequest{Body: body}) + if err != nil { + return mapRPCError(err) + } + return nil +} + +func (c *Client) UpdateInjectionTimestamps(ctx context.Context, req *injection.RuntimeUpdateInjectionTimestampReq) (*dto.InjectionItem, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode update injection timestamps request: %w", err) + } + resp, err := c.rpc.UpdateInjectionTimestamps(ctx, &orchestratorv1.MutationRequest{Body: body}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.InjectionItem](resp.GetData()) +} + +func (c *Client) GetExecution(ctx context.Context, executionID int) (*execution.ExecutionDetailResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + resp, err := c.rpc.GetExecution(ctx, &orchestratorv1.GetExecutionRequest{ExecutionId: int64(executionID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[execution.ExecutionDetailResp](resp.GetData()) +} + +func (c *Client) GetInjectionMetrics(ctx context.Context, req *metric.GetMetricsReq) (*metric.InjectionMetrics, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode injection metrics request: %w", err) + } + resp, err := c.rpc.GetInjectionMetrics(ctx, &orchestratorv1.MutationRequest{Body: body}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[metric.InjectionMetrics](resp.GetData()) +} + +func (c *Client) GetExecutionMetrics(ctx context.Context, req *metric.GetMetricsReq) (*metric.ExecutionMetrics, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode execution metrics request: %w", err) + } + resp, err := c.rpc.GetExecutionMetrics(ctx, &orchestratorv1.MutationRequest{Body: body}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[metric.ExecutionMetrics](resp.GetData()) +} + +func (c *Client) ListProjectStatistics(ctx context.Context, projectIDs []int) (map[int]*dto.ProjectStatistics, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + resp, err := c.rpc.ListProjectStatistics(ctx, &orchestratorv1.ListProjectStatisticsRequest{ + ProjectIds: intsToInt64s(projectIDs), + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeProjectStatisticsMap(resp.GetData()) +} + +func (c *Client) ListEvaluationExecutionsByDatapack(ctx context.Context, req *execution.EvaluationExecutionsByDatapackReq) ([]execution.EvaluationExecutionItem, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode evaluation datapack query: %w", err) + } + resp, err := c.rpc.ListEvaluationExecutionsByDatapack(ctx, &orchestratorv1.MutationRequest{Body: body}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStructItems[execution.EvaluationExecutionItem](resp.GetData()) +} + +func (c *Client) ListEvaluationExecutionsByDataset(ctx context.Context, req *execution.EvaluationExecutionsByDatasetReq) ([]execution.EvaluationExecutionItem, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode evaluation dataset query: %w", err) + } + resp, err := c.rpc.ListEvaluationExecutionsByDataset(ctx, &orchestratorv1.MutationRequest{Body: body}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStructItems[execution.EvaluationExecutionItem](resp.GetData()) +} + +func (c *Client) GetTask(ctx context.Context, taskID string) (*task.TaskDetailResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + resp, err := c.rpc.GetTask(ctx, &orchestratorv1.GetTaskRequest{TaskId: taskID}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[task.TaskDetailResp](resp.GetData()) +} + +func (c *Client) PollTaskLogs(ctx context.Context, taskID string, after time.Time) (*task.TaskLogPollResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + req := &orchestratorv1.PollTaskLogsRequest{TaskId: taskID} + if !after.IsZero() { + req.AfterUnixNano = after.UnixNano() + } + resp, err := c.rpc.PollTaskLogs(ctx, req) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[task.TaskLogPollResp](resp.GetData()) +} + +func (c *Client) ListTasks(ctx context.Context, req *task.ListTaskReq) (*dto.ListResp[task.TaskResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode task list request: %w", err) + } + resp, err := c.rpc.ListTasks(ctx, &orchestratorv1.ListTasksRequest{Query: query}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[task.TaskResp]](resp.GetData()) +} + +func (c *Client) GetTrace(ctx context.Context, traceID string) (*trace.TraceDetailResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + resp, err := c.rpc.GetTrace(ctx, &orchestratorv1.GetTraceRequest{TraceId: traceID}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[trace.TraceDetailResp](resp.GetData()) +} + +func (c *Client) ListTraces(ctx context.Context, req *trace.ListTraceReq) (*dto.ListResp[trace.TraceResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode trace list request: %w", err) + } + resp, err := c.rpc.ListTraces(ctx, &orchestratorv1.ListTracesRequest{Query: query}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[trace.TraceResp]](resp.GetData()) +} + +func (c *Client) GetGroupStats(ctx context.Context, groupID string) (*group.GroupStats, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + resp, err := c.rpc.GetGroupStats(ctx, &orchestratorv1.GetGroupStatsRequest{GroupId: groupID}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[group.GroupStats](resp.GetData()) +} + +func (c *Client) GetTraceStreamAlgorithms(ctx context.Context, traceID string) ([]dto.ContainerVersionItem, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + resp, err := c.rpc.GetTraceStreamState(ctx, &orchestratorv1.GetTraceStreamStateRequest{TraceId: traceID}) + if err != nil { + return nil, mapRPCError(err) + } + state, err := decodeStruct[traceStreamStateResp](resp.GetData()) + if err != nil { + return nil, err + } + return state.Algorithms, nil +} + +func (c *Client) ReadTraceStreamMessages(ctx context.Context, streamKey, lastID string, count int64, block time.Duration) ([]redis.XStream, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + resp, err := c.rpc.ReadTraceStreamMessages(ctx, &orchestratorv1.ReadStreamMessagesRequest{ + StreamKey: streamKey, + LastId: lastID, + Count: count, + BlockMillis: block.Milliseconds(), + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStreamMessages(resp.GetData(), streamKey) +} + +func (c *Client) GetGroupTraceCount(ctx context.Context, groupID string) (int, error) { + if !c.Enabled() { + return 0, fmt.Errorf("orchestrator grpc client is not configured") + } + resp, err := c.rpc.GetGroupStreamState(ctx, &orchestratorv1.GetGroupStreamStateRequest{GroupId: groupID}) + if err != nil { + return 0, mapRPCError(err) + } + state, err := decodeStruct[groupStreamStateResp](resp.GetData()) + if err != nil { + return 0, err + } + return state.TotalTraces, nil +} + +func (c *Client) ReadGroupStreamMessages(ctx context.Context, streamKey, lastID string, count int64, block time.Duration) ([]redis.XStream, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + resp, err := c.rpc.ReadGroupStreamMessages(ctx, &orchestratorv1.ReadStreamMessagesRequest{ + StreamKey: streamKey, + LastId: lastID, + Count: count, + BlockMillis: block.Milliseconds(), + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStreamMessages(resp.GetData(), streamKey) +} + +func (c *Client) ReadNotificationStreamMessages(ctx context.Context, lastID string, count int64, block time.Duration) ([]redis.XStream, error) { + if !c.Enabled() { + return nil, fmt.Errorf("orchestrator grpc client is not configured") + } + resp, err := c.rpc.ReadNotificationStreamMessages(ctx, &orchestratorv1.ReadStreamMessagesRequest{ + StreamKey: consts.NotificationStreamKey, + LastId: lastID, + Count: count, + BlockMillis: block.Milliseconds(), + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStreamMessages(resp.GetData(), consts.NotificationStreamKey) +} + +type traceStreamStateResp struct { + Algorithms []dto.ContainerVersionItem `json:"algorithms"` +} + +type groupStreamStateResp struct { + TotalTraces int `json:"total_traces"` +} + +type streamBatchResp struct { + Messages []streamMessageResp `json:"messages"` +} + +type streamMessageResp struct { + ID string `json:"id"` + Values map[string]any `json:"values"` +} + +func toStructPB(value any) (*structpb.Struct, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + + payload := map[string]any{} + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + + return structpb.NewStruct(payload) +} + +func decodeStruct[T any](payload *structpb.Struct) (*T, error) { + if payload == nil { + return nil, fmt.Errorf("orchestrator payload is nil") + } + data, err := json.Marshal(payload.AsMap()) + if err != nil { + return nil, err + } + var result T + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +func decodeStructItems[T any](payload *structpb.Struct) ([]T, error) { + type listEnvelope[T any] struct { + Items []T `json:"items"` + } + + result, err := decodeStruct[listEnvelope[T]](payload) + if err != nil { + return nil, err + } + return result.Items, nil +} + +func decodeStreamMessages(payload *structpb.Struct, streamKey string) ([]redis.XStream, error) { + result, err := decodeStruct[streamBatchResp](payload) + if err != nil { + return nil, err + } + messages := make([]redis.XMessage, 0, len(result.Messages)) + for _, item := range result.Messages { + messages = append(messages, redis.XMessage{ + ID: item.ID, + Values: item.Values, + }) + } + return []redis.XStream{{ + Stream: streamKey, + Messages: messages, + }}, nil +} + +func decodeProjectStatisticsMap(payload *structpb.Struct) (map[int]*dto.ProjectStatistics, error) { + if payload == nil { + return map[int]*dto.ProjectStatistics{}, nil + } + data, err := json.Marshal(payload.AsMap()) + if err != nil { + return nil, err + } + raw := map[string]dto.ProjectStatistics{} + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + result := make(map[int]*dto.ProjectStatistics, len(raw)) + for key, value := range raw { + var projectID int + if _, err := fmt.Sscanf(key, "%d", &projectID); err != nil { + return nil, fmt.Errorf("invalid project statistics key %q: %w", key, err) + } + stats := value + result[projectID] = &stats + } + return result, nil +} + +func intsToInt64s(items []int) []int64 { + if len(items) == 0 { + return nil + } + result := make([]int64, 0, len(items)) + for _, item := range items { + result = append(result, int64(item)) + } + return result +} + +func int64sToInts(items []int64) []int { + if len(items) == 0 { + return nil + } + result := make([]int, 0, len(items)) + for _, item := range items { + result = append(result, int(item)) + } + return result +} + +func mapRPCError(err error) error { + st, ok := status.FromError(err) + if !ok { + return err + } + + switch st.Code() { + case codes.Unauthenticated: + return fmt.Errorf("%w: %s", consts.ErrAuthenticationFailed, st.Message()) + case codes.PermissionDenied: + return fmt.Errorf("%w: %s", consts.ErrPermissionDenied, st.Message()) + case codes.InvalidArgument: + return fmt.Errorf("%w: %s", consts.ErrBadRequest, st.Message()) + case codes.NotFound: + return fmt.Errorf("%w: %s", consts.ErrNotFound, st.Message()) + case codes.AlreadyExists: + return fmt.Errorf("%w: %s", consts.ErrAlreadyExists, st.Message()) + default: + return fmt.Errorf("orchestrator rpc failed: %w", err) + } +} diff --git a/src/internalclient/orchestratorclient/module.go b/src/internalclient/orchestratorclient/module.go new file mode 100644 index 00000000..f244f323 --- /dev/null +++ b/src/internalclient/orchestratorclient/module.go @@ -0,0 +1,7 @@ +package orchestratorclient + +import "go.uber.org/fx" + +var Module = fx.Module("orchestrator_client", + fx.Provide(NewClient), +) diff --git a/src/internalclient/resourceclient/client.go b/src/internalclient/resourceclient/client.go new file mode 100644 index 00000000..2e162d1e --- /dev/null +++ b/src/internalclient/resourceclient/client.go @@ -0,0 +1,472 @@ +package resourceclient + +import ( + "context" + "encoding/json" + "fmt" + + "aegis/config" + "aegis/consts" + "aegis/dto" + "aegis/httpx" + chaossystem "aegis/module/chaossystem" + container "aegis/module/container" + dataset "aegis/module/dataset" + evaluation "aegis/module/evaluation" + label "aegis/module/label" + project "aegis/module/project" + resourcev1 "aegis/proto/resource/v1" + + "go.uber.org/fx" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +type Client struct { + target string + conn *grpc.ClientConn + rpc resourcev1.ResourceServiceClient +} + +func NewClient(lc fx.Lifecycle) (*Client, error) { + target := config.GetString("clients.resource.target") + if target == "" { + target = config.GetString("resource.grpc.target") + } + if target == "" { + return &Client{}, nil + } + + conn, err := grpc.NewClient( + target, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(httpx.UnaryClientRequestIDInterceptor()), + ) + if err != nil { + return nil, fmt.Errorf("create resource grpc client: %w", err) + } + + client := &Client{ + target: target, + conn: conn, + rpc: resourcev1.NewResourceServiceClient(conn), + } + + lc.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + return conn.Close() + }, + }) + + return client, nil +} + +func (c *Client) Enabled() bool { + return c != nil && c.rpc != nil +} + +func (c *Client) ListProjects(ctx context.Context, req *project.ListProjectReq) (*dto.ListResp[project.ProjectResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode project list request: %w", err) + } + resp, err := c.rpc.ListProjects(ctx, &resourcev1.ListProjectsRequest{Query: query}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[project.ProjectResp]](resp.GetData()) +} + +func (c *Client) GetProject(ctx context.Context, projectID int) (*project.ProjectDetailResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + resp, err := c.rpc.GetProject(ctx, &resourcev1.GetResourceRequest{Id: int64(projectID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[project.ProjectDetailResp](resp.GetData()) +} + +func (c *Client) ListContainers(ctx context.Context, req *container.ListContainerReq) (*dto.ListResp[container.ContainerResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode container list request: %w", err) + } + resp, err := c.rpc.ListContainers(ctx, &resourcev1.ListContainersRequest{Query: query}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[container.ContainerResp]](resp.GetData()) +} + +func (c *Client) GetContainer(ctx context.Context, containerID int) (*container.ContainerDetailResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + resp, err := c.rpc.GetContainer(ctx, &resourcev1.GetResourceRequest{Id: int64(containerID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[container.ContainerDetailResp](resp.GetData()) +} + +func (c *Client) ListDatasets(ctx context.Context, req *dataset.ListDatasetReq) (*dto.ListResp[dataset.DatasetResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode dataset list request: %w", err) + } + resp, err := c.rpc.ListDatasets(ctx, &resourcev1.ListDatasetsRequest{Query: query}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[dataset.DatasetResp]](resp.GetData()) +} + +func (c *Client) GetDataset(ctx context.Context, datasetID int) (*dataset.DatasetDetailResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + resp, err := c.rpc.GetDataset(ctx, &resourcev1.GetResourceRequest{Id: int64(datasetID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dataset.DatasetDetailResp](resp.GetData()) +} + +func (c *Client) CreateLabel(ctx context.Context, req *label.CreateLabelReq) (*label.LabelResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode label create request: %w", err) + } + resp, err := c.rpc.CreateLabel(ctx, &resourcev1.MutationRequest{Body: body}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[label.LabelResp](resp.GetData()) +} + +func (c *Client) GetLabel(ctx context.Context, labelID int) (*label.LabelDetailResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + resp, err := c.rpc.GetLabel(ctx, &resourcev1.GetResourceRequest{Id: int64(labelID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[label.LabelDetailResp](resp.GetData()) +} + +func (c *Client) ListLabels(ctx context.Context, req *label.ListLabelReq) (*dto.ListResp[label.LabelResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode label list request: %w", err) + } + resp, err := c.rpc.ListLabels(ctx, &resourcev1.QueryRequest{Query: query}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[label.LabelResp]](resp.GetData()) +} + +func (c *Client) UpdateLabel(ctx context.Context, req *label.UpdateLabelReq, labelID int) (*label.LabelResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode label update request: %w", err) + } + resp, err := c.rpc.UpdateLabel(ctx, &resourcev1.UpdateByIDRequest{ + Id: int64(labelID), + Body: body, + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[label.LabelResp](resp.GetData()) +} + +func (c *Client) DeleteLabel(ctx context.Context, labelID int) error { + if !c.Enabled() { + return fmt.Errorf("resource grpc client is not configured") + } + _, err := c.rpc.DeleteLabel(ctx, &resourcev1.GetResourceRequest{Id: int64(labelID)}) + if err != nil { + return mapRPCError(err) + } + return nil +} + +func (c *Client) BatchDeleteLabels(ctx context.Context, ids []int) error { + if !c.Enabled() { + return fmt.Errorf("resource grpc client is not configured") + } + _, err := c.rpc.BatchDeleteLabels(ctx, &resourcev1.BatchDeleteRequest{Ids: intsToInt64s(ids)}) + if err != nil { + return mapRPCError(err) + } + return nil +} + +func (c *Client) ListChaosSystems(ctx context.Context, req *chaossystem.ListChaosSystemReq) (*dto.ListResp[chaossystem.ChaosSystemResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode chaos system list request: %w", err) + } + resp, err := c.rpc.ListChaosSystems(ctx, &resourcev1.QueryRequest{Query: query}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[chaossystem.ChaosSystemResp]](resp.GetData()) +} + +func (c *Client) GetChaosSystem(ctx context.Context, systemID int) (*chaossystem.ChaosSystemResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + resp, err := c.rpc.GetChaosSystem(ctx, &resourcev1.GetResourceRequest{Id: int64(systemID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[chaossystem.ChaosSystemResp](resp.GetData()) +} + +func (c *Client) CreateChaosSystem(ctx context.Context, req *chaossystem.CreateChaosSystemReq) (*chaossystem.ChaosSystemResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode chaos system create request: %w", err) + } + resp, err := c.rpc.CreateChaosSystem(ctx, &resourcev1.MutationRequest{Body: body}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[chaossystem.ChaosSystemResp](resp.GetData()) +} + +func (c *Client) UpdateChaosSystem(ctx context.Context, req *chaossystem.UpdateChaosSystemReq, systemID int) (*chaossystem.ChaosSystemResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode chaos system update request: %w", err) + } + resp, err := c.rpc.UpdateChaosSystem(ctx, &resourcev1.UpdateByIDRequest{ + Id: int64(systemID), + Body: body, + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[chaossystem.ChaosSystemResp](resp.GetData()) +} + +func (c *Client) DeleteChaosSystem(ctx context.Context, systemID int) error { + if !c.Enabled() { + return fmt.Errorf("resource grpc client is not configured") + } + _, err := c.rpc.DeleteChaosSystem(ctx, &resourcev1.GetResourceRequest{Id: int64(systemID)}) + if err != nil { + return mapRPCError(err) + } + return nil +} + +func (c *Client) UpsertChaosSystemMetadata(ctx context.Context, systemID int, req *chaossystem.BulkUpsertSystemMetadataReq) error { + if !c.Enabled() { + return fmt.Errorf("resource grpc client is not configured") + } + body, err := toStructPB(req) + if err != nil { + return fmt.Errorf("encode chaos system metadata request: %w", err) + } + _, err = c.rpc.UpsertChaosSystemMetadata(ctx, &resourcev1.UpdateByIDRequest{ + Id: int64(systemID), + Body: body, + }) + if err != nil { + return mapRPCError(err) + } + return nil +} + +func (c *Client) ListChaosSystemMetadata(ctx context.Context, systemID int, metadataType string) ([]chaossystem.SystemMetadataResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + query, err := toStructPB(map[string]any{"type": metadataType}) + if err != nil { + return nil, fmt.Errorf("encode chaos system metadata query: %w", err) + } + resp, err := c.rpc.ListChaosSystemMetadata(ctx, &resourcev1.IDQueryRequest{ + Id: int64(systemID), + Query: query, + }) + if err != nil { + return nil, mapRPCError(err) + } + items, err := decodeStruct[struct { + Items []chaossystem.SystemMetadataResp `json:"items"` + }](resp.GetData()) + if err != nil { + return nil, err + } + return items.Items, nil +} + +func (c *Client) ListDatapackEvaluationResults(ctx context.Context, req *evaluation.BatchEvaluateDatapackReq, userID int) (*evaluation.BatchEvaluateDatapackResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode datapack evaluation request: %w", err) + } + resp, err := c.rpc.ListDatapackEvaluationResults(ctx, &resourcev1.ListDatapackEvaluationsRequest{ + Query: query, + UserId: int64(userID), + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[evaluation.BatchEvaluateDatapackResp](resp.GetData()) +} + +func (c *Client) ListDatasetEvaluationResults(ctx context.Context, req *evaluation.BatchEvaluateDatasetReq, userID int) (*evaluation.BatchEvaluateDatasetResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode dataset evaluation request: %w", err) + } + resp, err := c.rpc.ListDatasetEvaluationResults(ctx, &resourcev1.ListDatasetEvaluationsRequest{ + Query: query, + UserId: int64(userID), + }) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[evaluation.BatchEvaluateDatasetResp](resp.GetData()) +} + +func (c *Client) ListEvaluations(ctx context.Context, req *evaluation.ListEvaluationReq) (*dto.ListResp[evaluation.EvaluationResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode evaluation list request: %w", err) + } + resp, err := c.rpc.ListEvaluations(ctx, &resourcev1.ListEvaluationsRequest{Query: query}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[evaluation.EvaluationResp]](resp.GetData()) +} + +func (c *Client) GetEvaluation(ctx context.Context, evaluationID int) (*evaluation.EvaluationResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("resource grpc client is not configured") + } + resp, err := c.rpc.GetEvaluation(ctx, &resourcev1.GetResourceRequest{Id: int64(evaluationID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[evaluation.EvaluationResp](resp.GetData()) +} + +func (c *Client) DeleteEvaluation(ctx context.Context, evaluationID int) error { + if !c.Enabled() { + return fmt.Errorf("resource grpc client is not configured") + } + _, err := c.rpc.DeleteEvaluation(ctx, &resourcev1.GetResourceRequest{Id: int64(evaluationID)}) + if err != nil { + return mapRPCError(err) + } + return nil +} + +func toStructPB(value any) (*structpb.Struct, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + payload := map[string]any{} + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + return structpb.NewStruct(payload) +} + +func decodeStruct[T any](payload *structpb.Struct) (*T, error) { + if payload == nil { + return nil, fmt.Errorf("resource payload is nil") + } + data, err := json.Marshal(payload.AsMap()) + if err != nil { + return nil, err + } + var result T + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +func intsToInt64s(items []int) []int64 { + if len(items) == 0 { + return nil + } + result := make([]int64, 0, len(items)) + for _, item := range items { + result = append(result, int64(item)) + } + return result +} + +func mapRPCError(err error) error { + st, ok := status.FromError(err) + if !ok { + return err + } + switch st.Code() { + case codes.Unauthenticated: + return fmt.Errorf("%w: %s", consts.ErrAuthenticationFailed, st.Message()) + case codes.PermissionDenied: + return fmt.Errorf("%w: %s", consts.ErrPermissionDenied, st.Message()) + case codes.InvalidArgument: + return fmt.Errorf("%w: %s", consts.ErrBadRequest, st.Message()) + case codes.NotFound: + return fmt.Errorf("%w: %s", consts.ErrNotFound, st.Message()) + case codes.AlreadyExists: + return fmt.Errorf("%w: %s", consts.ErrAlreadyExists, st.Message()) + default: + return fmt.Errorf("resource rpc failed: %w", err) + } +} diff --git a/src/internalclient/resourceclient/module.go b/src/internalclient/resourceclient/module.go new file mode 100644 index 00000000..3478126b --- /dev/null +++ b/src/internalclient/resourceclient/module.go @@ -0,0 +1,7 @@ +package resourceclient + +import "go.uber.org/fx" + +var Module = fx.Module("resource_client", + fx.Provide(NewClient), +) diff --git a/src/internalclient/runtimeclient/client.go b/src/internalclient/runtimeclient/client.go new file mode 100644 index 00000000..31031cd8 --- /dev/null +++ b/src/internalclient/runtimeclient/client.go @@ -0,0 +1,122 @@ +package runtimeclient + +import ( + "context" + "encoding/json" + "fmt" + + "aegis/config" + "aegis/consts" + "aegis/httpx" + systemmetric "aegis/module/systemmetric" + task "aegis/module/task" + runtimev1 "aegis/proto/runtime/v1" + + "go.uber.org/fx" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +type Client struct { + target string + conn *grpc.ClientConn + rpc runtimev1.RuntimeServiceClient +} + +func NewClient(lc fx.Lifecycle) (*Client, error) { + target := config.GetString("clients.runtime.target") + if target == "" { + target = config.GetString("runtime_worker.grpc.target") + } + if target == "" { + return &Client{}, nil + } + + conn, err := grpc.NewClient( + target, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(httpx.UnaryClientRequestIDInterceptor()), + ) + if err != nil { + return nil, fmt.Errorf("create runtime grpc client: %w", err) + } + + client := &Client{ + target: target, + conn: conn, + rpc: runtimev1.NewRuntimeServiceClient(conn), + } + + lc.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + return conn.Close() + }, + }) + + return client, nil +} + +func (c *Client) Enabled() bool { + return c != nil && c.rpc != nil +} + +func (c *Client) GetNamespaceLocks(ctx context.Context) (*systemmetric.ListNamespaceLockResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("runtime grpc client is not configured") + } + resp, err := c.rpc.GetNamespaceLocks(ctx, &runtimev1.PingRequest{}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[systemmetric.ListNamespaceLockResp](resp.GetData()) +} + +func (c *Client) GetQueuedTasks(ctx context.Context) (*task.QueuedTasksResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("runtime grpc client is not configured") + } + resp, err := c.rpc.GetQueuedTasks(ctx, &runtimev1.PingRequest{}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[task.QueuedTasksResp](resp.GetData()) +} + +func decodeStruct[T any](payload *structpb.Struct) (*T, error) { + if payload == nil { + return nil, fmt.Errorf("runtime payload is nil") + } + data, err := json.Marshal(payload.AsMap()) + if err != nil { + return nil, err + } + var result T + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +func mapRPCError(err error) error { + st, ok := status.FromError(err) + if !ok { + return err + } + switch st.Code() { + case codes.Unauthenticated: + return fmt.Errorf("%w: %s", consts.ErrAuthenticationFailed, st.Message()) + case codes.PermissionDenied: + return fmt.Errorf("%w: %s", consts.ErrPermissionDenied, st.Message()) + case codes.InvalidArgument: + return fmt.Errorf("%w: %s", consts.ErrBadRequest, st.Message()) + case codes.NotFound: + return fmt.Errorf("%w: %s", consts.ErrNotFound, st.Message()) + case codes.AlreadyExists: + return fmt.Errorf("%w: %s", consts.ErrAlreadyExists, st.Message()) + default: + return fmt.Errorf("runtime rpc failed: %w", err) + } +} diff --git a/src/internalclient/runtimeclient/module.go b/src/internalclient/runtimeclient/module.go new file mode 100644 index 00000000..7bdd2b21 --- /dev/null +++ b/src/internalclient/runtimeclient/module.go @@ -0,0 +1,7 @@ +package runtimeclient + +import "go.uber.org/fx" + +var Module = fx.Module("runtime_client", + fx.Provide(NewClient), +) diff --git a/src/internalclient/systemclient/client.go b/src/internalclient/systemclient/client.go new file mode 100644 index 00000000..bee6e5ae --- /dev/null +++ b/src/internalclient/systemclient/client.go @@ -0,0 +1,242 @@ +package systemclient + +import ( + "context" + "encoding/json" + "fmt" + + "aegis/config" + "aegis/consts" + "aegis/dto" + "aegis/httpx" + system "aegis/module/system" + systemmetric "aegis/module/systemmetric" + systemv1 "aegis/proto/system/v1" + + "go.uber.org/fx" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +type Client struct { + target string + conn *grpc.ClientConn + rpc systemv1.SystemServiceClient +} + +func NewClient(lc fx.Lifecycle) (*Client, error) { + target := config.GetString("clients.system.target") + if target == "" { + target = config.GetString("system.grpc.target") + } + if target == "" { + return &Client{}, nil + } + + conn, err := grpc.NewClient( + target, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithUnaryInterceptor(httpx.UnaryClientRequestIDInterceptor()), + ) + if err != nil { + return nil, fmt.Errorf("create system grpc client: %w", err) + } + + client := &Client{ + target: target, + conn: conn, + rpc: systemv1.NewSystemServiceClient(conn), + } + + lc.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + return conn.Close() + }, + }) + + return client, nil +} + +func (c *Client) Enabled() bool { + return c != nil && c.rpc != nil +} + +func (c *Client) GetHealth(ctx context.Context) (*system.HealthCheckResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("system grpc client is not configured") + } + resp, err := c.rpc.GetHealth(ctx, &systemv1.PingRequest{}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[system.HealthCheckResp](resp.GetData()) +} + +func (c *Client) GetMetrics(ctx context.Context) (*system.MonitoringMetricsResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("system grpc client is not configured") + } + resp, err := c.rpc.GetMetrics(ctx, &systemv1.PingRequest{}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[system.MonitoringMetricsResp](resp.GetData()) +} + +func (c *Client) GetSystemInfo(ctx context.Context) (*system.SystemInfo, error) { + if !c.Enabled() { + return nil, fmt.Errorf("system grpc client is not configured") + } + resp, err := c.rpc.GetSystemInfo(ctx, &systemv1.PingRequest{}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[system.SystemInfo](resp.GetData()) +} + +func (c *Client) ListConfigs(ctx context.Context, req *system.ListConfigReq) (*dto.ListResp[system.ConfigResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("system grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode system config list request: %w", err) + } + resp, err := c.rpc.ListConfigs(ctx, &systemv1.ListConfigsRequest{Query: query}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[system.ConfigResp]](resp.GetData()) +} + +func (c *Client) GetConfig(ctx context.Context, configID int) (*system.ConfigDetailResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("system grpc client is not configured") + } + resp, err := c.rpc.GetConfig(ctx, &systemv1.GetResourceRequest{Id: int64(configID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[system.ConfigDetailResp](resp.GetData()) +} + +func (c *Client) ListAuditLogs(ctx context.Context, req *system.ListAuditLogReq) (*dto.ListResp[system.AuditLogResp], error) { + if !c.Enabled() { + return nil, fmt.Errorf("system grpc client is not configured") + } + query, err := toStructPB(req) + if err != nil { + return nil, fmt.Errorf("encode system audit list request: %w", err) + } + resp, err := c.rpc.ListAuditLogs(ctx, &systemv1.ListAuditLogsRequest{Query: query}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[dto.ListResp[system.AuditLogResp]](resp.GetData()) +} + +func (c *Client) GetAuditLog(ctx context.Context, auditLogID int) (*system.AuditLogDetailResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("system grpc client is not configured") + } + resp, err := c.rpc.GetAuditLog(ctx, &systemv1.GetResourceRequest{Id: int64(auditLogID)}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[system.AuditLogDetailResp](resp.GetData()) +} + +func (c *Client) ListNamespaceLocks(ctx context.Context) (*system.ListNamespaceLockResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("system grpc client is not configured") + } + resp, err := c.rpc.ListNamespaceLocks(ctx, &systemv1.PingRequest{}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[system.ListNamespaceLockResp](resp.GetData()) +} + +func (c *Client) ListQueuedTasks(ctx context.Context) (*system.QueuedTasksResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("system grpc client is not configured") + } + resp, err := c.rpc.ListQueuedTasks(ctx, &systemv1.PingRequest{}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[system.QueuedTasksResp](resp.GetData()) +} + +func (c *Client) GetSystemMetrics(ctx context.Context) (*systemmetric.SystemMetricsResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("system grpc client is not configured") + } + resp, err := c.rpc.GetSystemMetrics(ctx, &systemv1.PingRequest{}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[systemmetric.SystemMetricsResp](resp.GetData()) +} + +func (c *Client) GetSystemMetricsHistory(ctx context.Context) (*systemmetric.SystemMetricsHistoryResp, error) { + if !c.Enabled() { + return nil, fmt.Errorf("system grpc client is not configured") + } + resp, err := c.rpc.GetSystemMetricsHistory(ctx, &systemv1.PingRequest{}) + if err != nil { + return nil, mapRPCError(err) + } + return decodeStruct[systemmetric.SystemMetricsHistoryResp](resp.GetData()) +} + +func toStructPB(value any) (*structpb.Struct, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + payload := map[string]any{} + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + return structpb.NewStruct(payload) +} + +func decodeStruct[T any](payload *structpb.Struct) (*T, error) { + if payload == nil { + return nil, fmt.Errorf("system payload is nil") + } + data, err := json.Marshal(payload.AsMap()) + if err != nil { + return nil, err + } + var result T + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +func mapRPCError(err error) error { + st, ok := status.FromError(err) + if !ok { + return err + } + switch st.Code() { + case codes.Unauthenticated: + return fmt.Errorf("%w: %s", consts.ErrAuthenticationFailed, st.Message()) + case codes.PermissionDenied: + return fmt.Errorf("%w: %s", consts.ErrPermissionDenied, st.Message()) + case codes.InvalidArgument: + return fmt.Errorf("%w: %s", consts.ErrBadRequest, st.Message()) + case codes.NotFound: + return fmt.Errorf("%w: %s", consts.ErrNotFound, st.Message()) + case codes.AlreadyExists: + return fmt.Errorf("%w: %s", consts.ErrAlreadyExists, st.Message()) + default: + return fmt.Errorf("system rpc failed: %w", err) + } +} diff --git a/src/internalclient/systemclient/module.go b/src/internalclient/systemclient/module.go new file mode 100644 index 00000000..ca202fbb --- /dev/null +++ b/src/internalclient/systemclient/module.go @@ -0,0 +1,7 @@ +package systemclient + +import "go.uber.org/fx" + +var Module = fx.Module("system_client", + fx.Provide(NewClient), +) diff --git a/src/main.go b/src/main.go index 51a2b45c..cef7ba4a 100644 --- a/src/main.go +++ b/src/main.go @@ -18,53 +18,30 @@ package main import ( - "context" - "fmt" - "log" "os" - "path" - "runtime" - "time" - "aegis/client" - "aegis/client/k8s" - "aegis/config" - "aegis/consts" - "aegis/database" - "aegis/router" - "aegis/service/consumer" - "aegis/service/initialization" - "aegis/service/logreceiver" - producer "aegis/service/producer" - "aegis/utils" + "aegis/app" + gateway "aegis/app/gateway" + iam "aegis/app/iam" + orchestrator "aegis/app/orchestrator" + resource "aegis/app/resource" + runtimeapp "aegis/app/runtime" + system "aegis/app/system" - chaosCli "github.com/OperationsPAI/chaos-experiment/client" - nested "github.com/antonfisher/nested-logrus-formatter" - "github.com/go-logr/stdr" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" - k8slogger "sigs.k8s.io/controller-runtime/pkg/log" + "go.uber.org/fx" ) -func init() { - logrus.SetReportCaller(true) - logrus.SetFormatter(&nested.Formatter{ - CustomCallerFormatter: func(f *runtime.Frame) string { - filename := path.Base(f.File) - return fmt.Sprintf(" (%s:%d)", filename, f.Line) +func newModeCommand(use, short string, run func()) *cobra.Command { + return &cobra.Command{ + Use: use, + Short: short, + Run: func(cmd *cobra.Command, args []string) { + run() }, - FieldsOrder: []string{"component", "category"}, - HideKeys: true, - TimestampFormat: "2006-01-02 15:04:05", - }) - logrus.SetLevel(logrus.InfoLevel) - logrus.Info("Logger initialized") -} - -func initChaosExperiment() { - k8sConfig := k8s.GetK8sRestConfig() - chaosCli.InitWithConfig(k8sConfig) + } } func main() { @@ -74,7 +51,7 @@ func main() { Use: "rcabench", Short: "RCA Bench is a benchmarking tool", Run: func(cmd *cobra.Command, args []string) { - logrus.Println("Please specify a mode: producer, consumer, or both") + logrus.Println("Please specify a mode: producer, consumer, both, api-gateway, iam-service, orchestrator-service, resource-service, runtime-worker-service, or system-service") }, } @@ -88,136 +65,47 @@ func main() { logrus.Fatalf("failed to bind flag: %v", err) } - config.Init(viper.GetString("conf")) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Producer command - runs HTTP server for API endpoints - var producerCmd = &cobra.Command{ - Use: "producer", - Short: "Run as a producer", - Run: func(cmd *cobra.Command, args []string) { - logrus.Println("Running as producer") - database.InitDB() - initialization.InitializeProducer(ctx) - - utils.InitValidator() - client.InitTraceProvider() - initChaosExperiment() - - engine := router.New() - port := viper.GetString("port") - if err := engine.Run(":" + port); err != nil { - panic(err) - } - }, - } - - // Consumer command - runs background workers and Kubernetes controllers - var consumerCmd = &cobra.Command{ - Use: "consumer", - Short: "Run as a consumer", - Run: func(cmd *cobra.Command, args []string) { - logrus.Println("Running as consumer") - consts.InitialTime = utils.TimePtr(time.Now()) - consts.AppID = utils.GenerateULID(consts.InitialTime) - - k8slogger.SetLogger(stdr.New(log.New(os.Stdout, "", log.LstdFlags))) - initChaosExperiment() - go k8s.GetK8sController().Initialize(ctx, cancel, consumer.NewHandler()) - - database.InitDB() - initialization.InitializeConsumer(ctx) - initialization.InitConcurrencyLock(ctx) - - client.InitTraceProvider() - - // Auto-GC leaked rate-limiter tokens (OperationsPAI/aegis#21). - runRateLimiterStartupGC(ctx) - - // Start OTLP log receiver for real-time job log streaming - go func() { - otlpPort := config.GetInt("otlp_receiver.port") - if otlpPort == 0 { - otlpPort = logreceiver.DefaultPort - } - receiver := logreceiver.NewOTLPLogReceiver(otlpPort, 0) - if err := receiver.Start(ctx); err != nil { - logrus.Errorf("OTLP log receiver error: %v", err) - } - defer receiver.Shutdown() - }() - - go consumer.StartScheduler(ctx) - consumer.ConsumeTasks(ctx) - }, - } - - // Both subcommand - var bothCmd = &cobra.Command{ - Use: "both", - Short: "Run as both producer and consumer", - Run: func(cmd *cobra.Command, args []string) { - logrus.Println("Running as both producer and consumer") - consts.InitialTime = utils.TimePtr(time.Now()) - consts.AppID = utils.GenerateULID(consts.InitialTime) - - k8slogger.SetLogger(stdr.New(log.New(os.Stdout, "", log.LstdFlags))) - initChaosExperiment() - go k8s.GetK8sController().Initialize(ctx, cancel, consumer.NewHandler()) - - database.InitDB() - initialization.InitializeProducer(ctx) - initialization.InitializeConsumer(ctx) - initialization.InitConcurrencyLock(ctx) - - utils.InitValidator() - client.InitTraceProvider() - - // Auto-GC leaked rate-limiter tokens (OperationsPAI/aegis#21). - runRateLimiterStartupGC(ctx) - - // Start OTLP log receiver for real-time job log streaming - go func() { - otlpPort := config.GetInt("otlp_receiver.port") - if otlpPort == 0 { - otlpPort = logreceiver.DefaultPort - } - receiver := logreceiver.NewOTLPLogReceiver(otlpPort, 0) - if err := receiver.Start(ctx); err != nil { - logrus.Errorf("OTLP log receiver error: %v", err) - } - defer receiver.Shutdown() - }() - - go consumer.StartScheduler(ctx) - go consumer.ConsumeTasks(ctx) - - engine := router.New() - port := viper.GetString("port") - if err := engine.Run(":" + port); err != nil { - panic(err) - } - }, - } + producerCmd := newModeCommand("producer", "Run as a producer", func() { + fx.New(app.ProducerOptions(viper.GetString("conf"), viper.GetString("port"))).Run() + }) + consumerCmd := newModeCommand("consumer", "Run as a consumer", func() { + fx.New(app.ConsumerOptions(viper.GetString("conf"))).Run() + }) + bothCmd := newModeCommand("both", "Run as both producer and consumer", func() { + fx.New(app.BothOptions(viper.GetString("conf"), viper.GetString("port"))).Run() + }) + apiGatewayCmd := newModeCommand("api-gateway", "Run as the API gateway", func() { + fx.New(gateway.Options(viper.GetString("conf"), viper.GetString("port"))).Run() + }) + iamServiceCmd := newModeCommand("iam-service", "Run as the IAM service", func() { + fx.New(iam.Options(viper.GetString("conf"))).Run() + }) + orchestratorServiceCmd := newModeCommand("orchestrator-service", "Run as the orchestrator service", func() { + fx.New(orchestrator.Options(viper.GetString("conf"))).Run() + }) + resourceServiceCmd := newModeCommand("resource-service", "Run as the resource service", func() { + fx.New(resource.Options(viper.GetString("conf"))).Run() + }) + runtimeWorkerServiceCmd := newModeCommand("runtime-worker-service", "Run as the runtime worker service", func() { + fx.New(runtimeapp.Options(viper.GetString("conf"))).Run() + }) + systemServiceCmd := newModeCommand("system-service", "Run as the system service", func() { + fx.New(system.Options(viper.GetString("conf"))).Run() + }) - rootCmd.AddCommand(producerCmd, consumerCmd, bothCmd) + rootCmd.AddCommand( + producerCmd, + consumerCmd, + bothCmd, + apiGatewayCmd, + iamServiceCmd, + orchestratorServiceCmd, + resourceServiceCmd, + runtimeWorkerServiceCmd, + systemServiceCmd, + ) if err := rootCmd.Execute(); err != nil { logrus.Println(err.Error()) os.Exit(1) } } - -// runRateLimiterStartupGC releases tokens still held by terminal-state tasks (OperationsPAI/aegis#21). -func runRateLimiterStartupGC(ctx context.Context) { - released, buckets, err := producer.GCRateLimiters(ctx) - if err != nil { - logrus.WithError(err).Warn("rate-limiter startup GC failed") - return - } - logrus.WithFields(logrus.Fields{ - "released": released, - "buckets": buckets, - }).Infof("released %d leaked tokens", released) -} diff --git a/src/middleware/api_key_scope.go b/src/middleware/api_key_scope.go new file mode 100644 index 00000000..8433db07 --- /dev/null +++ b/src/middleware/api_key_scope.go @@ -0,0 +1,97 @@ +package middleware + +import ( + "net/http" + "strings" + + "aegis/dto" + + "github.com/gin-gonic/gin" +) + +func apiKeyScopeMatchesTarget(scope, target string) bool { + scope = strings.TrimSpace(scope) + target = strings.TrimSpace(target) + if scope == "" || target == "" { + return false + } + if scope == "*" { + return true + } + + targetParts := strings.Split(target, ":") + scopeParts := strings.Split(scope, ":") + if len(scopeParts) > len(targetParts) { + return false + } + for len(scopeParts) < len(targetParts) { + scopeParts = append(scopeParts, "*") + } + for i := range targetParts { + part := strings.TrimSpace(scopeParts[i]) + if part == "*" { + continue + } + if part != targetParts[i] { + return false + } + } + return true +} + +func apiKeyScopesAllowAnyTarget(scopes, targets []string) bool { + if len(scopes) == 0 || len(targets) == 0 { + return false + } + for _, scope := range scopes { + for _, target := range targets { + if apiKeyScopeMatchesTarget(scope, target) { + return true + } + } + } + return false +} + +// RequireHumanUserAuth rejects service tokens and API key bearer tokens. +// It is intended for self-service user/account endpoints. +func RequireHumanUserAuth() gin.HandlerFunc { + return func(c *gin.Context) { + if !RequireUserAuth(c) { + c.Abort() + return + } + if GetAuthType(c) == "api_key" { + dto.ErrorResponse(c, http.StatusForbidden, "User session required, API key token not allowed") + c.Abort() + return + } + c.Next() + } +} + +// RequireAPIKeyScopesAny applies explicit scope checks only to API key bearer tokens. +// Human user tokens continue through unchanged. +func RequireAPIKeyScopesAny(targets ...string) gin.HandlerFunc { + trimmed := make([]string, 0, len(targets)) + for _, target := range targets { + target = strings.TrimSpace(target) + if target != "" { + trimmed = append(trimmed, target) + } + } + + return func(c *gin.Context) { + if GetAuthType(c) != "api_key" { + c.Next() + return + } + scopes, ok := GetCurrentAPIKeyScopes(c) + if !ok || !apiKeyScopesAllowAnyTarget(scopes, trimmed) { + dto.ErrorResponse(c, http.StatusForbidden, "API key scope does not allow this endpoint") + c.Abort() + return + } + c.Next() + } +} diff --git a/src/middleware/api_key_scope_test.go b/src/middleware/api_key_scope_test.go new file mode 100644 index 00000000..c0a703d2 --- /dev/null +++ b/src/middleware/api_key_scope_test.go @@ -0,0 +1,153 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestAPIKeyScopeMatchesTarget(t *testing.T) { + tests := []struct { + name string + scope string + target string + want bool + }{ + {name: "global wildcard", scope: "*", target: "sdk:evaluations:read", want: true}, + {name: "sdk wildcard", scope: "sdk:*", target: "sdk:evaluations:read", want: true}, + {name: "sdk evaluations wildcard", scope: "sdk:evaluations:*", target: "sdk:evaluations:read", want: true}, + {name: "exact match", scope: "sdk:datasets:read", target: "sdk:datasets:read", want: true}, + {name: "resource only", scope: "sdk", target: "sdk:datasets:read", want: true}, + {name: "different family", scope: "project:read", target: "sdk:evaluations:read", want: false}, + {name: "different action", scope: "sdk:evaluations:write", target: "sdk:evaluations:read", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := apiKeyScopeMatchesTarget(tt.scope, tt.target); got != tt.want { + t.Fatalf("apiKeyScopeMatchesTarget(%q, %q) = %v, want %v", tt.scope, tt.target, got, tt.want) + } + }) + } +} + +func TestRequireAPIKeyScopesAny(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + setup func(*gin.Context) + wantStatus int + }{ + { + name: "user token bypasses explicit sdk scope gate", + setup: func(c *gin.Context) { + c.Set("user_id", 1) + c.Set("is_active", true) + c.Set("auth_type", "user") + }, + wantStatus: http.StatusNoContent, + }, + { + name: "api key with matching sdk scope passes", + setup: func(c *gin.Context) { + c.Set("user_id", 1) + c.Set("is_active", true) + c.Set("auth_type", "api_key") + c.Set("api_key_scopes", []string{"sdk:evaluations:read"}) + }, + wantStatus: http.StatusNoContent, + }, + { + name: "api key with non matching sdk scope denied", + setup: func(c *gin.Context) { + c.Set("user_id", 1) + c.Set("is_active", true) + c.Set("auth_type", "api_key") + c.Set("api_key_scopes", []string{"sdk:datasets:read"}) + }, + wantStatus: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status := runMiddlewareChain(func(r *gin.Engine) { + r.GET("/", func(c *gin.Context) { + tt.setup(c) + c.Next() + }, RequireAPIKeyScopesAny("sdk:*", "sdk:evaluations:*", "sdk:evaluations:read"), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + }) + if status != tt.wantStatus { + t.Fatalf("status = %d, want %d", status, tt.wantStatus) + } + }) + } +} + +func TestRequireHumanUserAuth(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + setup func(*gin.Context) + wantStatus int + }{ + { + name: "human user token passes", + setup: func(c *gin.Context) { + c.Set("user_id", 1) + c.Set("is_active", true) + c.Set("auth_type", "user") + }, + wantStatus: http.StatusNoContent, + }, + { + name: "api key token denied", + setup: func(c *gin.Context) { + c.Set("user_id", 1) + c.Set("is_active", true) + c.Set("auth_type", "api_key") + }, + wantStatus: http.StatusForbidden, + }, + { + name: "service token denied", + setup: func(c *gin.Context) { + c.Set("is_service_token", true) + c.Set("task_id", "task-1") + }, + wantStatus: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status := runMiddlewareChain(func(r *gin.Engine) { + r.GET("/", func(c *gin.Context) { + tt.setup(c) + c.Next() + }, RequireHumanUserAuth(), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + }) + if status != tt.wantStatus { + t.Fatalf("status = %d, want %d", status, tt.wantStatus) + } + }) + } +} + +func runMiddlewareChain(register func(*gin.Engine)) int { + engine := gin.New() + register(engine) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + return w.Code +} diff --git a/src/middleware/audit.go b/src/middleware/audit.go index 729a6265..750ae509 100644 --- a/src/middleware/audit.go +++ b/src/middleware/audit.go @@ -8,7 +8,6 @@ import ( "time" "aegis/consts" - producer "aegis/service/producer" "aegis/utils" "github.com/gin-gonic/gin" @@ -27,6 +26,7 @@ func AuditMiddleware() gin.HandlerFunc { // Get user information (if authenticated) userID, _ := GetCurrentUserID(c) + logger := auditLoggerFromContext(c) // Read request body (for recording details) var requestBody []byte @@ -84,23 +84,23 @@ func AuditMiddleware() gin.HandlerFunc { } } + ipAddress := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + durationMillis := int(duration.Milliseconds()) + // Async logging (don't block request) go func() { - ipAddress := c.ClientIP() - userAgent := c.GetHeader("User-Agent") - duration := int(duration.Milliseconds()) - //TODO resource instance extraction if errorMsg != "" { - if err := producer.LogFailedAction(ipAddress, userAgent, action, errorMsg, duration, userID, resource); err != nil { + if err := logger.LogFailedAction(ipAddress, userAgent, action, errorMsg, durationMillis, userID, resource); err != nil { logrus.Errorf("Failed to log audit action: %v", err) return } return } - if err := producer.LogUserAction(ipAddress, userAgent, action, string(detailsJSON), duration, userID, resource); err != nil { + if err := logger.LogUserAction(ipAddress, userAgent, action, string(detailsJSON), durationMillis, userID, resource); err != nil { logrus.Errorf("Failed to log audit action: %v", err) return } diff --git a/src/middleware/auth.go b/src/middleware/auth.go index dfab5300..88cbf67c 100644 --- a/src/middleware/auth.go +++ b/src/middleware/auth.go @@ -9,21 +9,27 @@ import ( "github.com/gin-gonic/gin" ) +func extractTokenFromHeader(header string) (string, error) { + return utils.ExtractTokenFromHeader(header) +} + // JWTAuth is the JWT authentication middleware // Supports both user tokens and service tokens (for K8s jobs) func JWTAuth() gin.HandlerFunc { return func(c *gin.Context) { // Extract token from Authorization header authHeader := c.GetHeader("Authorization") - token, err := utils.ExtractTokenFromHeader(authHeader) + token, err := extractTokenFromHeader(authHeader) if err != nil { dto.ErrorResponse(c, http.StatusUnauthorized, "Unauthorized: "+err.Error()) c.Abort() return } + service := serviceFromContext(c) + // Try to validate as user token first - claims, err := utils.ValidateToken(token) + claims, err := service.VerifyToken(c.Request.Context(), token) if err == nil { // Valid user token - store user information in context c.Set("user_id", claims.UserID) @@ -32,6 +38,9 @@ func JWTAuth() gin.HandlerFunc { c.Set("is_active", claims.IsActive) c.Set("is_admin", claims.IsAdmin) c.Set("user_roles", claims.Roles) + c.Set("auth_type", claims.AuthType) + c.Set("api_key_id", claims.APIKeyID) + c.Set("api_key_scopes", append([]string(nil), claims.APIKeyScopes...)) c.Set("token_expires_at", claims.ExpiresAt.Time) c.Set("token_type", "user") c.Next() @@ -39,7 +48,7 @@ func JWTAuth() gin.HandlerFunc { } // Try to validate as service token (for K8s jobs) - serviceClaims, serviceErr := utils.ValidateServiceToken(token) + serviceClaims, serviceErr := service.VerifyServiceToken(c.Request.Context(), token) if serviceErr == nil { // Valid service token - store service information in context c.Set("task_id", serviceClaims.TaskID) @@ -69,15 +78,17 @@ func OptionalJWTAuth() gin.HandlerFunc { return } - token, err := utils.ExtractTokenFromHeader(authHeader) + token, err := extractTokenFromHeader(authHeader) if err != nil { // Invalid header format, continue without auth c.Next() return } + service := serviceFromContext(c) + // Try to validate as user token first - claims, err := utils.ValidateToken(token) + claims, err := service.VerifyToken(c.Request.Context(), token) if err == nil { // Valid user token, set user information c.Set("user_id", claims.UserID) @@ -86,6 +97,9 @@ func OptionalJWTAuth() gin.HandlerFunc { c.Set("is_active", claims.IsActive) c.Set("is_admin", claims.IsAdmin) c.Set("user_roles", claims.Roles) + c.Set("auth_type", claims.AuthType) + c.Set("api_key_id", claims.APIKeyID) + c.Set("api_key_scopes", append([]string(nil), claims.APIKeyScopes...)) c.Set("token_expires_at", claims.ExpiresAt.Time) c.Set("token_type", "user") c.Next() @@ -93,7 +107,7 @@ func OptionalJWTAuth() gin.HandlerFunc { } // Try to validate as service token (for K8s jobs) - serviceClaims, serviceErr := utils.ValidateServiceToken(token) + serviceClaims, serviceErr := service.VerifyServiceToken(c.Request.Context(), token) if serviceErr == nil { // Valid service token, set service information c.Set("task_id", serviceClaims.TaskID) @@ -200,6 +214,32 @@ func GetCurrentUserRoles(c *gin.Context) ([]string, bool) { return userRoles, ok } +// GetCurrentAPIKeyScopes returns API key scopes when the current bearer token +// was issued via Key ID / Key Secret exchange. +func GetCurrentAPIKeyScopes(c *gin.Context) ([]string, bool) { + scopes, exists := c.Get("api_key_scopes") + if !exists { + return nil, false + } + + apiKeyScopes, ok := scopes.([]string) + return apiKeyScopes, ok +} + +// GetAuthType returns the auth_type claim of the current bearer token when present. +func GetAuthType(c *gin.Context) string { + authType, exists := c.Get("auth_type") + if !exists { + return "" + } + + value, ok := authType.(string) + if !ok { + return "" + } + return value +} + // GetServiceTaskID extracts task ID from service token context func GetServiceTaskID(c *gin.Context) (string, bool) { taskID, exists := c.Get("task_id") @@ -244,6 +284,22 @@ func RequireUserAuth(c *gin.Context) bool { return RequireAuth(c) } +// RequireServiceTokenAuth is a helper that ensures the current request uses a service token. +func RequireServiceTokenAuth() gin.HandlerFunc { + return func(c *gin.Context) { + if !RequireAuth(c) { + c.Abort() + return + } + if !IsServiceToken(c) { + dto.ErrorResponse(c, http.StatusForbidden, "Service token required") + c.Abort() + return + } + c.Next() + } +} + // RequireActiveUser ensures the current user exists and is active func RequireActiveUser() gin.HandlerFunc { return func(c *gin.Context) { diff --git a/src/middleware/deps.go b/src/middleware/deps.go new file mode 100644 index 00000000..82d2ae43 --- /dev/null +++ b/src/middleware/deps.go @@ -0,0 +1,411 @@ +package middleware + +import ( + "context" + "errors" + "fmt" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/model" + "aegis/utils" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type TokenVerifier interface { + VerifyToken(ctx context.Context, token string) (*utils.Claims, error) + VerifyServiceToken(ctx context.Context, token string) (*utils.ServiceClaims, error) +} + +type permissionChecker interface { + CheckUserPermission(context.Context, *dto.CheckPermissionParams) (bool, error) + IsUserTeamAdmin(context.Context, int, int) (bool, error) + IsUserInTeam(context.Context, int, int) (bool, error) + IsTeamPublic(context.Context, int) (bool, error) + IsUserProjectAdmin(context.Context, int, int) (bool, error) + IsUserInProject(context.Context, int, int) (bool, error) +} + +type auditLogger interface { + LogFailedAction(ipAddress, userAgent, action, errorMsg string, duration, userID int, resourceName consts.ResourceName) error + LogUserAction(ipAddress, userAgent, action, details string, duration, userID int, resourceName consts.ResourceName) error +} + +type Service interface { + TokenVerifier + permissionChecker + auditLogger +} + +const middlewareServiceContextKey = "middleware.service" + +func NewService(db *gorm.DB, verifier TokenVerifier) Service { + return &dbBackedMiddlewareService{db: db, verifier: verifier} +} + +func InjectService(service Service) gin.HandlerFunc { + if service == nil { + service = noopMiddlewareService{} + } + + return func(c *gin.Context) { + c.Set(middlewareServiceContextKey, service) + c.Next() + } +} + +func permissionCheckerFromContext(c *gin.Context) permissionChecker { return serviceFromContext(c) } + +func auditLoggerFromContext(c *gin.Context) auditLogger { return serviceFromContext(c) } + +func serviceFromContext(c *gin.Context) Service { + if c == nil { + return noopMiddlewareService{} + } + service, ok := c.Get(middlewareServiceContextKey) + if !ok { + return noopMiddlewareService{} + } + middlewareService, ok := service.(Service) + if !ok || middlewareService == nil { + return noopMiddlewareService{} + } + return middlewareService +} + +type dbBackedMiddlewareService struct { + db *gorm.DB + verifier TokenVerifier +} + +func (s *dbBackedMiddlewareService) VerifyToken(ctx context.Context, token string) (*utils.Claims, error) { + if s.verifier == nil { + return nil, fmt.Errorf("token verifier not initialized") + } + return s.verifier.VerifyToken(ctx, token) +} + +func (s *dbBackedMiddlewareService) VerifyServiceToken(ctx context.Context, token string) (*utils.ServiceClaims, error) { + if s.verifier == nil { + return nil, fmt.Errorf("token verifier not initialized") + } + return s.verifier.VerifyServiceToken(ctx, token) +} + +func (s *dbBackedMiddlewareService) CheckUserPermission(_ context.Context, params *dto.CheckPermissionParams) (bool, error) { + if err := params.Validate(); err != nil { + return false, fmt.Errorf("invalid request: %w", err) + } + + permission, err := s.getPermissionByActionAndResource(params.Action, params.Scope, params.ResourceName) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, fmt.Errorf("failed to find target permission: %w", err) + } + + return s.checkUserHasPermission(params, permission.ID) +} + +func (s *dbBackedMiddlewareService) IsUserInTeam(_ context.Context, userID, teamID int) (bool, error) { + ut, err := s.getUserTeamRole(userID, teamID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + return ut != nil, nil +} + +func (s *dbBackedMiddlewareService) IsUserTeamAdmin(_ context.Context, userID, teamID int) (bool, error) { + ut, err := s.getUserTeamRole(userID, teamID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + return ut != nil && ut.Role != nil && ut.Role.Name == consts.RoleTeamAdmin.String(), nil +} + +func (s *dbBackedMiddlewareService) IsTeamPublic(_ context.Context, teamID int) (bool, error) { + team, err := s.getTeamByID(teamID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + return team.IsPublic, nil +} + +func (s *dbBackedMiddlewareService) IsUserInProject(_ context.Context, userID, projectID int) (bool, error) { + up, err := s.getUserProjectRole(userID, projectID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + return up != nil, nil +} + +func (s *dbBackedMiddlewareService) IsUserProjectAdmin(_ context.Context, userID, projectID int) (bool, error) { + up, err := s.getUserProjectRole(userID, projectID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + return up != nil && up.Role != nil && up.Role.Name == consts.RoleProjectAdmin.String(), nil +} + +func (s *dbBackedMiddlewareService) LogFailedAction(ipAddress, userAgent, action, errorMsg string, duration, userID int, resourceName consts.ResourceName) error { + if resourceName == "" { + return fmt.Errorf("resource name cannot be empty") + } + + log := &model.AuditLog{ + IPAddress: ipAddress, + UserAgent: userAgent, + Duration: duration, + Action: action, + ErrorMsg: errorMsg, + UserID: userID, + State: consts.AuditLogStateFailed, + Status: consts.CommonEnabled, + } + + return s.db.Transaction(func(tx *gorm.DB) error { + resource, err := s.getResourceByName(tx, resourceName) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: resource %s not found", consts.ErrNotFound, resourceName) + } + return fmt.Errorf("failed to get resource: %w", err) + } + + log.ResourceID = resource.ID + return s.createAuditLog(tx, log) + }) +} + +func (s *dbBackedMiddlewareService) LogUserAction(ipAddress, userAgent, action, details string, duration, userID int, resourceName consts.ResourceName) error { + if resourceName == "" { + return fmt.Errorf("resource name cannot be empty") + } + + log := &model.AuditLog{ + IPAddress: ipAddress, + UserAgent: userAgent, + Duration: duration, + Action: action, + Details: details, + UserID: userID, + State: consts.AuditLogStateSuccess, + Status: consts.CommonEnabled, + } + + return s.db.Transaction(func(tx *gorm.DB) error { + resource, err := s.getResourceByName(tx, resourceName) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: resource %s not found", consts.ErrNotFound, resourceName) + } + return fmt.Errorf("failed to get resource: %w", err) + } + + log.ResourceID = resource.ID + return s.createAuditLog(tx, log) + }) +} + +func (s *dbBackedMiddlewareService) getPermissionByActionAndResource(action consts.ActionName, scope consts.ResourceScope, resourceName consts.ResourceName) (*model.Permission, error) { + var permission model.Permission + if err := s.db. + Select("permissions.*"). + Joins("JOIN resources ON permissions.resource_id = resources.id"). + Where("permissions.action = ? AND permissions.scope = ? AND resources.name = ?", action, scope, resourceName). + Where("permissions.status != ?", consts.CommonDeleted). + First(&permission).Error; err != nil { + return nil, err + } + return &permission, nil +} + +func (s *dbBackedMiddlewareService) checkUserHasPermission(params *dto.CheckPermissionParams, permissionID int) (bool, error) { + directQuery := s.buildDirectPermissionQuery(params.UserID, permissionID, params.ProjectID, params.ContainerID, params.DatasetID) + globalRoleQuery := s.buildGlobalRolePermissionQuery(params.UserID, permissionID) + finalQuery := s.db.Table("(? UNION ALL ?) as base", directQuery, globalRoleQuery) + + if params.TeamID != nil { + finalQuery = s.db.Table("(? UNION ALL ?) as combined", finalQuery, s.buildTeamRolePermissionQuery(params.UserID, permissionID, *params.TeamID)) + } + if params.ProjectID != nil { + finalQuery = s.db.Table("(? UNION ALL ?) as combined", finalQuery, s.buildProjectRolePermissionQuery(params.UserID, permissionID, *params.ProjectID)) + } + if params.ContainerID != nil { + finalQuery = s.db.Table("(? UNION ALL ?) as combined", finalQuery, s.buildContainerRolePermissionQuery(params.UserID, permissionID, *params.ContainerID)) + } + if params.DatasetID != nil { + finalQuery = s.db.Table("(? UNION ALL ?) as combined", finalQuery, s.buildDatasetRolePermissionQuery(params.UserID, permissionID, *params.DatasetID)) + } + + var count int64 + if err := finalQuery.Limit(1).Count(&count).Error; err != nil { + return false, fmt.Errorf("failed to check user permission: %w", err) + } + return count > 0, nil +} + +func (s *dbBackedMiddlewareService) buildDirectPermissionQuery(userID int, permissionID int, projectID, containerID, datasetID *int) *gorm.DB { + query := s.db. + Select("up.permission_id"). + Table("user_permissions up"). + Where("up.user_id = ? AND up.permission_id = ?", userID, permissionID). + Where("up.grant_type = ?", consts.GrantTypeGrant). + Where("up.expires_at IS NULL OR up.expires_at > ?", time.Now()) + + if projectID != nil { + query = query.Where("up.project_id IS NULL OR up.project_id = ?", *projectID) + } else { + query = query.Where("up.project_id IS NULL") + } + if containerID != nil { + query = query.Where("up.container_id IS NULL OR up.container_id = ?", *containerID) + } else { + query = query.Where("up.container_id IS NULL") + } + if datasetID != nil { + query = query.Where("up.dataset_id IS NULL OR up.dataset_id = ?", *datasetID) + } else { + query = query.Where("up.dataset_id IS NULL") + } + + return query +} + +func (s *dbBackedMiddlewareService) buildGlobalRolePermissionQuery(userID int, permissionID int) *gorm.DB { + return s.db. + Select("rp.permission_id"). + Table("role_permissions rp"). + Joins("JOIN user_roles ur ON rp.role_id = ur.role_id"). + Where("ur.user_id = ? AND rp.permission_id = ?", userID, permissionID) +} + +func (s *dbBackedMiddlewareService) buildTeamRolePermissionQuery(userID int, permissionID int, teamID int) *gorm.DB { + return s.db. + Select("rp.permission_id"). + Table("role_permissions rp"). + Joins("JOIN user_teams ut ON rp.role_id = ut.role_id"). + Where("ut.user_id = ? AND ut.team_id = ? AND rp.permission_id = ?", userID, teamID, permissionID). + Where("ut.status = ?", consts.CommonEnabled) +} + +func (s *dbBackedMiddlewareService) buildProjectRolePermissionQuery(userID int, permissionID int, projectID int) *gorm.DB { + return s.db. + Select("rp.permission_id"). + Table("role_permissions rp"). + Joins("JOIN user_projects upr ON rp.role_id = upr.role_id"). + Where("upr.user_id = ? AND upr.project_id = ? AND rp.permission_id = ?", userID, projectID, permissionID). + Where("upr.status = ?", consts.CommonEnabled) +} + +func (s *dbBackedMiddlewareService) buildContainerRolePermissionQuery(userID int, permissionID int, containerID int) *gorm.DB { + return s.db. + Select("rp.permission_id"). + Table("role_permissions rp"). + Joins("JOIN user_containers uc ON rp.role_id = uc.role_id"). + Where("uc.user_id = ? AND uc.container_id = ? AND rp.permission_id = ?", userID, containerID, permissionID). + Where("uc.status = ?", consts.CommonEnabled) +} + +func (s *dbBackedMiddlewareService) buildDatasetRolePermissionQuery(userID int, permissionID int, datasetID int) *gorm.DB { + return s.db. + Select("rp.permission_id"). + Table("role_permissions rp"). + Joins("JOIN user_datasets ud ON rp.role_id = ud.role_id"). + Where("ud.user_id = ? AND ud.dataset_id = ? AND rp.permission_id = ?", userID, datasetID, permissionID). + Where("ud.status = ?", consts.CommonEnabled) +} + +func (s *dbBackedMiddlewareService) getUserTeamRole(userID, teamID int) (*model.UserTeam, error) { + var userTeam model.UserTeam + if err := s.db.Preload("Role"). + Where("user_id = ? AND team_id = ? AND status = ?", userID, teamID, consts.CommonEnabled). + First(&userTeam).Error; err != nil { + return nil, err + } + return &userTeam, nil +} + +func (s *dbBackedMiddlewareService) getTeamByID(teamID int) (*model.Team, error) { + var team model.Team + if err := s.db.Where("id = ?", teamID).First(&team).Error; err != nil { + return nil, err + } + return &team, nil +} + +func (s *dbBackedMiddlewareService) getUserProjectRole(userID, projectID int) (*model.UserProject, error) { + var userProject model.UserProject + if err := s.db.Preload("Role"). + Where("user_id = ? AND project_id = ? AND status = ?", userID, projectID, consts.CommonEnabled). + First(&userProject).Error; err != nil { + return nil, err + } + return &userProject, nil +} + +func (s *dbBackedMiddlewareService) getResourceByName(db *gorm.DB, resourceName consts.ResourceName) (*model.Resource, error) { + var resource model.Resource + if err := db.Where("name = ? AND status != ?", resourceName, consts.CommonDeleted).First(&resource).Error; err != nil { + return nil, err + } + return &resource, nil +} + +func (s *dbBackedMiddlewareService) createAuditLog(db *gorm.DB, log *model.AuditLog) error { + return db.Create(log).Error +} + +type noopMiddlewareService struct{} + +func (noopMiddlewareService) VerifyToken(context.Context, string) (*utils.Claims, error) { + return nil, fmt.Errorf("token verifier not initialized") +} +func (noopMiddlewareService) VerifyServiceToken(context.Context, string) (*utils.ServiceClaims, error) { + return nil, fmt.Errorf("token verifier not initialized") +} + +func (noopMiddlewareService) CheckUserPermission(context.Context, *dto.CheckPermissionParams) (bool, error) { + return false, fmt.Errorf("permission checker not initialized") +} +func (noopMiddlewareService) IsUserTeamAdmin(context.Context, int, int) (bool, error) { + return false, fmt.Errorf("permission checker not initialized") +} +func (noopMiddlewareService) IsUserInTeam(context.Context, int, int) (bool, error) { + return false, fmt.Errorf("permission checker not initialized") +} +func (noopMiddlewareService) IsTeamPublic(context.Context, int) (bool, error) { + return false, fmt.Errorf("permission checker not initialized") +} +func (noopMiddlewareService) IsUserProjectAdmin(context.Context, int, int) (bool, error) { + return false, fmt.Errorf("permission checker not initialized") +} +func (noopMiddlewareService) IsUserInProject(context.Context, int, int) (bool, error) { + return false, fmt.Errorf("permission checker not initialized") +} + +func (noopMiddlewareService) LogFailedAction(string, string, string, string, int, int, consts.ResourceName) error { + return fmt.Errorf("audit logger not initialized") +} +func (noopMiddlewareService) LogUserAction(string, string, string, string, int, int, consts.ResourceName) error { + return fmt.Errorf("audit logger not initialized") +} diff --git a/src/middleware/middleware.go b/src/middleware/middleware.go index 05253a21..02595339 100644 --- a/src/middleware/middleware.go +++ b/src/middleware/middleware.go @@ -1,6 +1,7 @@ package middleware import ( + "aegis/httpx" "regexp" "github.com/google/uuid" @@ -43,6 +44,19 @@ func GroupID() gin.HandlerFunc { } } +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := c.GetHeader(httpx.RequestIDHeader) + if requestID == "" { + requestID = httpx.NewRequestID() + } + + c.Writer.Header().Set(httpx.RequestIDHeader, requestID) + c.Request = c.Request.WithContext(httpx.WithRequestID(c.Request.Context(), requestID)) + c.Next() + } +} + func TracerMiddleware() gin.HandlerFunc { return func(c *gin.Context) { groupID := c.GetString("groupID") diff --git a/src/middleware/permission.go b/src/middleware/permission.go index 0ec009c0..6fbe3106 100644 --- a/src/middleware/permission.go +++ b/src/middleware/permission.go @@ -1,26 +1,30 @@ package middleware import ( + "context" "fmt" "net/http" "strconv" "aegis/consts" "aegis/dto" - "aegis/service/producer" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" ) type permissionContext struct { - userID int - isAdmin bool - roles []string - teamID *int - projectID *int - containerID *int - datasetID *int + userID int + isAdmin bool + roles []string + authType string + apiKeyScopes []string + checker permissionChecker + ctx context.Context + teamID *int + projectID *int + containerID *int + datasetID *int } // permissionCheckFunc is a function that checks permission given the context @@ -60,9 +64,15 @@ func extractPermissionContext(c *gin.Context) (*permissionContext, string) { } ctx := &permissionContext{ - userID: userID, - isAdmin: isAdmin, - roles: roles, + userID: userID, + isAdmin: isAdmin, + roles: roles, + checker: permissionCheckerFromContext(c), + ctx: c.Request.Context(), + authType: GetAuthType(c), + } + if scopes, ok := GetCurrentAPIKeyScopes(c); ok { + ctx.apiKeyScopes = append([]string(nil), scopes...) } // Extract optional IDs from URL parameters @@ -93,6 +103,38 @@ func extractPermissionContext(c *gin.Context) (*permissionContext, string) { return ctx, "" } +func (ctx *permissionContext) isAPIKeyAuth() bool { + return ctx != nil && ctx.authType == "api_key" +} + +func (ctx *permissionContext) scopeAllowsPermission(permission consts.PermissionRule) bool { + if !ctx.isAPIKeyAuth() { + return true + } + if len(ctx.apiKeyScopes) == 0 { + return false + } + for _, scope := range ctx.apiKeyScopes { + if apiKeyScopeMatchesPermission(scope, permission) { + return true + } + } + return false +} + +func (ctx *permissionContext) scopeAllowsAnyPermission(permissions []consts.PermissionRule) bool { + for _, permission := range permissions { + if ctx.scopeAllowsPermission(permission) { + return true + } + } + return false +} + +func apiKeyScopeMatchesPermission(scope string, permission consts.PermissionRule) bool { + return apiKeyScopeMatchesTarget(scope, permission.String()) +} + // withPermissionCheck creates a middleware decorator that wraps permission check logic // This is similar to Python's decorator pattern func withPermissionCheck(checkFunc permissionCheckFunc) gin.HandlerFunc { @@ -143,7 +185,10 @@ func withPermissionCheck(checkFunc permissionCheckFunc) gin.HandlerFunc { // singlePermission creates a check for a single permission func singlePermission(permission consts.PermissionRule) permissionCheckFunc { return func(ctx *permissionContext) (bool, error) { - return producer.CheckUserPermission(&dto.CheckPermissionParams{ + if !ctx.scopeAllowsPermission(permission) { + return false, nil + } + return ctx.checker.CheckUserPermission(ctx.ctx, &dto.CheckPermissionParams{ UserID: ctx.userID, Action: permission.Action, Scope: permission.Scope, @@ -161,7 +206,11 @@ func singlePermission(permission consts.PermissionRule) permissionCheckFunc { func anyPermission(permissions []consts.PermissionRule) permissionCheckFunc { return func(ctx *permissionContext) (bool, error) { for _, perm := range permissions { - hasPermission, err := producer.CheckUserPermission( + if !ctx.scopeAllowsPermission(perm) { + continue + } + hasPermission, err := ctx.checker.CheckUserPermission( + ctx.ctx, &dto.CheckPermissionParams{ UserID: ctx.userID, Action: perm.Action, @@ -189,7 +238,11 @@ func anyPermission(permissions []consts.PermissionRule) permissionCheckFunc { func allPermissions(permissions []consts.PermissionRule) permissionCheckFunc { return func(ctx *permissionContext) (bool, error) { for _, perm := range permissions { - hasPermission, err := producer.CheckUserPermission( + if !ctx.scopeAllowsPermission(perm) { + return false, nil + } + hasPermission, err := ctx.checker.CheckUserPermission( + ctx.ctx, &dto.CheckPermissionParams{ UserID: ctx.userID, Action: perm.Action, @@ -260,6 +313,14 @@ func teamAccessCheck(requireAdmin bool) permissionCheckFunc { return false, fmt.Errorf("team_id is required") } + requiredScopes := []consts.PermissionRule{consts.PermTeamReadAll, consts.PermTeamManageAll} + if requireAdmin { + requiredScopes = []consts.PermissionRule{consts.PermTeamManageAll} + } + if !ctx.scopeAllowsAnyPermission(requiredScopes) { + return false, nil + } + // Check if system admin (from JWT token, no DB query) if ctx.isAdmin { return true, nil @@ -267,7 +328,7 @@ func teamAccessCheck(requireAdmin bool) permissionCheckFunc { // If admin access required, check team admin status if requireAdmin { - isTeamAdmin, err := producer.IsUserTeamAdmin(ctx.userID, *ctx.teamID) + isTeamAdmin, err := ctx.checker.IsUserTeamAdmin(ctx.ctx, ctx.userID, *ctx.teamID) if err != nil { return false, err } @@ -275,13 +336,13 @@ func teamAccessCheck(requireAdmin bool) permissionCheckFunc { } // For member access: check if member OR team is public - isMember, err := producer.IsUserInTeam(ctx.userID, *ctx.teamID) + isMember, err := ctx.checker.IsUserInTeam(ctx.ctx, ctx.userID, *ctx.teamID) if err == nil && isMember { return true, nil } // Check if team is public - isPublic, err := producer.IsTeamPublic(*ctx.teamID) + isPublic, err := ctx.checker.IsTeamPublic(ctx.ctx, *ctx.teamID) if err == nil && isPublic { return true, nil } @@ -297,6 +358,14 @@ func projectAccessCheck(requireAdmin bool) permissionCheckFunc { return false, fmt.Errorf("project_id is required") } + requiredScopes := []consts.PermissionRule{consts.PermProjectReadAll, consts.PermProjectManageAll} + if requireAdmin { + requiredScopes = []consts.PermissionRule{consts.PermProjectManageAll} + } + if !ctx.scopeAllowsAnyPermission(requiredScopes) { + return false, nil + } + // Check if system admin (from JWT token, no DB query) if ctx.isAdmin { return true, nil @@ -304,7 +373,7 @@ func projectAccessCheck(requireAdmin bool) permissionCheckFunc { // Check project admin status if required if requireAdmin { - isProjectAdmin, err := producer.IsUserProjectAdmin(ctx.userID, *ctx.projectID) + isProjectAdmin, err := ctx.checker.IsUserProjectAdmin(ctx.ctx, ctx.userID, *ctx.projectID) if err != nil { return false, err } @@ -312,7 +381,7 @@ func projectAccessCheck(requireAdmin bool) permissionCheckFunc { } // Check if user is project member - isMember, err := producer.IsUserInProject(ctx.userID, *ctx.projectID) + isMember, err := ctx.checker.IsUserInProject(ctx.ctx, ctx.userID, *ctx.projectID) if err != nil { return false, err } diff --git a/src/middleware/permission_test.go b/src/middleware/permission_test.go new file mode 100644 index 00000000..d4d1c702 --- /dev/null +++ b/src/middleware/permission_test.go @@ -0,0 +1,189 @@ +package middleware + +import ( + "context" + "testing" + + "aegis/consts" + "aegis/dto" + "aegis/utils" +) + +type permissionCheckerStub struct{} + +func (permissionCheckerStub) VerifyToken(context.Context, string) (*utils.Claims, error) { + return nil, nil +} +func (permissionCheckerStub) VerifyServiceToken(context.Context, string) (*utils.ServiceClaims, error) { + return nil, nil +} +func (permissionCheckerStub) CheckUserPermission(context.Context, *dto.CheckPermissionParams) (bool, error) { + return false, nil +} +func (permissionCheckerStub) IsUserTeamAdmin(context.Context, int, int) (bool, error) { + return false, nil +} +func (permissionCheckerStub) IsUserInTeam(context.Context, int, int) (bool, error) { + return false, nil +} +func (permissionCheckerStub) IsTeamPublic(context.Context, int) (bool, error) { + return false, nil +} +func (permissionCheckerStub) IsUserProjectAdmin(context.Context, int, int) (bool, error) { + return false, nil +} +func (permissionCheckerStub) IsUserInProject(context.Context, int, int) (bool, error) { + return false, nil +} +func (permissionCheckerStub) LogFailedAction(string, string, string, string, int, int, consts.ResourceName) error { + return nil +} +func (permissionCheckerStub) LogUserAction(string, string, string, string, int, int, consts.ResourceName) error { + return nil +} + +func TestAPIKeyScopeMatchesPermission(t *testing.T) { + permission := consts.PermProjectReadAll + + tests := []struct { + name string + scope string + want bool + }{ + {name: "wildcard all", scope: "*", want: true}, + {name: "resource only", scope: "project", want: true}, + {name: "resource action", scope: "project:read", want: true}, + {name: "resource action scope", scope: "project:read:all", want: true}, + {name: "resource wildcard action", scope: "project:*", want: true}, + {name: "resource action wildcard scope", scope: "project:read:*", want: true}, + {name: "full wildcard segments", scope: "project:*:*", want: true}, + {name: "other action", scope: "project:update", want: false}, + {name: "other resource", scope: "dataset:read", want: false}, + {name: "too specific mismatch", scope: "project:read:team", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := apiKeyScopeMatchesPermission(tt.scope, permission); got != tt.want { + t.Fatalf("apiKeyScopeMatchesPermission(%q, %q) = %v, want %v", tt.scope, permission.String(), got, tt.want) + } + }) + } +} + +func TestPermissionContextScopeAllowsPermission(t *testing.T) { + ctx := &permissionContext{ + authType: "api_key", + apiKeyScopes: []string{"project:read", "execution:execute:project"}, + } + + if !ctx.scopeAllowsPermission(consts.PermProjectReadAll) { + t.Fatalf("scopeAllowsPermission(project read) = false, want true") + } + if ctx.scopeAllowsPermission(consts.PermProjectUpdateAll) { + t.Fatalf("scopeAllowsPermission(project update) = true, want false") + } + if !ctx.scopeAllowsPermission(consts.PermExecutionExecuteProject) { + t.Fatalf("scopeAllowsPermission(execution execute project) = false, want true") + } +} + +func TestPermissionContextScopeAllowsAnyPermission(t *testing.T) { + ctx := &permissionContext{ + authType: "api_key", + apiKeyScopes: []string{"team:read"}, + } + + if !ctx.scopeAllowsAnyPermission([]consts.PermissionRule{consts.PermTeamManageAll, consts.PermTeamReadAll}) { + t.Fatalf("scopeAllowsAnyPermission(team manage/read) = false, want true") + } + if ctx.scopeAllowsAnyPermission([]consts.PermissionRule{consts.PermProjectManageAll, consts.PermProjectReadAll}) { + t.Fatalf("scopeAllowsAnyPermission(project manage/read) = true, want false") + } +} + +func TestTeamAccessCheckScopes(t *testing.T) { + memberCheck := teamAccessCheck(false) + adminCheck := teamAccessCheck(true) + teamID := 9 + + memberCtx := &permissionContext{ + authType: "api_key", + apiKeyScopes: []string{"team:read"}, + teamID: &teamID, + checker: permissionCheckerStub{}, + } + allowed, err := memberCheck(memberCtx) + if err != nil { + t.Fatalf("memberCheck() error = %v", err) + } + if allowed { + t.Fatalf("memberCheck() = true, want false without membership backing") + } + + allowed, err = adminCheck(memberCtx) + if err != nil { + t.Fatalf("adminCheck() error = %v", err) + } + if allowed { + t.Fatalf("adminCheck() = true, want false for read-only scope") + } + + adminCtx := &permissionContext{ + authType: "api_key", + apiKeyScopes: []string{"team:manage"}, + teamID: &teamID, + isAdmin: true, + checker: permissionCheckerStub{}, + } + allowed, err = adminCheck(adminCtx) + if err != nil { + t.Fatalf("adminCheck(manage) error = %v", err) + } + if !allowed { + t.Fatalf("adminCheck(manage) = false, want true") + } +} + +func TestProjectAccessCheckScopes(t *testing.T) { + memberCheck := projectAccessCheck(false) + adminCheck := projectAccessCheck(true) + projectID := 7 + + memberCtx := &permissionContext{ + authType: "api_key", + apiKeyScopes: []string{"project:read"}, + projectID: &projectID, + checker: permissionCheckerStub{}, + } + allowed, err := memberCheck(memberCtx) + if err != nil { + t.Fatalf("memberCheck() error = %v", err) + } + if allowed { + t.Fatalf("memberCheck() = true, want false without project membership") + } + + allowed, err = adminCheck(memberCtx) + if err != nil { + t.Fatalf("adminCheck() error = %v", err) + } + if allowed { + t.Fatalf("adminCheck() = true, want false for read-only scope") + } + + adminCtx := &permissionContext{ + authType: "api_key", + apiKeyScopes: []string{"project:manage"}, + projectID: &projectID, + isAdmin: true, + checker: permissionCheckerStub{}, + } + allowed, err = adminCheck(adminCtx) + if err != nil { + t.Fatalf("adminCheck(manage) error = %v", err) + } + if !allowed { + t.Fatalf("adminCheck(manage) = false, want true") + } +} diff --git a/src/database/entity.go b/src/model/entity.go similarity index 97% rename from src/database/entity.go rename to src/model/entity.go index 7d78838a..e8b0a19c 100644 --- a/src/database/entity.go +++ b/src/model/entity.go @@ -1,4 +1,4 @@ -package database +package model import ( "fmt" @@ -412,6 +412,27 @@ func (u *User) BeforeCreate(tx *gorm.DB) error { return nil } +type APIKey struct { + ID int `gorm:"primaryKey;autoIncrement"` + UserID int `gorm:"not null;index:idx_api_key_owner_status"` + Name string `gorm:"not null;size:128"` + Description string `gorm:"type:text"` + KeyID string `gorm:"not null;size:64"` + KeySecretHash string `gorm:"not null;size:255"` + KeySecretCiphertext string `gorm:"not null;type:text"` + Scopes []string `gorm:"type:json;serializer:json"` + RevokedAt *time.Time + LastUsedAt *time.Time + ExpiresAt *time.Time + Status consts.StatusType `gorm:"not null;default:1;index:idx_api_key_owner_status"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + ActiveKeyID string `gorm:"type:varchar(64) GENERATED ALWAYS AS (CASE WHEN status >= 0 THEN key_id ELSE NULL END) VIRTUAL;uniqueIndex:idx_active_api_key"` + + User *User `gorm:"foreignKey:UserID"` +} + // Role table type Role struct { ID int `gorm:"primaryKey;autoIncrement"` // Unique identifier diff --git a/src/database/entity_helper.go b/src/model/entity_helper.go similarity index 98% rename from src/database/entity_helper.go rename to src/model/entity_helper.go index 4e1cc2e7..3f5f6ca7 100644 --- a/src/database/entity_helper.go +++ b/src/model/entity_helper.go @@ -1,4 +1,4 @@ -package database +package model import ( "database/sql/driver" diff --git a/src/model/view.go b/src/model/view.go new file mode 100644 index 00000000..44dd4e9d --- /dev/null +++ b/src/model/view.go @@ -0,0 +1,46 @@ +package model + +import ( + "time" + + chaos "github.com/OperationsPAI/chaos-experiment/handler" +) + +// FaultInjectionNoIssues view model +type FaultInjectionNoIssues struct { + ID int `gorm:"column:datapack_id"` + Name string `gorm:"column:datapack_name"` + FaultType chaos.ChaosType `gorm:"column:fault_type"` + Category chaos.SystemType `gorm:"column:category"` + EngineConfig string `gorm:"column:engine_config"` + LabelKey string `gorm:"column:label_key"` + LabelValue string `gorm:"column:value_key"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +func (FaultInjectionNoIssues) TableName() string { + return "fault_injection_no_issues" +} + +// FaultInjectionWithIssues view model +type FaultInjectionWithIssues struct { + ID int `gorm:"column:datapack_id"` + Name string `gorm:"column:datapack_name"` + FaultType chaos.ChaosType `gorm:"column:fault_type"` + Category chaos.SystemType `gorm:"column:category"` + EngineConfig string `gorm:"column:engine_config"` + LabelKey string `gorm:"column:label_key"` + LabelValue string `gorm:"column:value_key"` + CreatedAt time.Time `gorm:"column:created_at"` + Issues string `gorm:"column:issues"` + AbnormalAvgDuration float64 `gorm:"column:abnormal_avg_duration"` + NormalAvgDuration float64 `gorm:"column:normal_avg_duration"` + AbnormalSuccRate float64 `gorm:"column:abnormal_succ_rate"` + NormalSuccRate float64 `gorm:"column:normal_succ_rate"` + AbnormalP99 float64 `gorm:"column:abnormal_p99"` + NormalP99 float64 `gorm:"column:normal_p99"` +} + +func (FaultInjectionWithIssues) TableName() string { + return "fault_injection_with_issues" +} diff --git a/src/module/auth/api_types.go b/src/module/auth/api_types.go new file mode 100644 index 00000000..1f0e42de --- /dev/null +++ b/src/module/auth/api_types.go @@ -0,0 +1,299 @@ +package auth + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/model" + user "aegis/module/user" + "aegis/utils" +) + +const usernamePattern = `^[a-zA-Z0-9_]{3,20}$` + +type RegisterReq struct { + Username string `json:"username" binding:"required" example:"newuser"` + Email string `json:"email" binding:"required,email" example:"user@example.com"` + Password string `json:"password" binding:"required,min=8" example:"password123"` +} + +func (req *RegisterReq) Validate() error { + usernameRegex := regexp.MustCompile(usernamePattern) + if !usernameRegex.MatchString(req.Username) { + return fmt.Errorf("username must be 3-20 characters and contain only letters, numbers, and underscores") + } + if len(req.Password) == 0 { + return fmt.Errorf("password is required") + } + if len(req.Password) < 8 { + return fmt.Errorf("password must be at least 8 characters long") + } + return nil +} + +type LoginReq struct { + Username string `json:"username" binding:"required" example:"admin"` + Password string `json:"password" binding:"required" example:"password123"` +} + +func (req *LoginReq) Validate() error { + usernameRegex := regexp.MustCompile(usernamePattern) + if !usernameRegex.MatchString(req.Username) { + return fmt.Errorf("invalid username or password") + } + if req.Password == "" { + return fmt.Errorf("invalid username or password") + } + return nil +} + +type TokenRefreshReq struct { + Token string `json:"token" binding:"required" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."` +} + +func (req *TokenRefreshReq) Validate() error { + if req.Token == "" { + return fmt.Errorf("invalid token") + } + return nil +} + +type ChangePasswordReq struct { + OldPassword string `json:"old_password" binding:"required" example:"oldpassword123"` + NewPassword string `json:"new_password" binding:"required,min=8" example:"newpassword123"` +} + +func (req *ChangePasswordReq) Validate() error { + if req.OldPassword == "" { + return fmt.Errorf("old_password is required") + } + if len(req.OldPassword) < 8 { + return fmt.Errorf("old_password must be at least 8 characters long") + } + if req.NewPassword == "" { + return fmt.Errorf("new_password is required") + } + if len(req.NewPassword) < 8 { + return fmt.Errorf("new_password must be at least 8 characters long") + } + return nil +} + +type CreateAPIKeyReq struct { + Name string `json:"name" binding:"required" example:"ci-bot"` + Description string `json:"description,omitempty" example:"SDK credential for CI pipeline"` + Scopes []string `json:"scopes,omitempty" example:"[\"*\"]"` + ExpiresAt *time.Time `json:"expires_at,omitempty" example:"2026-12-31T23:59:59Z"` +} + +func (req *CreateAPIKeyReq) Validate() error { + if req == nil { + return fmt.Errorf("request is required") + } + if req.Name == "" { + return fmt.Errorf("name is required") + } + if len(req.Name) > 128 { + return fmt.Errorf("name must be no more than 128 characters long") + } + normalizedScopes, err := normalizeAPIKeyScopes(req.Scopes) + if err != nil { + return err + } + req.Scopes = normalizedScopes + if req.ExpiresAt != nil && req.ExpiresAt.Before(time.Now()) { + return fmt.Errorf("expires_at must be in the future") + } + return nil +} + +type ListAPIKeyReq struct { + dto.PaginationReq +} + +func (req *ListAPIKeyReq) Validate() error { + if req == nil { + return fmt.Errorf("request is required") + } + return req.PaginationReq.Validate() +} + +type APIKeyTokenReq struct { + KeyID string `header:"X-Key-Id" example:"pk_1234567890abcdef"` + Timestamp string `header:"X-Timestamp" example:"1713333333"` + Nonce string `header:"X-Nonce" example:"abc123"` + Signature string `header:"X-Signature" example:"4cf2f2cbb93d..."` +} + +func (req *APIKeyTokenReq) Validate() error { + if req == nil { + return fmt.Errorf("request is required") + } + req.KeyID = strings.TrimSpace(req.KeyID) + req.Timestamp = strings.TrimSpace(req.Timestamp) + req.Nonce = strings.TrimSpace(req.Nonce) + req.Signature = strings.ToLower(strings.TrimSpace(req.Signature)) + if req.KeyID == "" || req.Timestamp == "" || req.Nonce == "" || req.Signature == "" { + return fmt.Errorf("X-Key-Id, X-Timestamp, X-Nonce and X-Signature are required") + } + if _, err := strconv.ParseInt(req.Timestamp, 10, 64); err != nil { + return fmt.Errorf("X-Timestamp must be a unix timestamp in seconds") + } + if len(req.Nonce) > 128 { + return fmt.Errorf("X-Nonce must be no more than 128 characters long") + } + return nil +} + +func (req *APIKeyTokenReq) TimestampUnix() (int64, error) { + return strconv.ParseInt(req.Timestamp, 10, 64) +} + +func (req *APIKeyTokenReq) CanonicalString(method, path string) string { + return strings.Join([]string{ + strings.ToUpper(method), + path, + req.Timestamp, + req.Nonce, + utils.SHA256Hex(nil), + }, "\n") +} + +type LoginResp struct { + Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."` + ExpiresAt time.Time `json:"expires_at" example:"2024-12-31T23:59:59Z"` + User UserInfo `json:"user"` +} + +type TokenRefreshResp struct { + Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."` + ExpiresAt time.Time `json:"expires_at" example:"2024-12-31T23:59:59Z"` +} + +type APIKeyInfo struct { + ID int `json:"id" example:"12"` + Name string `json:"name" example:"ci-bot"` + Description string `json:"description,omitempty" example:"SDK credential for CI pipeline"` + KeyID string `json:"key_id" example:"pk_1234567890abcdef"` + Scopes []string `json:"scopes,omitempty" example:"[\"*\"]"` + Status consts.StatusType `json:"status" example:"1"` + RevokedAt *time.Time `json:"revoked_at,omitempty" example:"2026-04-17T12:30:00Z"` + LastUsedAt *time.Time `json:"last_used_at,omitempty" example:"2026-04-17T12:00:00Z"` + ExpiresAt *time.Time `json:"expires_at,omitempty" example:"2026-12-31T23:59:59Z"` + CreatedAt time.Time `json:"created_at" example:"2026-04-17T11:00:00Z"` + UpdatedAt time.Time `json:"updated_at" example:"2026-04-17T11:00:00Z"` +} + +func NewAPIKeyInfo(key *model.APIKey) *APIKeyInfo { + if key == nil { + return nil + } + return &APIKeyInfo{ + ID: key.ID, + Name: key.Name, + Description: key.Description, + KeyID: key.KeyID, + Scopes: append([]string(nil), key.Scopes...), + Status: key.Status, + RevokedAt: key.RevokedAt, + LastUsedAt: key.LastUsedAt, + ExpiresAt: key.ExpiresAt, + CreatedAt: key.CreatedAt, + UpdatedAt: key.UpdatedAt, + } +} + +type APIKeyWithSecretResp struct { + APIKeyInfo + KeySecret string `json:"key_secret" example:"ks_abcdefghijklmnopqrstuvwxyz123456"` +} + +type ListAPIKeyResp struct { + Items []APIKeyInfo `json:"items"` + Pagination dto.PaginationInfo `json:"pagination"` +} + +type APIKeyTokenResp struct { + Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.api_key.jwt"` + TokenType string `json:"token_type" example:"Bearer"` + ExpiresAt time.Time `json:"expires_at" example:"2026-04-17T12:00:00Z"` + AuthType string `json:"auth_type" example:"api_key"` + KeyID string `json:"key_id" example:"pk_1234567890abcdef"` +} + +const defaultAPIKeyScope = "*" + +func normalizeAPIKeyScopes(scopes []string) ([]string, error) { + if len(scopes) == 0 { + return []string{defaultAPIKeyScope}, nil + } + + normalized := make([]string, 0, len(scopes)) + seen := make(map[string]struct{}, len(scopes)) + for _, scope := range scopes { + scope = strings.TrimSpace(scope) + if scope == "" { + return nil, fmt.Errorf("scopes cannot contain empty items") + } + if len(scope) > 128 { + return nil, fmt.Errorf("scope %q must be no more than 128 characters long", scope) + } + if _, exists := seen[scope]; exists { + continue + } + seen[scope] = struct{}{} + normalized = append(normalized, scope) + } + if len(normalized) == 0 { + return []string{defaultAPIKeyScope}, nil + } + return normalized, nil +} + +type UserProfileResp struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + FullName string `json:"full_name"` + Avatar string `json:"avatar,omitempty"` + Phone string `json:"phone,omitempty"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + + ContainerRoles []user.UserContainerInfo `json:"container_roles,omitempty"` + DatasetRoles []user.UserDatasetInfo `json:"dataset_roles,omitempty"` + ProjectRoles []user.UserProjectInfo `json:"project_roles,omitempty"` +} + +func NewUserProfileResp(user *model.User) *UserProfileResp { + return &UserProfileResp{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + FullName: user.FullName, + Avatar: user.Avatar, + Phone: user.Phone, + LastLoginAt: user.LastLoginAt, + CreatedAt: user.CreatedAt, + } +} + +type UserInfo struct { + ID int `json:"id" example:"1"` + Username string `json:"username" example:"admin"` + Avatar string `json:"avatar,omitempty"` + Role string `json:"role,omitempty"` +} + +func NewUserInfo(user *model.User) *UserInfo { + return &UserInfo{ + ID: user.ID, + Username: user.Username, + Avatar: user.Avatar, + } +} diff --git a/src/module/auth/handler.go b/src/module/auth/handler.go new file mode 100644 index 00000000..e9f15363 --- /dev/null +++ b/src/module/auth/handler.go @@ -0,0 +1,534 @@ +package auth + +import ( + "aegis/httpx" + "net/http" + + "aegis/consts" + "aegis/dto" + "aegis/middleware" + "aegis/utils" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + +// Login handles user authentication +// +// @Summary User login +// @Description Authenticate user with username and password +// @Tags Authentication +// @ID login +// @Accept json +// @Produce json +// @Param request body LoginReq true "Login credentials" +// @Success 200 {object} dto.GenericResponse[LoginResp] "Login successful" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format" +// @Failure 401 {object} dto.GenericResponse[any] "Invalid user name or password" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/auth/login [post] +// @x-api-type {"portal":"true","admin":"true"} +func (h *Handler) Login(c *gin.Context) { + var req LoginReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusUnauthorized, err.Error()) + return + } + + resp, err := h.service.Login(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse(c, http.StatusOK, "Login successful", resp) +} + +// Register handles user registration +// +// @Summary User registration +// @Description Register a new user account +// @Tags Authentication +// @ID register_user +// @Accept json +// @Produce json +// @Param request body RegisterReq true "Registration details" +// @Success 201 {object} dto.GenericResponse[UserInfo] "Registration successful" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format/parameters" +// @Failure 409 {object} dto.GenericResponse[any] "User already exists" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/auth/register [post] +// @x-api-type {"portal":"true","admin":"true"} +func (h *Handler) Register(c *gin.Context) { + var req RegisterReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.Register(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse(c, http.StatusCreated, "Registration successful", resp) +} + +// RefreshToken handles JWT token refresh +// +// @Summary Refresh JWT token +// @Description Refresh an existing JWT token +// @Tags Authentication +// @ID refresh_auth_token +// @Accept json +// @Produce json +// @Param request body TokenRefreshReq true "Token refresh request" +// @Success 200 {object} dto.GenericResponse[TokenRefreshResp] "Token refreshed successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format" +// @Failure 401 {object} dto.GenericResponse[any] "Invalid token" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/auth/refresh [post] +// @x-api-type {"portal":"true","admin":"true"} +func (h *Handler) RefreshToken(c *gin.Context) { + var req TokenRefreshReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusUnauthorized, err.Error()) + return + } + + resp, err := h.service.RefreshToken(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse(c, http.StatusOK, "Token refreshed successfully", resp) +} + +// Logout handles user logout +// +// @Summary User logout +// @Description Logout user and invalidate token +// @Tags Authentication +// @ID logout +// @Produce json +// @Success 200 {object} dto.GenericResponse[any] "Logout successful" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid authorization header" +// @Failure 401 {object} dto.GenericResponse[any] "Invalid token" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/auth/logout [post] +// @x-api-type {"portal":"true","admin":"true"} +func (h *Handler) Logout(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + token, err := utils.ExtractTokenFromHeader(authHeader) + if err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid authorization header") + return + } + + claims, err := utils.ValidateToken(token) + if err != nil { + dto.ErrorResponse(c, http.StatusUnauthorized, "Invalid token") + return + } + + err = h.service.Logout(c.Request.Context(), claims) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse[any](c, http.StatusOK, "Logged out successfully", nil) +} + +// ChangePassword handles password change +// +// @Summary Change user password +// @Description Change password for authenticated user +// @Tags Authentication +// @ID change_password +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body ChangePasswordReq true "Password change request" +// @Success 200 {object} dto.GenericResponse[any] "Password changed successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format/parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/auth/change-password [post] +// @x-api-type {"portal":"true","admin":"true"} +func (h *Handler) ChangePassword(c *gin.Context) { + userID, exists := middleware.GetCurrentUserID(c) + if !exists || userID <= 0 { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + + var req ChangePasswordReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + err := h.service.ChangePassword(c.Request.Context(), &req, userID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse[any](c, http.StatusOK, "Password changed successfully", nil) +} + +// GetProfile handles getting current user profile +// +// @Summary Get current user profile +// @Description Get profile information for authenticated user +// @Tags Authentication +// @ID get_current_user_profile +// @Produce json +// @Security BearerAuth +// @Success 200 {object} dto.GenericResponse[UserProfileResp] "Profile retrieved successfully" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/auth/profile [get] +// @x-api-type {"portal":"true","admin":"true"} +func (h *Handler) GetProfile(c *gin.Context) { + userID, exists := middleware.GetCurrentUserID(c) + if !exists || userID <= 0 { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + + resp, err := h.service.GetProfile(c.Request.Context(), userID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse(c, http.StatusOK, "Profile retrieved successfully", resp) +} + +// CreateAPIKey handles API key creation for the current user. +// +// @Summary Create API key +// @Description Create a Key ID / Key Secret credential for the current authenticated user. This Portal response is the only time the `key_secret` is returned in plaintext, so callers must save it immediately. +// @Tags Authentication +// @ID create_api_key +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body CreateAPIKeyReq true "API key create request" +// @Success 201 {object} dto.GenericResponse[APIKeyWithSecretResp] "API key created successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/api-keys [post] +// @x-api-type {"portal":"true"} +func (h *Handler) CreateAPIKey(c *gin.Context) { + userID, exists := middleware.GetCurrentUserID(c) + if !exists || userID <= 0 { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + + var req CreateAPIKeyReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.CreateAPIKey(c.Request.Context(), userID, &req) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse(c, http.StatusCreated, "API key created successfully", resp) +} + +// ListAPIKeys lists API keys for the current user. +// +// @Summary List API keys +// @Description List Key ID / Key Secret credentials owned by the current authenticated user +// @Tags Authentication +// @ID list_api_keys +// @Produce json +// @Security BearerAuth +// @Param page query int false "Page number" +// @Param size query int false "Page size" +// @Success 200 {object} dto.GenericResponse[ListAPIKeyResp] "API keys listed successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/api-keys [get] +// @x-api-type {"portal":"true"} +func (h *Handler) ListAPIKeys(c *gin.Context) { + userID, exists := middleware.GetCurrentUserID(c) + if !exists || userID <= 0 { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + + var req ListAPIKeyReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.ListAPIKeys(c.Request.Context(), userID, &req) + if httpx.HandleServiceError(c, err) { + return + } + + dto.SuccessResponse(c, resp) +} + +// GetAPIKey gets a single API key for the current user. +// +// @Summary Get API key detail +// @Description Get metadata for a Key ID / Key Secret credential owned by the current authenticated user +// @Tags Authentication +// @ID get_api_key +// @Produce json +// @Security BearerAuth +// @Param id path int true "API key record ID" +// @Success 200 {object} dto.GenericResponse[APIKeyInfo] "API key detail retrieved successfully" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 404 {object} dto.GenericResponse[any] "API key not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/api-keys/{id} [get] +// @x-api-type {"portal":"true"} +func (h *Handler) GetAPIKey(c *gin.Context) { + userID, accessKeyID, ok := parseCurrentUserAndAPIKeyID(c) + if !ok { + return + } + + resp, err := h.service.GetAPIKey(c.Request.Context(), userID, accessKeyID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.SuccessResponse(c, resp) +} + +// DeleteAPIKey deletes an API key for the current user. +// +// @Summary Delete API key +// @Description Delete a Key ID / Key Secret credential owned by the current authenticated user +// @Tags Authentication +// @ID delete_api_key +// @Produce json +// @Security BearerAuth +// @Param id path int true "API key record ID" +// @Success 204 {object} dto.GenericResponse[any] "API key deleted successfully" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 404 {object} dto.GenericResponse[any] "API key not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/api-keys/{id} [delete] +// @x-api-type {"portal":"true"} +func (h *Handler) DeleteAPIKey(c *gin.Context) { + userID, accessKeyID, ok := parseCurrentUserAndAPIKeyID(c) + if !ok { + return + } + + if httpx.HandleServiceError(c, h.service.DeleteAPIKey(c.Request.Context(), userID, accessKeyID)) { + return + } + + dto.JSONResponse[any](c, http.StatusNoContent, "API key deleted successfully", nil) +} + +// DisableAPIKey disables an API key for the current user. +// +// @Summary Disable API key +// @Description Disable a Key ID / Key Secret credential owned by the current authenticated user +// @Tags Authentication +// @ID disable_api_key +// @Produce json +// @Security BearerAuth +// @Param id path int true "API key record ID" +// @Success 200 {object} dto.GenericResponse[any] "API key disabled successfully" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 404 {object} dto.GenericResponse[any] "API key not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/api-keys/{id}/disable [post] +// @x-api-type {"portal":"true"} +func (h *Handler) DisableAPIKey(c *gin.Context) { + userID, accessKeyID, ok := parseCurrentUserAndAPIKeyID(c) + if !ok { + return + } + + if httpx.HandleServiceError(c, h.service.DisableAPIKey(c.Request.Context(), userID, accessKeyID)) { + return + } + + dto.JSONResponse[any](c, http.StatusOK, "API key disabled successfully", nil) +} + +// EnableAPIKey enables an API key for the current user. +// +// @Summary Enable API key +// @Description Enable a Key ID / Key Secret credential owned by the current authenticated user +// @Tags Authentication +// @ID enable_api_key +// @Produce json +// @Security BearerAuth +// @Param id path int true "API key record ID" +// @Success 200 {object} dto.GenericResponse[any] "API key enabled successfully" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 404 {object} dto.GenericResponse[any] "API key not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/api-keys/{id}/enable [post] +// @x-api-type {"portal":"true"} +func (h *Handler) EnableAPIKey(c *gin.Context) { + userID, accessKeyID, ok := parseCurrentUserAndAPIKeyID(c) + if !ok { + return + } + + if httpx.HandleServiceError(c, h.service.EnableAPIKey(c.Request.Context(), userID, accessKeyID)) { + return + } + + dto.JSONResponse[any](c, http.StatusOK, "API key enabled successfully", nil) +} + +// RevokeAPIKey permanently revokes an API key for the current user. +// +// @Summary Revoke API key +// @Description Permanently revoke a Key ID / Key Secret credential owned by the current authenticated user. Revoked API keys can no longer be re-enabled or used to exchange bearer tokens. +// @Tags Authentication +// @ID revoke_api_key +// @Produce json +// @Security BearerAuth +// @Param id path int true "API key record ID" +// @Success 200 {object} dto.GenericResponse[any] "API key revoked successfully" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 404 {object} dto.GenericResponse[any] "API key not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/api-keys/{id}/revoke [post] +// @x-api-type {"portal":"true"} +func (h *Handler) RevokeAPIKey(c *gin.Context) { + userID, accessKeyID, ok := parseCurrentUserAndAPIKeyID(c) + if !ok { + return + } + + if httpx.HandleServiceError(c, h.service.RevokeAPIKey(c.Request.Context(), userID, accessKeyID)) { + return + } + + dto.JSONResponse[any](c, http.StatusOK, "API key revoked successfully", nil) +} + +// RotateAPIKey rotates the key secret for an existing API key. +// +// @Summary Rotate API key secret +// @Description Rotate the key secret half of a Key ID / Key Secret credential owned by the current authenticated user +// @Tags Authentication +// @ID rotate_api_key +// @Produce json +// @Security BearerAuth +// @Param id path int true "API key record ID" +// @Success 200 {object} dto.GenericResponse[APIKeyWithSecretResp] "API key rotated successfully" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 404 {object} dto.GenericResponse[any] "API key not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/api-keys/{id}/rotate [post] +// @x-api-type {"portal":"true"} +func (h *Handler) RotateAPIKey(c *gin.Context) { + userID, accessKeyID, ok := parseCurrentUserAndAPIKeyID(c) + if !ok { + return + } + + resp, err := h.service.RotateAPIKey(c.Request.Context(), userID, accessKeyID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse(c, http.StatusOK, "API key rotated successfully", resp) +} + +// ExchangeAPIKeyToken exchanges a signed API key request for a bearer token. +// +// @Summary Exchange API key for token +// @Description Exchange a signed Key ID / Key Secret request for a short-lived bearer token. SDK and CLI callers sign `METHOD\\nPATH\\nTIMESTAMP\\nNONCE\\nSHA256(BODY)` with the key secret and send the result via `X-Key-Id`, `X-Timestamp`, `X-Nonce`, and `X-Signature`. +// @Tags Authentication +// @ID exchange_api_key_token +// @Produce json +// @Param X-Key-Id header string true "Public key identifier" +// @Param X-Timestamp header string true "Unix timestamp in seconds" +// @Param X-Nonce header string true "Unique request nonce" +// @Param X-Signature header string true "Hex encoded HMAC-SHA256 signature of METHOD\\nPATH\\nTIMESTAMP\\nNONCE\\nSHA256(BODY)" +// @Success 200 {object} dto.GenericResponse[APIKeyTokenResp] "API key token issued successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Invalid signature or replayed request" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/auth/api-key/token [post] +// @x-api-type {"sdk":"true"} +func (h *Handler) ExchangeAPIKeyToken(c *gin.Context) { + var req APIKeyTokenReq + req.KeyID = c.GetHeader("X-Key-Id") + req.Timestamp = c.GetHeader("X-Timestamp") + req.Nonce = c.GetHeader("X-Nonce") + req.Signature = c.GetHeader("X-Signature") + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.ExchangeAPIKeyToken(c.Request.Context(), &req, c.Request.Method, c.Request.URL.Path) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse(c, http.StatusOK, "API key token issued successfully", resp) +} + +func parseCurrentUserAndAPIKeyID(c *gin.Context) (int, int, bool) { + userID, exists := middleware.GetCurrentUserID(c) + if !exists || userID <= 0 { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return 0, 0, false + } + + accessKeyID, ok := httpx.ParsePositiveID(c, c.Param("id"), consts.URLPathID) + if !ok { + return 0, 0, false + } + + return userID, accessKeyID, true +} diff --git a/src/module/auth/handler_service.go b/src/module/auth/handler_service.go new file mode 100644 index 00000000..55827e46 --- /dev/null +++ b/src/module/auth/handler_service.go @@ -0,0 +1,30 @@ +package auth + +import ( + "context" + + "aegis/utils" +) + +// HandlerService captures the auth operations consumed by the HTTP handler. +type HandlerService interface { + Login(context.Context, *LoginReq) (*LoginResp, error) + Register(context.Context, *RegisterReq) (*UserInfo, error) + RefreshToken(context.Context, *TokenRefreshReq) (*TokenRefreshResp, error) + Logout(context.Context, *utils.Claims) error + ChangePassword(context.Context, *ChangePasswordReq, int) error + GetProfile(context.Context, int) (*UserProfileResp, error) + CreateAPIKey(context.Context, int, *CreateAPIKeyReq) (*APIKeyWithSecretResp, error) + ListAPIKeys(context.Context, int, *ListAPIKeyReq) (*ListAPIKeyResp, error) + GetAPIKey(context.Context, int, int) (*APIKeyInfo, error) + DeleteAPIKey(context.Context, int, int) error + DisableAPIKey(context.Context, int, int) error + EnableAPIKey(context.Context, int, int) error + RevokeAPIKey(context.Context, int, int) error + RotateAPIKey(context.Context, int, int) (*APIKeyWithSecretResp, error) + ExchangeAPIKeyToken(context.Context, *APIKeyTokenReq, string, string) (*APIKeyTokenResp, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/auth/middleware_adapter.go b/src/module/auth/middleware_adapter.go new file mode 100644 index 00000000..ce98623f --- /dev/null +++ b/src/module/auth/middleware_adapter.go @@ -0,0 +1,7 @@ +package auth + +import "aegis/middleware" + +func NewTokenVerifier(service *Service) middleware.TokenVerifier { + return service +} diff --git a/src/module/auth/module.go b/src/module/auth/module.go new file mode 100644 index 00000000..fcf705e0 --- /dev/null +++ b/src/module/auth/module.go @@ -0,0 +1,16 @@ +package auth + +import ( + "go.uber.org/fx" +) + +var Module = fx.Module("auth", + fx.Provide(NewUserRepository), + fx.Provide(NewRoleRepository), + fx.Provide(NewAPIKeyRepository), + fx.Provide(NewTokenStore), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewTokenVerifier), + fx.Provide(NewHandler), +) diff --git a/src/module/auth/repository.go b/src/module/auth/repository.go new file mode 100644 index 00000000..bc06fb1d --- /dev/null +++ b/src/module/auth/repository.go @@ -0,0 +1,186 @@ +package auth + +import ( + "aegis/consts" + "aegis/model" + "fmt" + "time" + + "gorm.io/gorm" +) + +const ( + userOmitFields = "active_username" + userContainerOmitFields = "active_user_container" + userDatasetOmitFields = "active_user_dataset" + userProjectOmitFields = "active_user_project" +) + +type UserRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) Create(user *model.User) error { + if err := r.db.Omit(userOmitFields).Create(user).Error; err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + return nil +} + +func (r *UserRepository) GetByID(id int) (*model.User, error) { + var user model.User + if err := r.db.Where("id = ?", id).First(&user).Error; err != nil { + return nil, fmt.Errorf("failed to find user with id %d: %w", id, err) + } + return &user, nil +} + +func (r *UserRepository) GetByUsername(username string) (*model.User, error) { + var user model.User + if err := r.db.Where("username = ?", username).First(&user).Error; err != nil { + return nil, fmt.Errorf("failed to find user with username %s: %w", username, err) + } + return &user, nil +} + +func (r *UserRepository) GetByEmail(email string) (*model.User, error) { + var user model.User + if err := r.db.Where("email = ?", email).First(&user).Error; err != nil { + return nil, fmt.Errorf("failed to find user with email %s: %w", email, err) + } + return &user, nil +} + +func (r *UserRepository) Update(user *model.User) error { + if err := r.db.Omit(userOmitFields).Save(user).Error; err != nil { + return fmt.Errorf("failed to update user: %w", err) + } + return nil +} + +func (r *UserRepository) UpdateLoginTime(userID int) error { + now := r.db.NowFunc() + if err := r.db.Model(&model.User{}). + Where("id = ? AND status != ?", userID, consts.CommonDeleted). + Update("last_login_at", now).Error; err != nil { + return fmt.Errorf("failed to update user login time: %w", err) + } + return nil +} + +func (r *UserRepository) ListContainerRoles(userID int) ([]model.UserContainer, error) { + var userContainers []model.UserContainer + if err := r.db.Preload("Container"). + Preload("Role"). + Where("user_id = ? AND status = ?", userID, consts.CommonEnabled). + Find(&userContainers).Error; err != nil { + return nil, fmt.Errorf("failed to get user-container associations of the specific user: %w", err) + } + return userContainers, nil +} + +func (r *UserRepository) ListDatasetRoles(userID int) ([]model.UserDataset, error) { + var userDatasets []model.UserDataset + if err := r.db.Preload("Dataset"). + Preload("Role"). + Where("user_id = ? AND status = ?", userID, consts.CommonEnabled). + Find(&userDatasets).Error; err != nil { + return nil, fmt.Errorf("failed to get user-dataset associations of the specific user: %w", err) + } + return userDatasets, nil +} + +func (r *UserRepository) ListProjectRoles(userID int) ([]model.UserProject, error) { + var userProjects []model.UserProject + if err := r.db.Preload("Project"). + Preload("Role"). + Where("user_id = ? AND status = ?", userID, consts.CommonEnabled). + Find(&userProjects).Error; err != nil { + return nil, fmt.Errorf("failed to get user-project associations of the specific user: %w", err) + } + return userProjects, nil +} + +type RoleRepository struct { + db *gorm.DB +} + +func NewRoleRepository(db *gorm.DB) *RoleRepository { + return &RoleRepository{db: db} +} + +func (r *RoleRepository) ListByUserID(userID int) ([]model.Role, error) { + var roles []model.Role + if err := r.db.Table("roles"). + Joins("JOIN user_roles ur ON ur.role_id = roles.id"). + Where("ur.user_id = ? AND roles.status = ?", userID, consts.CommonEnabled). + Find(&roles).Error; err != nil { + return nil, fmt.Errorf("failed to get global roles of the specific user: %w", err) + } + return roles, nil +} + +type APIKeyRepository struct { + db *gorm.DB +} + +func NewAPIKeyRepository(db *gorm.DB) *APIKeyRepository { + return &APIKeyRepository{db: db} +} + +func (r *APIKeyRepository) Create(key *model.APIKey) error { + if err := r.db.Create(key).Error; err != nil { + return fmt.Errorf("failed to create api key: %w", err) + } + return nil +} + +func (r *APIKeyRepository) ListByUserID(userID, limit, offset int) ([]model.APIKey, int64, error) { + query := r.db.Model(&model.APIKey{}). + Where("user_id = ? AND status != ?", userID, consts.CommonDeleted) + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count api keys: %w", err) + } + + var keys []model.APIKey + if err := query.Order("id DESC").Limit(limit).Offset(offset).Find(&keys).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list api keys: %w", err) + } + return keys, total, nil +} + +func (r *APIKeyRepository) GetByIDForUser(id, userID int) (*model.APIKey, error) { + var key model.APIKey + if err := r.db.Where("id = ? AND user_id = ? AND status != ?", id, userID, consts.CommonDeleted).First(&key).Error; err != nil { + return nil, fmt.Errorf("failed to find api key: %w", err) + } + return &key, nil +} + +func (r *APIKeyRepository) GetByKeyID(keyID string) (*model.APIKey, error) { + var key model.APIKey + if err := r.db.Where("key_id = ? AND status != ?", keyID, consts.CommonDeleted).First(&key).Error; err != nil { + return nil, fmt.Errorf("failed to find api key: %w", err) + } + return &key, nil +} + +func (r *APIKeyRepository) Update(key *model.APIKey) error { + if err := r.db.Save(key).Error; err != nil { + return fmt.Errorf("failed to update api key: %w", err) + } + return nil +} + +func (r *APIKeyRepository) UpdateLastUsedAt(id int, usedAt time.Time) error { + if err := r.db.Model(&model.APIKey{}).Where("id = ?", id).Update("last_used_at", usedAt).Error; err != nil { + return fmt.Errorf("failed to update api key last used time: %w", err) + } + return nil +} diff --git a/src/module/auth/service.go b/src/module/auth/service.go new file mode 100644 index 00000000..3f04f90f --- /dev/null +++ b/src/module/auth/service.go @@ -0,0 +1,579 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "time" + + "aegis/consts" + "aegis/model" + user "aegis/module/user" + "aegis/utils" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +const accessKeySignatureTTL = 5 * time.Minute + +type Service struct { + userRepo *UserRepository + roleRepo *RoleRepository + apiKeyRepo *APIKeyRepository + tokenStore *TokenStore +} + +func NewService(userRepo *UserRepository, roleRepo *RoleRepository, apiKeyRepo *APIKeyRepository, tokenStore *TokenStore) *Service { + return &Service{ + userRepo: userRepo, + roleRepo: roleRepo, + apiKeyRepo: apiKeyRepo, + tokenStore: tokenStore, + } +} + +func (s *Service) Register(ctx context.Context, req *RegisterReq) (*UserInfo, error) { + if req == nil { + return nil, fmt.Errorf("register request is nil") + } + + var createdUser *model.User + err := s.userRepo.db.Transaction(func(tx *gorm.DB) error { + userRepo := NewUserRepository(tx) + + if _, err := userRepo.GetByUsername(req.Username); err == nil { + return fmt.Errorf("%w: username is already taken", consts.ErrAlreadyExists) + } + + if _, err := userRepo.GetByEmail(req.Email); err == nil { + return fmt.Errorf("%w: email is already registered", consts.ErrAlreadyExists) + } + + user := &model.User{ + Username: req.Username, + Email: req.Email, + Password: req.Password, + IsActive: true, + Status: consts.CommonEnabled, + } + + if err := userRepo.Create(user); err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + + createdUser = user + return nil + }) + if err != nil { + return nil, err + } + + return NewUserInfo(createdUser), nil +} + +func (s *Service) Login(ctx context.Context, req *LoginReq) (*LoginResp, error) { + if req == nil { + return nil, fmt.Errorf("login request is nil") + } + + var loginedUser *model.User + var token string + var expiresAt time.Time + + err := s.userRepo.db.Transaction(func(tx *gorm.DB) error { + userRepo := NewUserRepository(tx) + roleRepo := NewRoleRepository(tx) + + user, err := userRepo.GetByUsername(req.Username) + if err != nil { + return fmt.Errorf("%w: invalid username or password", consts.ErrAuthenticationFailed) + } + + if !utils.VerifyPassword(req.Password, user.Password) { + return fmt.Errorf("%w: invalid username or password", consts.ErrAuthenticationFailed) + } + + token, expiresAt, err = s.generateTokenWithRoles(roleRepo, user) + if err != nil { + return err + } + + if err := userRepo.UpdateLoginTime(user.ID); err != nil { + logrus.Errorf("failed to update last login time for user %d: %v", user.ID, err) + } + + loginedUser = user + return nil + }) + if err != nil { + return nil, err + } + + roles, err := s.roleRepo.ListByUserID(loginedUser.ID) + if err != nil { + return nil, fmt.Errorf("failed to get user role: %w", err) + } + + if len(roles) == 0 { + return nil, fmt.Errorf("%w: user has no assigned role", consts.ErrPermissionDenied) + } + + info := NewUserInfo(loginedUser) + info.Role = roles[0].Name + + return &LoginResp{ + Token: token, + ExpiresAt: expiresAt, + User: *info, + }, nil +} + +func (s *Service) RefreshToken(ctx context.Context, req *TokenRefreshReq) (*TokenRefreshResp, error) { + if req == nil { + return nil, fmt.Errorf("token refresh request is nil") + } + + refreshClaims, err := utils.ValidateToken(req.Token) + if err != nil { + return nil, fmt.Errorf("token refresh failed: %w", err) + } + + user, err := s.userRepo.GetByID(refreshClaims.UserID) + if err != nil { + return nil, fmt.Errorf("user not found: %w", err) + } + + newToken, expiresAt, err := s.generateTokenWithRoles(s.roleRepo, user) + if err != nil { + return nil, err + } + + return &TokenRefreshResp{ + Token: newToken, + ExpiresAt: expiresAt, + }, nil +} + +func (s *Service) Logout(ctx context.Context, claims *utils.Claims) error { + metaData := map[string]any{ + "user_id": claims.UserID, + "reason": "User logout", + } + if err := s.tokenStore.AddTokenToBlacklist(ctx, claims.ID, claims.ExpiresAt.Time, metaData); err != nil { + logrus.Errorf("failed to add token to blacklist: %v", err) + return fmt.Errorf("failed to blacklist token: %w", err) + } + return nil +} + +func (s *Service) VerifyToken(ctx context.Context, token string) (*utils.Claims, error) { + claims, err := utils.ValidateToken(token) + if err != nil { + return nil, err + } + + if s.tokenStore != nil { + blacklisted, err := s.tokenStore.IsTokenBlacklisted(ctx, claims.ID) + if err != nil { + return nil, err + } + if blacklisted { + return nil, fmt.Errorf("%w: token has been revoked", consts.ErrAuthenticationFailed) + } + } + + return claims, nil +} + +func (s *Service) VerifyServiceToken(ctx context.Context, token string) (*utils.ServiceClaims, error) { + return utils.ValidateServiceToken(token) +} + +func (s *Service) ChangePassword(ctx context.Context, req *ChangePasswordReq, userID int) error { + if req == nil { + return fmt.Errorf("change password request is nil") + } + + return s.userRepo.db.Transaction(func(tx *gorm.DB) error { + userRepo := NewUserRepository(tx) + + user, err := userRepo.GetByID(userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: user not found", consts.ErrNotFound) + } + return fmt.Errorf("failed to get user: %w", err) + } + + if !utils.VerifyPassword(req.OldPassword, user.Password) { + return fmt.Errorf("invalid old password") + } + + hashedPassword, err := utils.HashPassword(req.NewPassword) + if err != nil { + return fmt.Errorf("password hashing failed: %w", err) + } + user.Password = hashedPassword + + if err := userRepo.Update(user); err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + return nil + }) +} + +func (s *Service) GetProfile(ctx context.Context, userID int) (*UserProfileResp, error) { + user, err := s.userRepo.GetByID(userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: user not found", consts.ErrNotFound) + } + return nil, fmt.Errorf("failed to get user: %w", err) + } + + resp := NewUserProfileResp(user) + userContainers, userDatasets, userProjects, err := s.getAllUserResourceRoles(userID) + if err != nil { + return nil, fmt.Errorf("failed to get user resource roles: %w", err) + } + + resp.ContainerRoles = userContainers + resp.DatasetRoles = userDatasets + resp.ProjectRoles = userProjects + + return resp, nil +} + +func (s *Service) CreateAPIKey(ctx context.Context, userID int, req *CreateAPIKeyReq) (*APIKeyWithSecretResp, error) { + if req == nil { + return nil, fmt.Errorf("api key create request is nil") + } + normalizedScopes, err := normalizeAPIKeyScopes(req.Scopes) + if err != nil { + return nil, err + } + + accessKeyValue, err := generateCredentialValue("pk_", 16) + if err != nil { + return nil, fmt.Errorf("failed to generate api key id: %w", err) + } + secretKeyValue, err := generateCredentialValue("ks_", 24) + if err != nil { + return nil, fmt.Errorf("failed to generate key secret: %w", err) + } + secretHash, err := utils.HashPassword(secretKeyValue) + if err != nil { + return nil, fmt.Errorf("failed to hash key secret: %w", err) + } + secretCiphertext, err := utils.EncryptAPIKeySecret(secretKeyValue) + if err != nil { + return nil, fmt.Errorf("failed to encrypt key secret: %w", err) + } + + key := &model.APIKey{ + UserID: userID, + Name: req.Name, + Description: req.Description, + KeyID: accessKeyValue, + KeySecretHash: secretHash, + KeySecretCiphertext: secretCiphertext, + Scopes: normalizedScopes, + ExpiresAt: req.ExpiresAt, + Status: consts.CommonEnabled, + } + if err := s.apiKeyRepo.Create(key); err != nil { + return nil, err + } + + resp := &APIKeyWithSecretResp{ + APIKeyInfo: *NewAPIKeyInfo(key), + KeySecret: secretKeyValue, + } + return resp, nil +} + +func (s *Service) ListAPIKeys(ctx context.Context, userID int, req *ListAPIKeyReq) (*ListAPIKeyResp, error) { + if req == nil { + return nil, fmt.Errorf("api key list request is nil") + } + + limit, offset := req.ToGormParams() + keys, total, err := s.apiKeyRepo.ListByUserID(userID, limit, offset) + if err != nil { + return nil, err + } + + items := make([]APIKeyInfo, 0, len(keys)) + for i := range keys { + items = append(items, *NewAPIKeyInfo(&keys[i])) + } + + return &ListAPIKeyResp{ + Items: items, + Pagination: *req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) GetAPIKey(ctx context.Context, userID, accessKeyID int) (*APIKeyInfo, error) { + key, err := s.apiKeyRepo.GetByIDForUser(accessKeyID, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: api key not found", consts.ErrNotFound) + } + return nil, err + } + return NewAPIKeyInfo(key), nil +} + +func (s *Service) DeleteAPIKey(ctx context.Context, userID, accessKeyID int) error { + key, err := s.apiKeyRepo.GetByIDForUser(accessKeyID, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: api key not found", consts.ErrNotFound) + } + return err + } + + key.Status = consts.CommonDeleted + return s.apiKeyRepo.Update(key) +} + +func (s *Service) DisableAPIKey(ctx context.Context, userID, accessKeyID int) error { + return s.setAPIKeyStatus(userID, accessKeyID, consts.CommonDisabled) +} + +func (s *Service) EnableAPIKey(ctx context.Context, userID, accessKeyID int) error { + return s.setAPIKeyStatus(userID, accessKeyID, consts.CommonEnabled) +} + +func (s *Service) RevokeAPIKey(ctx context.Context, userID, accessKeyID int) error { + key, err := s.apiKeyRepo.GetByIDForUser(accessKeyID, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: api key not found", consts.ErrNotFound) + } + return err + } + if key.RevokedAt != nil { + return nil + } + + now := time.Now() + key.RevokedAt = &now + key.Status = consts.CommonDisabled + return s.apiKeyRepo.Update(key) +} + +func (s *Service) RotateAPIKey(ctx context.Context, userID, accessKeyID int) (*APIKeyWithSecretResp, error) { + key, err := s.apiKeyRepo.GetByIDForUser(accessKeyID, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: api key not found", consts.ErrNotFound) + } + return nil, err + } + if key.RevokedAt != nil { + return nil, fmt.Errorf("%w: revoked api key cannot be rotated", consts.ErrBadRequest) + } + + secretKeyValue, err := generateCredentialValue("ks_", 24) + if err != nil { + return nil, fmt.Errorf("failed to generate key secret: %w", err) + } + secretHash, err := utils.HashPassword(secretKeyValue) + if err != nil { + return nil, fmt.Errorf("failed to hash key secret: %w", err) + } + secretCiphertext, err := utils.EncryptAPIKeySecret(secretKeyValue) + if err != nil { + return nil, fmt.Errorf("failed to encrypt key secret: %w", err) + } + + key.KeySecretHash = secretHash + key.KeySecretCiphertext = secretCiphertext + if err := s.apiKeyRepo.Update(key); err != nil { + return nil, err + } + + return &APIKeyWithSecretResp{ + APIKeyInfo: *NewAPIKeyInfo(key), + KeySecret: secretKeyValue, + }, nil +} + +func (s *Service) ExchangeAPIKeyToken(ctx context.Context, req *APIKeyTokenReq, method, path string) (*APIKeyTokenResp, error) { + if req == nil { + return nil, fmt.Errorf("api key token request is nil") + } + + key, err := s.apiKeyRepo.GetByKeyID(req.KeyID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: invalid key id or key secret", consts.ErrAuthenticationFailed) + } + return nil, err + } + + if key.Status != consts.CommonEnabled { + return nil, fmt.Errorf("%w: api key is disabled", consts.ErrAuthenticationFailed) + } + if key.RevokedAt != nil { + return nil, fmt.Errorf("%w: api key is revoked", consts.ErrAuthenticationFailed) + } + if key.ExpiresAt != nil && key.ExpiresAt.Before(time.Now()) { + return nil, fmt.Errorf("%w: api key is expired", consts.ErrAuthenticationFailed) + } + timestampUnix, err := req.TimestampUnix() + if err != nil { + return nil, fmt.Errorf("%w: invalid request timestamp", consts.ErrAuthenticationFailed) + } + now := time.Now() + requestTime := time.Unix(timestampUnix, 0) + if requestTime.Before(now.Add(-accessKeySignatureTTL)) || requestTime.After(now.Add(accessKeySignatureTTL)) { + return nil, fmt.Errorf("%w: request timestamp is outside the allowed window", consts.ErrAuthenticationFailed) + } + + secretKey, err := utils.DecryptAPIKeySecret(key.KeySecretCiphertext) + if err != nil { + return nil, fmt.Errorf("failed to decrypt api key secret: %w", err) + } + if !utils.VerifyAPIKeyRequestSignature(secretKey, req.CanonicalString(method, path), req.Signature) { + return nil, fmt.Errorf("%w: invalid api key signature", consts.ErrAuthenticationFailed) + } + if err := s.tokenStore.ReserveAPIKeyNonce(ctx, key.KeyID, req.Nonce, accessKeySignatureTTL); err != nil { + return nil, err + } + + user, err := s.userRepo.GetByID(key.UserID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: api key owner not found", consts.ErrAuthenticationFailed) + } + return nil, err + } + if !user.IsActive || user.Status != consts.CommonEnabled { + return nil, fmt.Errorf("%w: api key owner is inactive", consts.ErrAuthenticationFailed) + } + + token, expiresAt, err := s.generateAPIKeyTokenWithRoles(s.roleRepo, user, key.ID, key.Scopes) + if err != nil { + return nil, err + } + + if err := s.apiKeyRepo.UpdateLastUsedAt(key.ID, time.Now()); err != nil { + logrus.WithError(err).Warn("failed to update api key last used time") + } + + return &APIKeyTokenResp{ + Token: token, + TokenType: "Bearer", + ExpiresAt: expiresAt, + AuthType: "api_key", + KeyID: key.KeyID, + }, nil +} + +func (s *Service) generateTokenWithRoles(roleRepo *RoleRepository, user *model.User) (string, time.Time, error) { + roles, err := roleRepo.ListByUserID(user.ID) + if err != nil { + return "", time.Time{}, fmt.Errorf("failed to get user roles: %w", err) + } + + isAdmin := false + roleNames := make([]string, 0, len(roles)) + for _, role := range roles { + roleNames = append(roleNames, role.Name) + if role.Name == string(consts.RoleSuperAdmin) || role.Name == string(consts.RoleAdmin) { + isAdmin = true + } + } + + token, expiresAt, err := utils.GenerateToken(user.ID, user.Username, user.Email, user.IsActive, isAdmin, roleNames) + if err != nil { + return "", time.Time{}, fmt.Errorf("failed to generate token: %w", err) + } + + return token, expiresAt, nil +} + +func (s *Service) generateAPIKeyTokenWithRoles(roleRepo *RoleRepository, user *model.User, apiKeyID int, apiKeyScopes []string) (string, time.Time, error) { + roles, err := roleRepo.ListByUserID(user.ID) + if err != nil { + return "", time.Time{}, fmt.Errorf("failed to get user roles: %w", err) + } + + isAdmin := false + roleNames := make([]string, 0, len(roles)) + for _, role := range roles { + roleNames = append(roleNames, role.Name) + if role.Name == string(consts.RoleSuperAdmin) || role.Name == string(consts.RoleAdmin) { + isAdmin = true + } + } + + token, expiresAt, err := utils.GenerateAPIKeyToken(user.ID, user.Username, user.Email, user.IsActive, isAdmin, roleNames, apiKeyID, apiKeyScopes) + if err != nil { + return "", time.Time{}, fmt.Errorf("failed to generate api key token: %w", err) + } + + return token, expiresAt, nil +} + +func (s *Service) getAllUserResourceRoles(userID int) ([]user.UserContainerInfo, []user.UserDatasetInfo, []user.UserProjectInfo, error) { + userContainers, err := s.userRepo.ListContainerRoles(userID) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to list user-container roles: %w", err) + } + containerRoles := make([]user.UserContainerInfo, 0, len(userContainers)) + for _, uc := range userContainers { + containerRoles = append(containerRoles, *user.NewUserContainerInfo(&uc)) + } + + userDatasets, err := s.userRepo.ListDatasetRoles(userID) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to list user-dataset roles: %w", err) + } + datasetRoles := make([]user.UserDatasetInfo, 0, len(userDatasets)) + for _, ud := range userDatasets { + datasetRoles = append(datasetRoles, *user.NewUserDatasetInfo(&ud)) + } + + userProjects, err := s.userRepo.ListProjectRoles(userID) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to list user-project roles: %w", err) + } + projectRoles := make([]user.UserProjectInfo, 0, len(userProjects)) + for _, up := range userProjects { + projectRoles = append(projectRoles, *user.NewUserProjectInfo(&up)) + } + + return containerRoles, datasetRoles, projectRoles, nil +} + +func (s *Service) setAPIKeyStatus(userID, accessKeyID int, status consts.StatusType) error { + key, err := s.apiKeyRepo.GetByIDForUser(accessKeyID, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: api key not found", consts.ErrNotFound) + } + return err + } + if status == consts.CommonEnabled && key.RevokedAt != nil { + return fmt.Errorf("%w: revoked api key cannot be re-enabled", consts.ErrBadRequest) + } + + key.Status = status + return s.apiKeyRepo.Update(key) +} + +func generateCredentialValue(prefix string, randomBytes int) (string, error) { + buf := make([]byte, randomBytes) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return prefix + hex.EncodeToString(buf), nil +} diff --git a/src/module/auth/service_test.go b/src/module/auth/service_test.go new file mode 100644 index 00000000..88cd6f0e --- /dev/null +++ b/src/module/auth/service_test.go @@ -0,0 +1,264 @@ +package auth + +import ( + "database/sql/driver" + "fmt" + "regexp" + "testing" + "time" + + "aegis/consts" + "aegis/utils" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +type passwordHashMatcher struct { + plain string +} + +func (m passwordHashMatcher) Match(v driver.Value) bool { + hash, ok := v.(string) + if !ok { + return false + } + return utils.VerifyPassword(m.plain, hash) +} + +func newAuthService(t *testing.T) (*Service, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New() + require.NoError(t, err) + + db, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + require.NoError(t, err) + + service := NewService(NewUserRepository(db), NewRoleRepository(db), NewAPIKeyRepository(db), &TokenStore{}) + return service, mock, func() { + _ = sqlDB.Close() + } +} + +func TestAuthServiceRegisterSuccess(t *testing.T) { + service, mock, cleanup := newAuthService(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `users` WHERE username = ? ORDER BY `users`.`id` LIMIT ?")). + WithArgs("new_user", 1). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `users` WHERE email = ? ORDER BY `users`.`id` LIMIT ?")). + WithArgs("new@example.com", 1). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `users` (`username`,`email`,`password`,`full_name`,`avatar`,`phone`,`last_login_at`,`is_active`,`status`,`created_at`,`updated_at`) VALUES (?,?,?,?,?,?,?,?,?,?,?)")). + WithArgs("new_user", "new@example.com", passwordHashMatcher{plain: "password123"}, "", "", "", nil, true, consts.CommonEnabled, sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(9, 1)) + mock.ExpectCommit() + + resp, err := service.Register(t.Context(), &RegisterReq{ + Username: "new_user", + Email: "new@example.com", + Password: "password123", + }) + + require.NoError(t, err) + require.Equal(t, "new_user", resp.Username) + require.Equal(t, 9, resp.ID) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestAuthServiceLoginSuccess(t *testing.T) { + service, mock, cleanup := newAuthService(t) + defer cleanup() + + now := time.Now() + hashedPassword, err := utils.HashPassword("password123") + require.NoError(t, err) + + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `users` WHERE username = ? ORDER BY `users`.`id` LIMIT ?")). + WithArgs("demo_user", 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "username", "email", "password", "full_name", "avatar", "phone", "last_login_at", + "is_active", "status", "created_at", "updated_at", + }).AddRow(7, "demo_user", "demo@example.com", hashedPassword, "Demo User", "", "", nil, true, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT `roles`.`id`,`roles`.`name`,`roles`.`display_name`,`roles`.`description`,`roles`.`is_system`,`roles`.`status`,`roles`.`created_at`,`roles`.`updated_at`,`roles`.`active_name` FROM `roles` JOIN user_roles ur ON ur.role_id = roles.id WHERE ur.user_id = ? AND roles.status = ?")). + WithArgs(7, consts.CommonEnabled). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "display_name", "description", "is_system", "status", "created_at", "updated_at", "active_name", + }).AddRow(1, consts.RoleAdmin.String(), "Admin", "", true, consts.CommonEnabled, now, now, consts.RoleAdmin.String())) + mock.ExpectExec(regexp.QuoteMeta("UPDATE `users` SET `last_login_at`=?,`updated_at`=? WHERE id = ? AND status != ?")). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), 7, consts.CommonDeleted). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + mock.ExpectQuery(regexp.QuoteMeta("SELECT `roles`.`id`,`roles`.`name`,`roles`.`display_name`,`roles`.`description`,`roles`.`is_system`,`roles`.`status`,`roles`.`created_at`,`roles`.`updated_at`,`roles`.`active_name` FROM `roles` JOIN user_roles ur ON ur.role_id = roles.id WHERE ur.user_id = ? AND roles.status = ?")). + WithArgs(7, consts.CommonEnabled). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "display_name", "description", "is_system", "status", "created_at", "updated_at", "active_name", + }).AddRow(1, consts.RoleAdmin.String(), "Admin", "", true, consts.CommonEnabled, now, now, consts.RoleAdmin.String())) + + resp, err := service.Login(t.Context(), &LoginReq{ + Username: "demo_user", + Password: "password123", + }) + + require.NoError(t, err) + require.Equal(t, "demo_user", resp.User.Username) + require.Equal(t, consts.RoleAdmin.String(), resp.User.Role) + + claims, err := utils.ValidateToken(resp.Token) + require.NoError(t, err) + require.Equal(t, 7, claims.UserID) + require.True(t, claims.IsAdmin) + require.Contains(t, claims.Roles, consts.RoleAdmin.String()) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestAuthServiceRefreshTokenSuccess(t *testing.T) { + service, mock, cleanup := newAuthService(t) + defer cleanup() + + now := time.Now() + token, _, err := utils.GenerateToken(7, "demo_user", "demo@example.com", true, false, []string{consts.RoleUser.String()}) + require.NoError(t, err) + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `users` WHERE id = ? ORDER BY `users`.`id` LIMIT ?")). + WithArgs(7, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "username", "email", "password", "full_name", "avatar", "phone", "last_login_at", + "is_active", "status", "created_at", "updated_at", + }).AddRow(7, "demo_user", "demo@example.com", "ignored", "Demo User", "", "", nil, true, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT `roles`.`id`,`roles`.`name`,`roles`.`display_name`,`roles`.`description`,`roles`.`is_system`,`roles`.`status`,`roles`.`created_at`,`roles`.`updated_at`,`roles`.`active_name` FROM `roles` JOIN user_roles ur ON ur.role_id = roles.id WHERE ur.user_id = ? AND roles.status = ?")). + WithArgs(7, consts.CommonEnabled). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "display_name", "description", "is_system", "status", "created_at", "updated_at", "active_name", + }).AddRow(2, consts.RoleUser.String(), "User", "", true, consts.CommonEnabled, now, now, consts.RoleUser.String())) + + resp, err := service.RefreshToken(t.Context(), &TokenRefreshReq{Token: token}) + + require.NoError(t, err) + require.NotEmpty(t, resp.Token) + + claims, err := utils.ValidateToken(resp.Token) + require.NoError(t, err) + require.Equal(t, 7, claims.UserID) + require.Contains(t, claims.Roles, consts.RoleUser.String()) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestAuthServiceCreateAPIKeySuccess(t *testing.T) { + service, mock, cleanup := newAuthService(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `api_keys` (`user_id`,`name`,`description`,`key_id`,`key_secret_hash`,`key_secret_ciphertext`,`scopes`,`revoked_at`,`last_used_at`,`expires_at`,`status`,`created_at`,`updated_at`,`active_key_id`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)")). + WithArgs(7, "ci-bot", "SDK credential", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), nil, nil, nil, consts.CommonEnabled, sqlmock.AnyArg(), sqlmock.AnyArg(), ""). + WillReturnResult(sqlmock.NewResult(11, 1)) + mock.ExpectCommit() + + resp, err := service.CreateAPIKey(t.Context(), 7, &CreateAPIKeyReq{ + Name: "ci-bot", + Description: "SDK credential", + }) + + require.NoError(t, err) + require.Equal(t, 11, resp.ID) + require.Equal(t, "ci-bot", resp.Name) + require.NotEmpty(t, resp.KeyID) + require.NotEmpty(t, resp.KeySecret) + require.Equal(t, []string{"*"}, resp.Scopes) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestAuthServiceExchangeAPIKeyTokenSuccess(t *testing.T) { + service, mock, cleanup := newAuthService(t) + defer cleanup() + + now := time.Now() + secret := "ks_test_secret_123456" + secretHash, err := utils.HashPassword(secret) + require.NoError(t, err) + secretCiphertext, err := utils.EncryptAPIKeySecret(secret) + require.NoError(t, err) + req := &APIKeyTokenReq{ + KeyID: "pk_test_credential", + Timestamp: fmt.Sprintf("%d", now.Unix()), + Nonce: "nonce_123", + } + req.Signature = utils.SignAPIKeyRequest(secret, req.CanonicalString("POST", "/api/v2/auth/api-key/token")) + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `api_keys` WHERE key_id = ? AND status != ? ORDER BY `api_keys`.`id` LIMIT ?")). + WithArgs("pk_test_credential", consts.CommonDeleted, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "user_id", "name", "description", "key_id", "key_secret_hash", "key_secret_ciphertext", "scopes", "revoked_at", "last_used_at", "expires_at", "status", "created_at", "updated_at", + }).AddRow(5, 7, "ci-bot", "SDK credential", "pk_test_credential", secretHash, secretCiphertext, []byte(`["*"]`), nil, nil, nil, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `users` WHERE id = ? ORDER BY `users`.`id` LIMIT ?")). + WithArgs(7, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "username", "email", "password", "full_name", "avatar", "phone", "last_login_at", + "is_active", "status", "created_at", "updated_at", + }).AddRow(7, "demo_user", "demo@example.com", "ignored", "Demo User", "", "", nil, true, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT `roles`.`id`,`roles`.`name`,`roles`.`display_name`,`roles`.`description`,`roles`.`is_system`,`roles`.`status`,`roles`.`created_at`,`roles`.`updated_at`,`roles`.`active_name` FROM `roles` JOIN user_roles ur ON ur.role_id = roles.id WHERE ur.user_id = ? AND roles.status = ?")). + WithArgs(7, consts.CommonEnabled). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "display_name", "description", "is_system", "status", "created_at", "updated_at", "active_name", + }).AddRow(2, consts.RoleUser.String(), "User", "", true, consts.CommonEnabled, now, now, consts.RoleUser.String())) + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("UPDATE `api_keys` SET `last_used_at`=?,`updated_at`=? WHERE id = ?")). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), 5). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + resp, err := service.ExchangeAPIKeyToken(t.Context(), req, "POST", "/api/v2/auth/api-key/token") + + require.NoError(t, err) + require.Equal(t, "Bearer", resp.TokenType) + require.Equal(t, "api_key", resp.AuthType) + + claims, err := utils.ValidateToken(resp.Token) + require.NoError(t, err) + require.Equal(t, 7, claims.UserID) + require.Equal(t, "api_key", claims.AuthType) + require.Equal(t, 5, claims.APIKeyID) + require.Equal(t, []string{"*"}, claims.APIKeyScopes) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestAuthServiceExchangeAPIKeyTokenRevoked(t *testing.T) { + service, mock, cleanup := newAuthService(t) + defer cleanup() + + now := time.Now() + revokedAt := now.Add(-time.Minute) + secret := "ks_test_secret_123456" + secretHash, err := utils.HashPassword(secret) + require.NoError(t, err) + secretCiphertext, err := utils.EncryptAPIKeySecret(secret) + require.NoError(t, err) + req := &APIKeyTokenReq{ + KeyID: "pk_test_credential", + Timestamp: fmt.Sprintf("%d", now.Unix()), + Nonce: "nonce_123", + } + req.Signature = utils.SignAPIKeyRequest(secret, req.CanonicalString("POST", "/api/v2/auth/api-key/token")) + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `api_keys` WHERE key_id = ? AND status != ? ORDER BY `api_keys`.`id` LIMIT ?")). + WithArgs("pk_test_credential", consts.CommonDeleted, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "user_id", "name", "description", "key_id", "key_secret_hash", "key_secret_ciphertext", "scopes", "revoked_at", "last_used_at", "expires_at", "status", "created_at", "updated_at", + }).AddRow(5, 7, "ci-bot", "SDK credential", "pk_test_credential", secretHash, secretCiphertext, []byte(`["*"]`), revokedAt, nil, nil, consts.CommonEnabled, now, now)) + + resp, err := service.ExchangeAPIKeyToken(t.Context(), req, "POST", "/api/v2/auth/api-key/token") + + require.Nil(t, resp) + require.Error(t, err) + require.ErrorContains(t, err, "api key is revoked") + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/src/module/auth/token_store.go b/src/module/auth/token_store.go new file mode 100644 index 00000000..bee97d12 --- /dev/null +++ b/src/module/auth/token_store.go @@ -0,0 +1,71 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "aegis/consts" + redis "aegis/infra/redis" +) + +const tokenBlacklistPrefix = "blacklist:token:%s" +const apiKeyNoncePrefix = "api_key:nonce:%s:%s" + +type TokenStore struct { + redis *redis.Gateway +} + +func NewTokenStore(redis *redis.Gateway) *TokenStore { + return &TokenStore{redis: redis} +} + +func (s *TokenStore) AddTokenToBlacklist(ctx context.Context, tokenID string, expiresAt time.Time, metaData map[string]any) error { + key := fmt.Sprintf(tokenBlacklistPrefix, tokenID) + + ttl := time.Until(expiresAt) + if ttl <= 0 { + return nil + } + + metaDataJSON, err := json.Marshal(metaData) + if err != nil { + return fmt.Errorf("failed to marshal metadata to JSON: %w", err) + } + + if err = s.redis.Set(ctx, key, string(metaDataJSON), ttl); err != nil { + return fmt.Errorf("failed to blacklist token in Redis: %w", err) + } + + return nil +} + +func (s *TokenStore) ReserveAPIKeyNonce(ctx context.Context, keyID, nonce string, ttl time.Duration) error { + if s == nil || s.redis == nil { + return nil + } + + key := fmt.Sprintf(apiKeyNoncePrefix, keyID, nonce) + ok, err := s.redis.SetNX(ctx, key, "1", ttl) + if err != nil { + return fmt.Errorf("failed to reserve api key nonce: %w", err) + } + if !ok { + return fmt.Errorf("%w: request nonce has already been used", consts.ErrAuthenticationFailed) + } + return nil +} + +func (s *TokenStore) IsTokenBlacklisted(ctx context.Context, tokenID string) (bool, error) { + if s == nil || s.redis == nil || tokenID == "" { + return false, nil + } + + key := fmt.Sprintf(tokenBlacklistPrefix, tokenID) + exists, err := s.redis.Exists(ctx, key) + if err != nil { + return false, fmt.Errorf("failed to check blacklisted token: %w", err) + } + return exists, nil +} diff --git a/src/dto/chaos_system.go b/src/module/chaossystem/api_types.go similarity index 84% rename from src/dto/chaos_system.go rename to src/module/chaossystem/api_types.go index 74fc4d94..c809b82f 100644 --- a/src/dto/chaos_system.go +++ b/src/module/chaossystem/api_types.go @@ -1,12 +1,14 @@ -package dto +package chaossystem import ( - "aegis/database" "encoding/json" "time" + + "aegis/dto" + "aegis/model" ) -// CreateChaosSystemReq represents the request to create a new chaos system +// CreateChaosSystemReq represents the request to create a new chaos system. type CreateChaosSystemReq struct { Name string `json:"name" binding:"required"` DisplayName string `json:"display_name" binding:"required"` @@ -16,7 +18,7 @@ type CreateChaosSystemReq struct { Description string `json:"description"` } -// UpdateChaosSystemReq represents the request to update a chaos system +// UpdateChaosSystemReq represents the request to update a chaos system. type UpdateChaosSystemReq struct { DisplayName *string `json:"display_name"` NsPattern *string `json:"ns_pattern"` @@ -25,7 +27,7 @@ type UpdateChaosSystemReq struct { Description *string `json:"description"` } -// ChaosSystemResp represents a chaos system in API responses +// ChaosSystemResp represents a chaos system in API responses. type ChaosSystemResp struct { ID int `json:"id"` Name string `json:"name"` @@ -39,13 +41,13 @@ type ChaosSystemResp struct { UpdatedAt time.Time `json:"updated_at"` } -// ListChaosSystemReq represents the request to list chaos systems +// ListChaosSystemReq represents the request to list chaos systems. type ListChaosSystemReq struct { - PaginationReq + dto.PaginationReq } -// NewChaosSystemResp creates a ChaosSystemResp from a database System -func NewChaosSystemResp(s *database.System) *ChaosSystemResp { +// NewChaosSystemResp creates a ChaosSystemResp from a system model. +func NewChaosSystemResp(s *model.System) *ChaosSystemResp { return &ChaosSystemResp{ ID: s.ID, Name: s.Name, @@ -60,19 +62,19 @@ func NewChaosSystemResp(s *database.System) *ChaosSystemResp { } } -// UpsertSystemMetadataReq represents a single metadata upsert request +// UpsertSystemMetadataReq represents a single metadata upsert request. type UpsertSystemMetadataReq struct { - MetadataType string `json:"metadata_type" binding:"required"` // "service_endpoint", "java_class_method", "database_operation", "grpc_operation", "network_dependency" + MetadataType string `json:"metadata_type" binding:"required"` ServiceName string `json:"service_name" binding:"required"` Data json.RawMessage `json:"data" binding:"required"` } -// BulkUpsertSystemMetadataReq represents a bulk metadata upsert request +// BulkUpsertSystemMetadataReq represents a bulk metadata upsert request. type BulkUpsertSystemMetadataReq struct { Items []UpsertSystemMetadataReq `json:"items" binding:"required,dive"` } -// SystemMetadataResp represents system metadata in API responses +// SystemMetadataResp represents system metadata in API responses. type SystemMetadataResp struct { ID int `json:"id"` SystemName string `json:"system_name"` @@ -83,8 +85,8 @@ type SystemMetadataResp struct { UpdatedAt time.Time `json:"updated_at"` } -// NewSystemMetadataResp creates a SystemMetadataResp from a database SystemMetadata -func NewSystemMetadataResp(m *database.SystemMetadata) *SystemMetadataResp { +// NewSystemMetadataResp creates a SystemMetadataResp from a metadata model. +func NewSystemMetadataResp(m *model.SystemMetadata) *SystemMetadataResp { return &SystemMetadataResp{ ID: m.ID, SystemName: m.SystemName, diff --git a/src/handlers/v2/systems.go b/src/module/chaossystem/handler.go similarity index 57% rename from src/handlers/v2/systems.go rename to src/module/chaossystem/handler.go index 3e4f42b3..7b55a95a 100644 --- a/src/handlers/v2/systems.go +++ b/src/module/chaossystem/handler.go @@ -1,16 +1,23 @@ -package v2 +package chaossystem import ( + "aegis/httpx" "net/http" "aegis/consts" "aegis/dto" - "aegis/handlers" - producer "aegis/service/producer" "github.com/gin-gonic/gin" ) +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + // ListChaosSystemsHandler handles listing chaos systems with pagination // // @Summary List chaos systems @@ -21,28 +28,26 @@ import ( // @Security BearerAuth // @Param page query int false "Page number" default(1) // @Param size query int false "Page size" default(20) -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.ChaosSystemResp]] "Systems retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[ChaosSystemResp]] "Systems retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/systems [get] -func ListChaosSystemsHandler(c *gin.Context) { - var req dto.ListChaosSystemReq +// @x-api-type {"admin":"true"} +func (h *Handler) ListSystems(c *gin.Context) { + var req ListChaosSystemReq if err := c.ShouldBindQuery(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - resp, err := producer.ListChaosSystemsService(&req) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ListSystems(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { return } - dto.SuccessResponse(c, resp) } @@ -55,22 +60,21 @@ func ListChaosSystemsHandler(c *gin.Context) { // @Produce json // @Security BearerAuth // @Param id path int true "System ID" -// @Success 200 {object} dto.GenericResponse[dto.ChaosSystemResp] "System retrieved successfully" +// @Success 200 {object} dto.GenericResponse[ChaosSystemResp] "System retrieved successfully" // @Failure 400 {object} dto.GenericResponse[any] "Invalid system ID" // @Failure 404 {object} dto.GenericResponse[any] "System not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/systems/{id} [get] -func GetChaosSystemHandler(c *gin.Context) { - id, ok := handlers.ParsePositiveID(c, c.Param(consts.URLPathID), "system ID") +// @x-api-type {"admin":"true"} +func (h *Handler) GetSystem(c *gin.Context) { + id, ok := httpx.ParsePositiveID(c, c.Param(consts.URLPathID), "system ID") if !ok { return } - - resp, err := producer.GetChaosSystemService(id) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.GetSystem(c.Request.Context(), id) + if httpx.HandleServiceError(c, err) { return } - dto.SuccessResponse(c, resp) } @@ -83,25 +87,24 @@ func GetChaosSystemHandler(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param request body dto.CreateChaosSystemReq true "System creation request" -// @Success 201 {object} dto.GenericResponse[dto.ChaosSystemResp] "System created successfully" +// @Param request body CreateChaosSystemReq true "System creation request" +// @Success 201 {object} dto.GenericResponse[ChaosSystemResp] "System created successfully" // @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" // @Failure 401 {object} dto.GenericResponse[any] "Authentication required" // @Failure 409 {object} dto.GenericResponse[any] "System already exists" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/systems [post] -func CreateChaosSystemHandler(c *gin.Context) { - var req dto.CreateChaosSystemReq +// @x-api-type {"admin":"true"} +func (h *Handler) CreateSystem(c *gin.Context) { + var req CreateChaosSystemReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - - resp, err := producer.CreateChaosSystemService(&req) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.CreateSystem(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { return } - dto.JSONResponse(c, http.StatusCreated, "System created successfully", resp) } @@ -115,29 +118,27 @@ func CreateChaosSystemHandler(c *gin.Context) { // @Produce json // @Security BearerAuth // @Param id path int true "System ID" -// @Param request body dto.UpdateChaosSystemReq true "System update request" -// @Success 200 {object} dto.GenericResponse[dto.ChaosSystemResp] "System updated successfully" +// @Param request body UpdateChaosSystemReq true "System update request" +// @Success 200 {object} dto.GenericResponse[ChaosSystemResp] "System updated successfully" // @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" // @Failure 404 {object} dto.GenericResponse[any] "System not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/systems/{id} [put] -func UpdateChaosSystemHandler(c *gin.Context) { - id, ok := handlers.ParsePositiveID(c, c.Param(consts.URLPathID), "system ID") +// @x-api-type {"admin":"true"} +func (h *Handler) UpdateSystem(c *gin.Context) { + id, ok := httpx.ParsePositiveID(c, c.Param(consts.URLPathID), "system ID") if !ok { return } - - var req dto.UpdateChaosSystemReq + var req UpdateChaosSystemReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - - resp, err := producer.UpdateChaosSystemService(id, &req) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.UpdateSystem(c.Request.Context(), id, &req) + if httpx.HandleServiceError(c, err) { return } - dto.SuccessResponse(c, resp) } @@ -155,17 +156,15 @@ func UpdateChaosSystemHandler(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "System not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/systems/{id} [delete] -func DeleteChaosSystemHandler(c *gin.Context) { - id, ok := handlers.ParsePositiveID(c, c.Param(consts.URLPathID), "system ID") +// @x-api-type {"admin":"true"} +func (h *Handler) DeleteSystem(c *gin.Context) { + id, ok := httpx.ParsePositiveID(c, c.Param(consts.URLPathID), "system ID") if !ok { return } - - err := producer.DeleteChaosSystemService(id) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.DeleteSystem(c.Request.Context(), id)) { return } - dto.JSONResponse[any](c, http.StatusOK, "System deleted successfully", nil) } @@ -178,30 +177,27 @@ func DeleteChaosSystemHandler(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param id path int true "System ID" -// @Param request body dto.BulkUpsertSystemMetadataReq true "Metadata upsert request" -// @Success 200 {object} dto.GenericResponse[any] "Metadata upserted successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 404 {object} dto.GenericResponse[any] "System not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param id path int true "System ID" +// @Param request body BulkUpsertSystemMetadataReq true "Metadata upsert request" +// @Success 200 {object} dto.GenericResponse[any] "Metadata upserted successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 404 {object} dto.GenericResponse[any] "System not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/systems/{id}/metadata [post] -func UpsertChaosSystemMetadataHandler(c *gin.Context) { - id, ok := handlers.ParsePositiveID(c, c.Param(consts.URLPathID), "system ID") +// @x-api-type {"admin":"true"} +func (h *Handler) UpsertMetadata(c *gin.Context) { + id, ok := httpx.ParsePositiveID(c, c.Param(consts.URLPathID), "system ID") if !ok { return } - - var req dto.BulkUpsertSystemMetadataReq + var req BulkUpsertSystemMetadataReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - - err := producer.UpsertChaosSystemMetadataService(id, &req) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.UpsertMetadata(c.Request.Context(), id, &req)) { return } - dto.JSONResponse[any](c, http.StatusOK, "Metadata upserted successfully", nil) } @@ -213,25 +209,22 @@ func UpsertChaosSystemMetadataHandler(c *gin.Context) { // @ID list_chaos_system_metadata // @Produce json // @Security BearerAuth -// @Param id path int true "System ID" -// @Param type query string false "Metadata type filter" -// @Success 200 {object} dto.GenericResponse[[]dto.SystemMetadataResp] "Metadata retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid system ID" -// @Failure 404 {object} dto.GenericResponse[any] "System not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param id path int true "System ID" +// @Param type query string false "Metadata type filter" +// @Success 200 {object} dto.GenericResponse[[]SystemMetadataResp] "Metadata retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid system ID" +// @Failure 404 {object} dto.GenericResponse[any] "System not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/systems/{id}/metadata [get] -func ListChaosSystemMetadataHandler(c *gin.Context) { - id, ok := handlers.ParsePositiveID(c, c.Param(consts.URLPathID), "system ID") +// @x-api-type {"admin":"true"} +func (h *Handler) ListMetadata(c *gin.Context) { + id, ok := httpx.ParsePositiveID(c, c.Param(consts.URLPathID), "system ID") if !ok { return } - - metadataType := c.Query("type") - - resp, err := producer.ListChaosSystemMetadataService(id, metadataType) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ListMetadata(c.Request.Context(), id, c.Query("type")) + if httpx.HandleServiceError(c, err) { return } - dto.SuccessResponse(c, resp) } diff --git a/src/module/chaossystem/handler_service.go b/src/module/chaossystem/handler_service.go new file mode 100644 index 00000000..d1b8b911 --- /dev/null +++ b/src/module/chaossystem/handler_service.go @@ -0,0 +1,22 @@ +package chaossystem + +import ( + "context" + + "aegis/dto" +) + +// HandlerService captures chaos system operations consumed by HTTP and resource gRPC handlers. +type HandlerService interface { + ListSystems(context.Context, *ListChaosSystemReq) (*dto.ListResp[ChaosSystemResp], error) + GetSystem(context.Context, int) (*ChaosSystemResp, error) + CreateSystem(context.Context, *CreateChaosSystemReq) (*ChaosSystemResp, error) + UpdateSystem(context.Context, int, *UpdateChaosSystemReq) (*ChaosSystemResp, error) + DeleteSystem(context.Context, int) error + UpsertMetadata(context.Context, int, *BulkUpsertSystemMetadataReq) error + ListMetadata(context.Context, int, string) ([]SystemMetadataResp, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/chaossystem/module.go b/src/module/chaossystem/module.go new file mode 100644 index 00000000..cb9e440c --- /dev/null +++ b/src/module/chaossystem/module.go @@ -0,0 +1,10 @@ +package chaossystem + +import "go.uber.org/fx" + +var Module = fx.Module("chaos_system", + fx.Provide(NewRepository), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/module/chaossystem/repository.go b/src/module/chaossystem/repository.go new file mode 100644 index 00000000..de1e2518 --- /dev/null +++ b/src/module/chaossystem/repository.go @@ -0,0 +1,105 @@ +package chaossystem + +import ( + "aegis/consts" + "aegis/model" + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) ListSystems(limit, offset int) ([]model.System, int64, error) { + var ( + systems []model.System + total int64 + ) + + query := r.db.Model(&model.System{}).Where("status != ?", consts.CommonDeleted) + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count systems: %w", err) + } + if err := query.Limit(limit).Offset(offset).Order("updated_at DESC").Find(&systems).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list systems: %w", err) + } + return systems, total, nil +} + +func (r *Repository) GetSystemByID(id int) (*model.System, error) { + var system model.System + if err := r.db.Where("id = ? AND status != ?", id, consts.CommonDeleted).First(&system).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("system with id %d: %w", id, consts.ErrNotFound) + } + return nil, fmt.Errorf("failed to find system with id %d: %w", id, err) + } + return &system, nil +} + +func (r *Repository) CreateSystem(system *model.System) error { + if err := r.db.Create(system).Error; err != nil { + return fmt.Errorf("failed to create system: %w", err) + } + return nil +} + +func (r *Repository) UpdateSystem(id int, updates map[string]interface{}) error { + result := r.db.Model(&model.System{}). + Where("id = ? AND status != ?", id, consts.CommonDeleted). + Updates(updates) + if err := result.Error; err != nil { + return fmt.Errorf("failed to update system with id %d: %w", id, err) + } + if result.RowsAffected == 0 { + return fmt.Errorf("system with id %d: %w", id, consts.ErrNotFound) + } + return nil +} + +func (r *Repository) DeleteSystem(id int) error { + result := r.db.Model(&model.System{}). + Where("id = ? AND status != ?", id, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if err := result.Error; err != nil { + return fmt.Errorf("failed to delete system with id %d: %w", id, err) + } + if result.RowsAffected == 0 { + return fmt.Errorf("system with id %d: %w", id, consts.ErrNotFound) + } + return nil +} + +func (r *Repository) UpsertSystemMetadata(meta *model.SystemMetadata) error { + if err := r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "system_name"}, {Name: "metadata_type"}, {Name: "service_name"}}, + DoUpdates: clause.AssignmentColumns([]string{"data", "updated_at"}), + }).Create(meta).Error; err != nil { + var existing model.SystemMetadata + if findErr := r.db.Where("system_name = ? AND metadata_type = ? AND service_name = ?", + meta.SystemName, meta.MetadataType, meta.ServiceName).First(&existing).Error; findErr == nil { + return r.db.Model(&existing).Updates(map[string]any{"data": meta.Data}).Error + } + return fmt.Errorf("failed to upsert system metadata: %w", err) + } + return nil +} + +func (r *Repository) ListSystemMetadata(systemName, metadataType string) ([]model.SystemMetadata, error) { + var metas []model.SystemMetadata + query := r.db.Where("system_name = ?", systemName) + if metadataType != "" { + query = query.Where("metadata_type = ?", metadataType) + } + if err := query.Find(&metas).Error; err != nil { + return nil, fmt.Errorf("failed to list system metadata: %w", err) + } + return metas, nil +} diff --git a/src/service/producer/chaos_system.go b/src/module/chaossystem/service.go similarity index 54% rename from src/service/producer/chaos_system.go rename to src/module/chaossystem/service.go index 1c45bcbe..394e000c 100644 --- a/src/service/producer/chaos_system.go +++ b/src/module/chaossystem/service.go @@ -1,50 +1,53 @@ -package producer +package chaossystem import ( + "context" "fmt" "regexp" "aegis/consts" - "aegis/database" "aegis/dto" - "aegis/repository" + "aegis/model" chaos "github.com/OperationsPAI/chaos-experiment/handler" "github.com/sirupsen/logrus" ) -// ListChaosSystemsService lists chaos systems with pagination -func ListChaosSystemsService(req *dto.ListChaosSystemReq) (*dto.ListResp[dto.ChaosSystemResp], error) { - limit, offset := req.ToGormParams() +type Service struct { + repo *Repository +} - systems, total, err := repository.ListSystems(database.DB, limit, offset) +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) ListSystems(_ context.Context, req *ListChaosSystemReq) (*dto.ListResp[ChaosSystemResp], error) { + limit, offset := req.ToGormParams() + systems, total, err := s.repo.ListSystems(limit, offset) if err != nil { return nil, fmt.Errorf("failed to list systems: %w", err) } - items := make([]dto.ChaosSystemResp, 0, len(systems)) - for _, s := range systems { - items = append(items, *dto.NewChaosSystemResp(&s)) + items := make([]ChaosSystemResp, 0, len(systems)) + for _, item := range systems { + items = append(items, *NewChaosSystemResp(&item)) } - return &dto.ListResp[dto.ChaosSystemResp]{ + return &dto.ListResp[ChaosSystemResp]{ Items: items, Pagination: req.ConvertToPaginationInfo(total), }, nil } -// GetChaosSystemService retrieves a single chaos system by ID -func GetChaosSystemService(id int) (*dto.ChaosSystemResp, error) { - system, err := repository.GetSystemByID(database.DB, id) +func (s *Service) GetSystem(_ context.Context, id int) (*ChaosSystemResp, error) { + system, err := s.repo.GetSystemByID(id) if err != nil { return nil, err } - return dto.NewChaosSystemResp(system), nil + return NewChaosSystemResp(system), nil } -// CreateChaosSystemService creates a new chaos system and registers it with chaos-experiment -func CreateChaosSystemService(req *dto.CreateChaosSystemReq) (*dto.ChaosSystemResp, error) { - // Validate regex patterns +func (s *Service) CreateSystem(_ context.Context, req *CreateChaosSystemReq) (*ChaosSystemResp, error) { if _, err := regexp.Compile(req.NsPattern); err != nil { return nil, fmt.Errorf("invalid ns_pattern regex: %w: %w", err, consts.ErrBadRequest) } @@ -52,7 +55,7 @@ func CreateChaosSystemService(req *dto.CreateChaosSystemReq) (*dto.ChaosSystemRe return nil, fmt.Errorf("invalid extract_pattern regex: %w: %w", err, consts.ErrBadRequest) } - system := &database.System{ + system := &model.System{ Name: req.Name, DisplayName: req.DisplayName, NsPattern: req.NsPattern, @@ -63,11 +66,9 @@ func CreateChaosSystemService(req *dto.CreateChaosSystemReq) (*dto.ChaosSystemRe Status: consts.CommonEnabled, } - if err := repository.CreateSystem(database.DB, system); err != nil { + if err := s.repo.CreateSystem(system); err != nil { return nil, fmt.Errorf("failed to create system: %w", err) } - - // Register with chaos-experiment if err := chaos.RegisterSystem(chaos.SystemConfig{ Name: system.Name, NsPattern: system.NsPattern, @@ -76,18 +77,16 @@ func CreateChaosSystemService(req *dto.CreateChaosSystemReq) (*dto.ChaosSystemRe logrus.WithError(err).Warnf("Failed to register system %s with chaos-experiment", system.Name) } - return dto.NewChaosSystemResp(system), nil + return NewChaosSystemResp(system), nil } -// UpdateChaosSystemService updates a chaos system and re-registers it -func UpdateChaosSystemService(id int, req *dto.UpdateChaosSystemReq) (*dto.ChaosSystemResp, error) { - system, err := repository.GetSystemByID(database.DB, id) +func (s *Service) UpdateSystem(_ context.Context, id int, req *UpdateChaosSystemReq) (*ChaosSystemResp, error) { + system, err := s.repo.GetSystemByID(id) if err != nil { return nil, err } updates := make(map[string]interface{}) - if req.DisplayName != nil { updates["display_name"] = *req.DisplayName } @@ -109,22 +108,17 @@ func UpdateChaosSystemService(id int, req *dto.UpdateChaosSystemReq) (*dto.Chaos if req.Description != nil { updates["description"] = *req.Description } - if len(updates) == 0 { - return dto.NewChaosSystemResp(system), nil + return NewChaosSystemResp(system), nil } - if err := repository.UpdateSystem(database.DB, id, updates); err != nil { + if err := s.repo.UpdateSystem(id, updates); err != nil { return nil, err } - - // Reload the system to get updated fields - system, err = repository.GetSystemByID(database.DB, id) + system, err = s.repo.GetSystemByID(id) if err != nil { return nil, err } - - // Re-register with chaos-experiment if err := chaos.RegisterSystem(chaos.SystemConfig{ Name: system.Name, NsPattern: system.NsPattern, @@ -133,70 +127,58 @@ func UpdateChaosSystemService(id int, req *dto.UpdateChaosSystemReq) (*dto.Chaos logrus.WithError(err).Warnf("Failed to re-register system %s with chaos-experiment", system.Name) } - return dto.NewChaosSystemResp(system), nil + return NewChaosSystemResp(system), nil } -// DeleteChaosSystemService soft-deletes a chaos system -func DeleteChaosSystemService(id int) error { - system, err := repository.GetSystemByID(database.DB, id) +func (s *Service) DeleteSystem(_ context.Context, id int) error { + system, err := s.repo.GetSystemByID(id) if err != nil { return err } - if system.IsBuiltin { return fmt.Errorf("cannot delete builtin system %s: %w", system.Name, consts.ErrBadRequest) } - - if err := repository.DeleteSystem(database.DB, id); err != nil { + if err := s.repo.DeleteSystem(id); err != nil { return err } - - // Unregister from chaos-experiment if err := chaos.UnregisterSystem(system.Name); err != nil { logrus.WithError(err).Warnf("Failed to unregister system %s from chaos-experiment", system.Name) } - return nil } -// UpsertChaosSystemMetadataService bulk upserts metadata for a system -func UpsertChaosSystemMetadataService(id int, req *dto.BulkUpsertSystemMetadataReq) error { - system, err := repository.GetSystemByID(database.DB, id) +func (s *Service) UpsertMetadata(_ context.Context, id int, req *BulkUpsertSystemMetadataReq) error { + system, err := s.repo.GetSystemByID(id) if err != nil { return err } for _, item := range req.Items { - meta := &database.SystemMetadata{ + meta := &model.SystemMetadata{ SystemName: system.Name, MetadataType: item.MetadataType, ServiceName: item.ServiceName, Data: string(item.Data), } - if err := repository.UpsertSystemMetadata(database.DB, meta); err != nil { + if err := s.repo.UpsertSystemMetadata(meta); err != nil { return fmt.Errorf("failed to upsert metadata (type=%s, service=%s): %w", item.MetadataType, item.ServiceName, err) } } - return nil } -// ListChaosSystemMetadataService lists metadata for a system, optionally filtered by type -func ListChaosSystemMetadataService(id int, metadataType string) ([]dto.SystemMetadataResp, error) { - system, err := repository.GetSystemByID(database.DB, id) +func (s *Service) ListMetadata(_ context.Context, id int, metadataType string) ([]SystemMetadataResp, error) { + system, err := s.repo.GetSystemByID(id) if err != nil { return nil, err } - - metas, err := repository.ListSystemMetadata(database.DB, system.Name, metadataType) + metas, err := s.repo.ListSystemMetadata(system.Name, metadataType) if err != nil { return nil, fmt.Errorf("failed to list system metadata: %w", err) } - - items := make([]dto.SystemMetadataResp, 0, len(metas)) - for _, m := range metas { - items = append(items, *dto.NewSystemMetadataResp(&m)) + items := make([]SystemMetadataResp, 0, len(metas)) + for _, meta := range metas { + items = append(items, *NewSystemMetadataResp(&meta)) } - return items, nil } diff --git a/src/module/container/api_types.go b/src/module/container/api_types.go new file mode 100644 index 00000000..5e98724e --- /dev/null +++ b/src/module/container/api_types.go @@ -0,0 +1,717 @@ +package container + +import ( + "fmt" + "net/url" + "path/filepath" + "strings" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/model" + "aegis/utils" +) + +// CreateContainerReq represents container creation request. +type CreateContainerReq struct { + Name string `json:"name" binding:"required"` + Type *consts.ContainerType `json:"type"` + README string `json:"readme" binding:"omitempty"` + IsPublic *bool `json:"is_public"` + + VersionReq *CreateContainerVersionReq `json:"version" binding:"omitempty"` +} + +func (req *CreateContainerReq) Validate() error { + req.Name = strings.TrimSpace(req.Name) + + if req.Name == "" { + return fmt.Errorf("container name cannot be empty") + } + if req.IsPublic == nil { + req.IsPublic = utils.BoolPtr(true) + } + if req.Type == nil { + return fmt.Errorf("container type is required") + } + if err := validateContainerType(req.Type); err != nil { + return err + } + if req.VersionReq != nil { + if err := req.VersionReq.Validate(); err != nil { + return fmt.Errorf("invalid container version request: %v", err) + } + } + + return nil +} + +func (req *CreateContainerReq) ConvertToContainer() *model.Container { + container := &model.Container{ + Name: req.Name, + Type: *req.Type, + README: req.README, + IsPublic: *req.IsPublic, + Status: consts.CommonEnabled, + } + + if req.VersionReq != nil { + container.Versions = []model.ContainerVersion{ + *req.VersionReq.ConvertToContainerVersion(), + } + } + + return container +} + +// ListContainerReq represents container list query parameters. +type ListContainerReq struct { + dto.PaginationReq + Type *consts.ContainerType `form:"type"` + IsPublic *bool `form:"is_public"` + Status *consts.StatusType `form:"status"` +} + +func (req *ListContainerReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + if err := validateContainerType(req.Type); err != nil { + return err + } + return validateStatus(req.Status, false) +} + +// UpdateContainerReq represents container update request. +type UpdateContainerReq struct { + README *string `json:"readme" binding:"omitempty"` + IsPublic *bool `json:"is_public" binding:"omitempty"` + Status *consts.StatusType `json:"status" binding:"omitempty"` +} + +func (req *UpdateContainerReq) Validate() error { + return validateStatus(req.Status, true) +} + +func (req *UpdateContainerReq) PatchContainerModel(target *model.Container) { + if req.README != nil { + target.README = *req.README + } + if req.IsPublic != nil { + target.IsPublic = *req.IsPublic + } + if req.Status != nil { + target.Status = *req.Status + } +} + +// ManageContainerLabelReq represents container label management request. +type ManageContainerLabelReq struct { + AddLabels []dto.LabelItem `json:"add_labels" binding:"omitempty"` + RemoveLabels []string `json:"remove_labels" binding:"omitempty"` +} + +func (req *ManageContainerLabelReq) Validate() error { + if len(req.AddLabels) == 0 && len(req.RemoveLabels) == 0 { + return fmt.Errorf("at least one of add_labels or remove_labels must be provided") + } + if err := validateLabelItems(req.AddLabels); err != nil { + return err + } + for i, key := range req.RemoveLabels { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("empty label key at index %d in remove_labels", i) + } + } + return nil +} + +// ContainerResp represents basic container summary information. +type ContainerResp struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + IsPublic bool `json:"is_public"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Labels []dto.LabelItem `json:"labels,omitempty"` +} + +func NewContainerResp(container *model.Container) *ContainerResp { + resp := &ContainerResp{ + ID: container.ID, + Name: container.Name, + Type: consts.GetContainerTypeName(container.Type), + IsPublic: container.IsPublic, + Status: consts.GetStatusTypeName(container.Status), + CreatedAt: container.CreatedAt, + UpdatedAt: container.UpdatedAt, + } + + if len(container.Labels) > 0 { + resp.Labels = make([]dto.LabelItem, 0, len(container.Labels)) + for _, label := range container.Labels { + resp.Labels = append(resp.Labels, dto.LabelItem{Key: label.Key, Value: label.Value}) + } + } + return resp +} + +// ContainerDetailResp represents detailed container information. +type ContainerDetailResp struct { + ContainerResp + + README string `json:"readme"` + + Versions []ContainerVersionResp `json:"versions"` +} + +func NewContainerDetailResp(container *model.Container) *ContainerDetailResp { + return &ContainerDetailResp{ + ContainerResp: *NewContainerResp(container), + README: container.README, + } +} + +type CreateContainerVersionReq struct { + Name string `json:"name" binding:"required"` + GithubLink string `json:"github_link" binding:"omitempty"` + ImageRef string `json:"image_ref" binding:"required"` + Command string `json:"command" binding:"omitempty"` + EnvVarRequests []CreateParameterConfigReq `json:"env_vars" binding:"omitempty"` + HelmConfigRequest *CreateHelmConfigReq `json:"helm_config" binding:"omitempty"` +} + +func (req *CreateContainerVersionReq) Validate() error { + req.Name = strings.TrimSpace(req.Name) + req.ImageRef = strings.TrimSpace(req.ImageRef) + + if req.Name == "" { + return fmt.Errorf("name cannot be empty") + } + if req.ImageRef == "" { + return fmt.Errorf("docker image reference cannot be empty") + } + + if req.GithubLink != "" { + req.GithubLink = strings.TrimSpace(req.GithubLink) + if err := utils.IsValidGitHubLink(req.GithubLink); err != nil { + return fmt.Errorf("invalid github link: %s, %v", req.GithubLink, err) + } + } + if _, _, _, err := utils.ParseSemanticVersion(req.Name); err != nil { + return fmt.Errorf("invalid semantic version: %s, %v", req.Name, err) + } + if _, _, _, _, err := utils.ParseFullImageRefernce(req.ImageRef); err != nil { + return fmt.Errorf("invalid docker image reference: %s, %v", req.ImageRef, err) + } + + for idx, envVarReq := range req.EnvVarRequests { + if err := envVarReq.Validate(); err != nil { + return fmt.Errorf("invalid env var at index %d: %v", idx, err) + } + } + if req.HelmConfigRequest != nil { + if err := req.HelmConfigRequest.Validate(); err != nil { + return fmt.Errorf("invalid helm config: %v", err) + } + } + + return nil +} + +func (req *CreateContainerVersionReq) ConvertToContainerVersion() *model.ContainerVersion { + version := &model.ContainerVersion{ + Name: req.Name, + ImageRef: req.ImageRef, + Command: req.Command, + Status: consts.CommonEnabled, + } + + if len(req.EnvVarRequests) > 0 { + params := make([]model.ParameterConfig, 0, len(req.EnvVarRequests)) + for _, envVarReq := range req.EnvVarRequests { + params = append(params, *envVarReq.ConvertToParameterConfig()) + } + version.EnvVars = params + } + + if req.HelmConfigRequest != nil { + version.HelmConfig = req.HelmConfigRequest.ConvertToHelmConfig() + } + + return version +} + +type CreateHelmConfigReq struct { + Version string `json:"version" binding:"required"` + ChartName string `json:"chart_name" binding:"required"` + RepoName string `json:"repo_name" binding:"required"` + RepoURL string `json:"repo_url" binding:"required"` + DynamicValues []CreateParameterConfigReq `json:"dynamic_values" binding:"omitempty" swaggertype:"object"` +} + +func (req *CreateHelmConfigReq) Validate() error { + req.Version = strings.TrimSpace(req.Version) + req.ChartName = strings.TrimSpace(req.ChartName) + req.RepoName = strings.TrimSpace(req.RepoName) + req.RepoURL = strings.TrimSpace(req.RepoURL) + + if req.Version == "" { + if _, _, _, err := utils.ParseSemanticVersion(req.Version); err != nil { + return fmt.Errorf("invalid semantic version: %s, %v", req.Version, err) + } + } + if req.ChartName == "" { + return fmt.Errorf("chart name cannot be empty") + } + if req.RepoName == "" { + return fmt.Errorf("repository name cannot be empty") + } + if req.RepoURL == "" { + return fmt.Errorf("repository URL cannot be empty") + } + if _, err := url.ParseRequestURI(req.RepoURL); err != nil { + return fmt.Errorf("invalid repository URL: %s, %w", req.RepoURL, err) + } + for i, val := range req.DynamicValues { + if err := val.Validate(); err != nil { + return fmt.Errorf("invalid parameter config at index %d: %w", i, err) + } + } + + return nil +} + +func (req *CreateHelmConfigReq) ConvertToHelmConfig() *model.HelmConfig { + cfg := &model.HelmConfig{ + Version: req.Version, + ChartName: req.ChartName, + RepoName: req.RepoName, + RepoURL: req.RepoURL, + } + + if len(req.DynamicValues) > 0 { + params := make([]model.ParameterConfig, 0, len(req.DynamicValues)) + for _, val := range req.DynamicValues { + params = append(params, *val.ConvertToParameterConfig()) + } + cfg.DynamicValues = params + } + + return cfg +} + +type CreateParameterConfigReq struct { + Key string `json:"key" binding:"required"` + Type consts.ParameterType `json:"type" binding:"required"` + Category consts.ParameterCategory `json:"category" binding:"required"` + ValueType consts.ValueDataType `json:"value_type" binding:"omitempty"` + Description string `json:"description" binding:"omitempty"` + DefaultValue *string `json:"default_value" binding:"omitempty"` + TemplateString *string `json:"template_string" binding:"omitempty"` + Required bool `json:"required"` + Overridable *bool `json:"overridable" binding:"omitempty"` +} + +func (req *CreateParameterConfigReq) Validate() error { + if req.Key == "" { + return fmt.Errorf("parameter key cannot be empty") + } + if _, exists := consts.ValidParameterTypes[req.Type]; !exists { + return fmt.Errorf("invalid parameter type: %v", req.Type) + } + if _, exists := consts.ValidParameterCategories[req.Category]; !exists { + return fmt.Errorf("invalid parameter category: %v", req.Category) + } + if req.Type == consts.ParameterTypeFixed && req.Required && req.DefaultValue == nil { + return fmt.Errorf("default value is required for fixed parameter type when marked as required") + } + if req.Type == consts.ParameterTypeDynamic && req.TemplateString == nil { + return fmt.Errorf("template string is required for dynamic parameter type") + } + return nil +} + +func (req *CreateParameterConfigReq) ConvertToParameterConfig() *model.ParameterConfig { + config := &model.ParameterConfig{ + Key: req.Key, + Type: req.Type, + Category: req.Category, + ValueType: req.ValueType, + Description: req.Description, + DefaultValue: req.DefaultValue, + TemplateString: req.TemplateString, + Required: req.Required, + Overridable: true, + } + if req.Overridable != nil { + config.Overridable = *req.Overridable + } + return config +} + +type ListContainerVersionReq struct { + dto.PaginationReq + Status *consts.StatusType `json:"status" binding:"omitempty"` +} + +func (req *ListContainerVersionReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + return validateStatus(req.Status, false) +} + +type SearchContainerReq struct { + dto.AdvancedSearchReq[string] + Name *string `json:"name,omitempty"` + Image *string `json:"image,omitempty"` + Tag *string `json:"tag,omitempty"` + Type *string `json:"type,omitempty"` + Command *string `json:"command,omitempty"` + Status *int `json:"status,omitempty"` +} + +func (csr *SearchContainerReq) ConvertToSearchRequest() *dto.SearchReq[string] { + sr := csr.ConvertAdvancedToSearch() + if csr.Name != nil { + sr.AddFilter("name", dto.OpLike, *csr.Name) + } + if csr.Image != nil { + sr.AddFilter("image", dto.OpLike, *csr.Image) + } + if csr.Tag != nil { + sr.AddFilter("tag", dto.OpEqual, *csr.Tag) + } + if csr.Type != nil { + sr.AddFilter("type", dto.OpEqual, *csr.Type) + } + if csr.Command != nil { + sr.AddFilter("command", dto.OpLike, *csr.Command) + } + return sr +} + +type SubmitBuildContainerReq struct { + ImageName string `json:"image_name" binding:"required"` + Tag string `json:"tag" binding:"omitempty"` + GithubRepository string `json:"github_repository" binding:"required"` + GithubBranch string `json:"github_branch" binding:"omitempty"` + GithubCommit string `json:"github_commit" binding:"omitempty"` + GithubToken string `json:"github_token" binding:"omitempty"` + SubPath string `json:"sub_path" binding:"omitempty"` + Options *dto.BuildOptions `json:"build_options" binding:"omitempty"` +} + +func (req *SubmitBuildContainerReq) Validate() error { + req.ImageName = strings.TrimSpace(req.ImageName) + req.GithubRepository = strings.TrimSpace(req.GithubRepository) + + if req.ImageName == "" { + return fmt.Errorf("container image name cannot be empty") + } + if req.Tag != "" { + req.Tag = strings.TrimSpace(req.Tag) + } + parts := strings.Split(req.GithubRepository, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("invalid repository format, expected 'owner/repo'") + } + if req.GithubBranch != "" { + req.GithubBranch = strings.TrimSpace(req.GithubBranch) + if err := utils.IsValidGitHubBranch(req.GithubBranch); err != nil { + return err + } + } + if req.GithubCommit != "" { + req.GithubCommit = strings.TrimSpace(req.GithubCommit) + if err := utils.IsValidGitHubCommit(req.GithubCommit); err != nil { + return err + } + } + if req.GithubToken != "" { + req.GithubToken = strings.TrimSpace(req.GithubToken) + if err := utils.IsValidGitHubToken(req.GithubToken); err != nil { + return err + } + } + if req.Tag == "" { + req.Tag = "latest" + } + if req.GithubBranch == "" { + req.GithubBranch = "main" + } + if req.SubPath == "" { + req.SubPath = "." + } + return req.Options.Validate() +} + +func (req *SubmitBuildContainerReq) ValidateInfoContent(sourcePath string) error { + if req.ImageName == "" { + tomlPath := filepath.Join(sourcePath, dto.InfoFileName) + content, err := utils.ReadTomlFile(tomlPath) + if err != nil { + return err + } + + if name, ok := content[dto.InfoNameField].(string); ok && name != "" { + req.ImageName = name + } else { + return fmt.Errorf("%s does not contain a valid name field", dto.InfoFileName) + } + } + return nil +} + +type UpdateContainerVersionReq struct { + GithubLink *string `json:"github_link" binding:"omitempty"` + Command *string `json:"command" binding:"omitempty"` + Status *consts.StatusType `json:"status" binding:"omitempty"` + HelmConfigRequest *UpdateHelmConfigReq `json:"helm_config" binding:"omitempty"` +} + +func (req *UpdateContainerVersionReq) Validate() error { + if req.GithubLink != nil { + trimmedLink := strings.TrimSpace(*req.GithubLink) + *req.GithubLink = trimmedLink + if trimmedLink != "" { + if err := utils.IsValidGitHubLink(trimmedLink); err != nil { + return fmt.Errorf("invalid GitHub link '%s': %v", trimmedLink, err) + } + } + } + if req.Command != nil { + *req.Command = strings.TrimSpace(*req.Command) + } + if req.Status != nil { + if err := validateStatus(req.Status, true); err != nil { + return err + } + } + if req.HelmConfigRequest != nil { + if err := req.HelmConfigRequest.Validate(); err != nil { + return fmt.Errorf("invalid helm config: %v", err) + } + } + return nil +} + +func (req *UpdateContainerVersionReq) PatchContainerVersionModel(target *model.ContainerVersion) { + if req.GithubLink != nil { + target.GithubLink = *req.GithubLink + } + if req.Command != nil { + target.Command = *req.Command + } + if req.Status != nil { + target.Status = *req.Status + } +} + +type UpdateHelmConfigReq struct { + RepoURL *string `json:"repo_url" binding:"omitempty"` + RepoName *string `json:"repo_name" binding:"omitempty"` + ChartName *string `json:"chart_name" binding:"omitempty"` + DynamicValues *map[string]any `json:"dynamic_values" binding:"omitempty" swaggertype:"object"` +} + +func (req *UpdateHelmConfigReq) Validate() error { + if req.RepoURL != nil { + trimmedURL := strings.TrimSpace(*req.RepoURL) + *req.RepoURL = trimmedURL + if trimmedURL == "" { + return fmt.Errorf("repository URL cannot be empty if provided") + } + if _, err := url.Parse(trimmedURL); err != nil { + return fmt.Errorf("invalid repository URL format: %s. Error: %v", trimmedURL, err) + } + } + if req.RepoName != nil { + *req.RepoName = strings.TrimSpace(*req.RepoName) + } + if req.ChartName != nil { + *req.ChartName = strings.TrimSpace(*req.ChartName) + } + return nil +} + +func (req *UpdateHelmConfigReq) PatchHelmConfigModel(target *model.HelmConfig) error { + if req.RepoURL != nil { + target.RepoURL = *req.RepoURL + } + if req.RepoName != nil { + target.RepoName = *req.RepoName + } + if req.ChartName != nil { + target.ChartName = *req.ChartName + } + return nil +} + +type ContainerVersionResp struct { + ID int `json:"id"` + Name string `json:"name"` + ImageRef string `json:"image_ref"` + Usage int `json:"usage"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SetContainerVersionImageReq is the request body for +// PATCH /api/v2/container-versions/:id/image. It rewrites the four image +// reference columns on a container_versions row. +type SetContainerVersionImageReq struct { + Registry string `json:"registry"` + Namespace string `json:"namespace"` + Repository string `json:"repository" binding:"required"` + Tag string `json:"tag" binding:"required"` +} + +func (req *SetContainerVersionImageReq) Validate() error { + req.Registry = strings.TrimSpace(req.Registry) + req.Namespace = strings.TrimSpace(req.Namespace) + req.Repository = strings.TrimSpace(req.Repository) + req.Tag = strings.TrimSpace(req.Tag) + if req.Registry == "" { + req.Registry = "docker.io" + } + if req.Repository == "" { + return fmt.Errorf("repository is required") + } + if req.Tag == "" { + return fmt.Errorf("tag is required") + } + return nil +} + +// SetContainerVersionImageResp is returned after a successful image rewrite. +type SetContainerVersionImageResp struct { + ID int `json:"id"` + Name string `json:"name"` + Registry string `json:"registry"` + Namespace string `json:"namespace"` + Repository string `json:"repository"` + Tag string `json:"tag"` + ImageRef string `json:"image_ref"` +} + +func NewSetContainerVersionImageResp(version *model.ContainerVersion) *SetContainerVersionImageResp { + return &SetContainerVersionImageResp{ + ID: version.ID, + Name: version.Name, + Registry: version.Registry, + Namespace: version.Namespace, + Repository: version.Repository, + Tag: version.Tag, + ImageRef: version.ImageRef, + } +} + +func NewContainerVersionResp(version *model.ContainerVersion) *ContainerVersionResp { + return &ContainerVersionResp{ + ID: version.ID, + Name: version.Name, + ImageRef: version.ImageRef, + Usage: version.Usage, + UpdatedAt: version.UpdatedAt, + } +} + +type ContainerVersionDetailResp struct { + ContainerVersionResp + GithubLink string `json:"github_link"` + Command string `json:"command"` + EnvVars string `json:"env_vars"` + HelmConfig *HelmConfigDetailResp `json:"helm_config,omitempty"` +} + +func NewContainerVersionDetailResp(version *model.ContainerVersion) *ContainerVersionDetailResp { + return &ContainerVersionDetailResp{ + ContainerVersionResp: *NewContainerVersionResp(version), + GithubLink: version.GithubLink, + Command: version.Command, + } +} + +type ListContainerVersionResp struct { + Items []ContainerVersionResp `json:"items"` + Pagination dto.PaginationInfo `json:"pagination"` +} + +type HelmConfigDetailResp struct { + ID int `json:"id"` + Version string `json:"version"` + ChartName string `json:"chart_name"` + RepoName string `json:"repo_name"` + RepoURL string `json:"repo_url"` + LocalPath string `json:"local_path,omitempty"` + ValueFile string `json:"value_file,omitempty"` + Values map[string]any `json:"values"` +} + +func NewHelmConfigDetailResp(cfg *model.HelmConfig) (*HelmConfigDetailResp, error) { + return &HelmConfigDetailResp{ + ID: cfg.ID, + Version: cfg.Version, + ChartName: cfg.ChartName, + RepoName: cfg.RepoName, + RepoURL: cfg.RepoURL, + LocalPath: cfg.LocalPath, + ValueFile: cfg.ValueFile, + }, nil +} + +type UploadHelmValueFileResp struct { + FilePath string `json:"file_path"` + FileName string `json:"file_name"` +} + +type UploadHelmChartResp struct { + FilePath string `json:"file_path"` + FileName string `json:"file_name"` + Checksum string `json:"checksum"` +} + +type SubmitContainerBuildResp struct { + GroupID string `json:"group_id"` + TraceID string `json:"trace_id"` + TaskID string `json:"task_id"` +} + +func validateStatus(statusPtr *consts.StatusType, isMutation bool) error { + if statusPtr == nil { + return nil + } + status := *statusPtr + if _, exists := consts.ValidStatuses[status]; !exists { + return fmt.Errorf("invalid status value: %d", status) + } + if isMutation && status == consts.CommonDeleted { + return fmt.Errorf("status value cannot be set to deleted (%d) directly through this update/create operation", consts.CommonDeleted) + } + return nil +} + +func validateLabelItems(items []dto.LabelItem) error { + for i, label := range items { + if strings.TrimSpace(label.Key) == "" { + return fmt.Errorf("empty label key at index %d in add_labels", i) + } + if strings.TrimSpace(label.Value) == "" { + return fmt.Errorf("empty label value at index %d in add_labels", i) + } + } + return nil +} + +func validateContainerType(containerType *consts.ContainerType) error { + if containerType != nil { + if _, exists := consts.ValidContainerTypes[*containerType]; !exists { + return fmt.Errorf("invalid container type: %d", *containerType) + } + } + return nil +} diff --git a/src/module/container/build_gateway.go b/src/module/container/build_gateway.go new file mode 100644 index 00000000..a0b9753a --- /dev/null +++ b/src/module/container/build_gateway.go @@ -0,0 +1,87 @@ +package container + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "aegis/config" + "aegis/utils" + + "github.com/sirupsen/logrus" +) + +type BuildGateway struct { + containerBasePath string + registry string + namespace string + repoURLBuilder func(*SubmitBuildContainerReq) string + commandRunner func(string, ...string) *exec.Cmd +} + +func NewBuildGateway() *BuildGateway { + return &BuildGateway{ + containerBasePath: config.GetString("jfs.container_path"), + registry: config.GetString("harbor.registry"), + namespace: config.GetString("harbor.namespace"), + repoURLBuilder: func(req *SubmitBuildContainerReq) string { + repoURL := fmt.Sprintf("https://github.com/%s.git", req.GithubRepository) + if req.GithubToken != "" { + repoURL = fmt.Sprintf("https://%s@github.com/%s.git", req.GithubToken, req.GithubRepository) + } + return repoURL + }, + commandRunner: exec.Command, + } +} + +func (g *BuildGateway) BuildImageRef(imageName, tag string) string { + return fmt.Sprintf("%s/%s/%s:%s", g.registry, g.namespace, imageName, tag) +} + +func (g *BuildGateway) PrepareGitHubSource(req *SubmitBuildContainerReq) (string, error) { + targetDir := filepath.Join(g.containerBasePath, req.ImageName, fmt.Sprintf("build_%d", time.Now().Unix())) + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create target directory: %w", err) + } + + repoURL := g.repoURLBuilder(req) + + gitCmd := []string{"git", "clone"} + if req.GithubBranch != "" { + gitCmd = append(gitCmd, "--branch", req.GithubBranch, "--single-branch") + } + gitCmd = append(gitCmd, repoURL, targetDir) + + cmd := g.commandRunner(gitCmd[0], gitCmd[1:]...) + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to clone repository: %w", err) + } + + if req.GithubCommit != "" { + cmd = g.commandRunner("git", "-C", targetDir, "checkout", req.GithubCommit) + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to checkout commit %s: %w", req.GithubCommit, err) + } + } + + if req.SubPath != "" && req.SubPath != "." { + sourcePath := filepath.Join(targetDir, req.SubPath) + if _, err := os.Stat(sourcePath); os.IsNotExist(err) { + return "", fmt.Errorf("sub path '%s' does not exist in repository", req.SubPath) + } + + newTargetDir := filepath.Join(g.containerBasePath, req.ImageName, fmt.Sprintf("build_final_%d", time.Now().Unix())) + if err := utils.CopyDir(sourcePath, newTargetDir); err != nil { + return "", fmt.Errorf("failed to copy subdirectory: %w", err) + } + if err := os.RemoveAll(targetDir); err != nil { + logrus.WithField("target_dir", targetDir).Warnf("failed to remove temporary directory: %v", err) + } + targetDir = newTargetDir + } + + return targetDir, nil +} diff --git a/src/module/container/build_gateway_test.go b/src/module/container/build_gateway_test.go new file mode 100644 index 00000000..f20a85f9 --- /dev/null +++ b/src/module/container/build_gateway_test.go @@ -0,0 +1,84 @@ +package container + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/viper" +) + +func TestBuildGatewayBuildImageRef(t *testing.T) { + gateway := &BuildGateway{ + registry: "registry.example.com", + namespace: "team-a", + } + + if got := gateway.BuildImageRef("demo", "v1"); got != "registry.example.com/team-a/demo:v1" { + t.Fatalf("unexpected image ref: %s", got) + } +} + +func TestBuildGatewayPrepareGitHubSourceCopiesSubPath(t *testing.T) { + tmpDir := t.TempDir() + repoDir := filepath.Join(tmpDir, "repo") + if err := os.MkdirAll(filepath.Join(repoDir, "subdir"), 0o755); err != nil { + t.Fatalf("mkdir repo: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, "subdir", "payload.txt"), []byte("payload"), 0o644); err != nil { + t.Fatalf("write payload: %v", err) + } + + runGit(t, repoDir, "init") + runGit(t, repoDir, "config", "user.email", "codex@example.com") + runGit(t, repoDir, "config", "user.name", "Codex") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "init") + + viper.Set("jfs.container_path", tmpDir) + gateway := &BuildGateway{ + containerBasePath: tmpDir, + registry: "registry.example.com", + namespace: "team-a", + repoURLBuilder: func(*SubmitBuildContainerReq) string { + return repoDir + }, + commandRunner: exec.Command, + } + + req := &SubmitBuildContainerReq{ + ImageName: "demo", + GithubRepository: "owner/repo", + GithubBranch: "master", + SubPath: "subdir", + } + + targetDir, err := gateway.PrepareGitHubSource(req) + if err != nil { + t.Fatalf("PrepareGitHubSource failed: %v", err) + } + + if filepath.Base(targetDir) == "subdir" { + t.Fatalf("expected copied final directory, got raw subdir path: %s", targetDir) + } + + content, err := os.ReadFile(filepath.Join(targetDir, "payload.txt")) + if err != nil { + t.Fatalf("read copied file: %v", err) + } + if string(content) != "payload" { + t.Fatalf("unexpected copied content: %s", string(content)) + } +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, string(output)) + } +} diff --git a/src/module/container/core.go b/src/module/container/core.go new file mode 100644 index 00000000..1f72abba --- /dev/null +++ b/src/module/container/core.go @@ -0,0 +1,21 @@ +package container + +import ( + "aegis/model" +) + +func (r *Repository) CreateContainerCore(container *model.Container, userID int) (*model.Container, error) { + service := NewService(r, NewBuildGateway(), NewHelmFileStore(), nil) + return service.createContainerCore(r, container, userID) +} + +func (r *Repository) UploadHelmValueFileFromPath(containerName string, helmConfig *model.HelmConfig, srcFilePath string) error { + store := NewHelmFileStore() + targetPath, err := store.SaveValueFile(containerName, nil, srcFilePath) + if err != nil { + return err + } + + helmConfig.ValueFile = targetPath + return r.updateHelmConfig(helmConfig) +} diff --git a/src/module/container/file_store.go b/src/module/container/file_store.go new file mode 100644 index 00000000..fe217aae --- /dev/null +++ b/src/module/container/file_store.go @@ -0,0 +1,86 @@ +package container + +import ( + "fmt" + "mime/multipart" + "os" + "path/filepath" + "time" + + "aegis/config" + "aegis/utils" + + "github.com/sirupsen/logrus" +) + +type HelmFileStore struct { + basePath string +} + +func NewHelmFileStore() *HelmFileStore { + return &HelmFileStore{basePath: config.GetString("jfs.dataset_path")} +} + +func (s *HelmFileStore) SaveChart(containerName string, file *multipart.FileHeader) (string, string, error) { + if s.basePath == "" { + return "", "", fmt.Errorf("jfs.dataset_path is not configured") + } + + targetDir := filepath.Join(s.basePath, "helm-charts") + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return "", "", fmt.Errorf("failed to create directory: %w", err) + } + + targetPath := filepath.Join( + targetDir, + fmt.Sprintf("%s_chart_%d%s", containerName, time.Now().Unix(), filepath.Ext(file.Filename)), + ) + if err := utils.CopyFileFromFileHeader(file, targetPath); err != nil { + return "", "", fmt.Errorf("failed to save chart file: %w", err) + } + + checksum, err := utils.CalculateFileSHA256(targetPath) + if err != nil { + logrus.WithField("file_path", targetPath).Warnf("failed to calculate checksum: %v", err) + checksum = "" + } + + logrus.WithFields(logrus.Fields{ + "file_path": targetPath, + "checksum": checksum, + }).Info("Helm chart package uploaded successfully") + + return targetPath, checksum, nil +} + +func (s *HelmFileStore) SaveValueFile(containerName string, srcFileHeader *multipart.FileHeader, srcFilePath string) (string, error) { + if s.basePath == "" { + return "", fmt.Errorf("jfs.dataset_path is not configured") + } + + targetDir := filepath.Join(s.basePath, "helm-values") + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create directory: %w", err) + } + + timestamp := time.Now().Unix() + var targetPath string + + switch { + case srcFileHeader != nil: + targetPath = filepath.Join(targetDir, fmt.Sprintf("%s_values_%d%s", containerName, timestamp, filepath.Ext(srcFileHeader.Filename))) + if err := utils.CopyFileFromFileHeader(srcFileHeader, targetPath); err != nil { + return "", fmt.Errorf("failed to save file: %w", err) + } + case srcFilePath != "": + targetPath = filepath.Join(targetDir, fmt.Sprintf("%s_values_%d%s", containerName, timestamp, filepath.Ext(srcFilePath))) + if err := utils.CopyFile(srcFilePath, targetPath); err != nil { + return "", fmt.Errorf("failed to save file: %w", err) + } + default: + return "", fmt.Errorf("either source file header or source file path is required") + } + + logrus.WithField("file_path", targetPath).Info("Helm values file uploaded successfully") + return targetPath, nil +} diff --git a/src/module/container/file_store_test.go b/src/module/container/file_store_test.go new file mode 100644 index 00000000..eee4f1b7 --- /dev/null +++ b/src/module/container/file_store_test.go @@ -0,0 +1,84 @@ +package container + +import ( + "bytes" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" +) + +func TestHelmFileStoreSaveChartAndValueFile(t *testing.T) { + tmpDir := t.TempDir() + viper.Set("jfs.dataset_path", tmpDir) + + store := &HelmFileStore{basePath: tmpDir} + fileHeader := newMultipartFileHeader(t, "chart.tgz", []byte("chart-bytes")) + + chartPath, checksum, err := store.SaveChart("pedestal", fileHeader) + if err != nil { + t.Fatalf("SaveChart failed: %v", err) + } + if checksum == "" { + t.Fatalf("expected checksum to be populated") + } + if !filepath.IsAbs(chartPath) && filepath.Dir(chartPath) == "." { + t.Fatalf("expected chart path to include target directory, got %s", chartPath) + } + + chartContent, err := os.ReadFile(chartPath) + if err != nil { + t.Fatalf("read saved chart: %v", err) + } + if string(chartContent) != "chart-bytes" { + t.Fatalf("unexpected chart content: %s", string(chartContent)) + } + + valueHeader := newMultipartFileHeader(t, "values.yaml", []byte("key: value\n")) + valuePath, err := store.SaveValueFile("pedestal", valueHeader, "") + if err != nil { + t.Fatalf("SaveValueFile failed: %v", err) + } + + valueContent, err := os.ReadFile(valuePath) + if err != nil { + t.Fatalf("read saved values file: %v", err) + } + if string(valueContent) != "key: value\n" { + t.Fatalf("unexpected values content: %s", string(valueContent)) + } +} + +func newMultipartFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader { + t.Helper() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", filename) + if err != nil { + t.Fatalf("create form file: %v", err) + } + if _, err := io.Copy(part, bytes.NewReader(content)); err != nil { + t.Fatalf("write multipart content: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + if err := req.ParseMultipartForm(int64(body.Len()) + 1024); err != nil { + t.Fatalf("parse multipart form: %v", err) + } + + fileHeaders := req.MultipartForm.File["file"] + if len(fileHeaders) != 1 { + t.Fatalf("expected one file header, got %d", len(fileHeaders)) + } + return fileHeaders[0] +} diff --git a/src/handlers/v2/containers.go b/src/module/container/handler.go similarity index 50% rename from src/handlers/v2/containers.go rename to src/module/container/handler.go index 03ef4dec..01806554 100644 --- a/src/handlers/v2/containers.go +++ b/src/module/container/handler.go @@ -1,6 +1,7 @@ -package v2 +package container import ( + "aegis/httpx" "context" "net/http" "path/filepath" @@ -8,15 +9,18 @@ import ( "aegis/consts" "aegis/dto" - "aegis/handlers" "aegis/middleware" - producer "aegis/service/producer" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" ) -// ===================== Container ===================== +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} // CreateContainer handles container creation for v2 API // @@ -27,23 +31,23 @@ import ( // @Accept json // @Produce json // @Security BearerAuth -// @Param request body dto.CreateContainerReq true "Container creation request" -// @Success 201 {object} dto.GenericResponse[dto.ContainerResp] "Container created successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 409 {object} dto.GenericResponse[any] "Conflict error" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param request body CreateContainerReq true "Container creation request" +// @Success 201 {object} dto.GenericResponse[ContainerResp] "Container created successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 409 {object} dto.GenericResponse[any] "Conflict error" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/containers [post] -// @x-api-type {"sdk":"true"} -func CreateContainer(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) CreateContainer(c *gin.Context) { userID, exists := middleware.GetCurrentUserID(c) if !exists || userID <= 0 { dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") return } - var req dto.CreateContainerReq + var req CreateContainerReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -54,8 +58,8 @@ func CreateContainer(c *gin.Context) { return } - resp, err := producer.CreateContainer(&req, userID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.CreateContainer(c.Request.Context(), &req, userID) + if httpx.HandleServiceError(c, err) { return } @@ -78,16 +82,14 @@ func CreateContainer(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "Container not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/containers/{container_id} [delete] -func DeleteContainer(c *gin.Context) { - containerIDStr := c.Param(consts.URLPathContainerID) - containerID, err := strconv.Atoi(containerIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container ID") +// @x-api-type {"portal":"true"} +func (h *Handler) DeleteContainer(c *gin.Context) { + containerID, ok := parseContainerID(c) + if !ok { return } - err = producer.DeleteContainer(containerID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.DeleteContainer(c.Request.Context(), containerID)) { return } @@ -102,25 +104,23 @@ func DeleteContainer(c *gin.Context) { // @ID get_container_by_id // @Produce json // @Security BearerAuth -// @Param container_id path int true "Container ID" -// @Success 200 {object} dto.GenericResponse[dto.ContainerDetailResp] "Container retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid container ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Container not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param container_id path int true "Container ID" +// @Success 200 {object} dto.GenericResponse[ContainerDetailResp] "Container retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid container ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Container not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/containers/{container_id} [get] -// @x-api-type {"sdk":"true"} -func GetContainer(c *gin.Context) { - containerIDStr := c.Param(consts.URLPathContainerID) - containerID, err := strconv.Atoi(containerIDStr) - if err != nil || containerID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container ID") +// @x-api-type {"portal":"true"} +func (h *Handler) GetContainer(c *gin.Context) { + containerID, ok := parseContainerID(c) + if !ok { return } - resp, err := producer.GetContainerDetail(containerID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.GetContainer(c.Request.Context(), containerID) + if httpx.HandleServiceError(c, err) { return } @@ -135,20 +135,20 @@ func GetContainer(c *gin.Context) { // @ID list_containers // @Produce json // @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query consts.PageSize false "Page size" default(20) -// @Param type query consts.ContainerType false "Container type filter" -// @Param is_public query bool false "Container public visibility filter" -// @Param status query consts.StatusType false "Container status filter" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.ContainerResp]] "Containers retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param page query int false "Page number" default(1) +// @Param size query consts.PageSize false "Page size" default(20) +// @Param type query consts.ContainerType false "Container type filter" +// @Param is_public query bool false "Container public visibility filter" +// @Param status query consts.StatusType false "Container status filter" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[ContainerResp]] "Containers retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/containers [get] -// @x-api-type {"sdk":"true"} -func ListContainers(c *gin.Context) { - var req dto.ListContainerReq +// @x-api-type {"portal":"true"} +func (h *Handler) ListContainers(c *gin.Context) { + var req ListContainerReq if err := c.ShouldBindQuery(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -159,8 +159,8 @@ func ListContainers(c *gin.Context) { return } - resp, err := producer.ListContainers(&req) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ListContainers(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { return } @@ -176,38 +176,79 @@ func ListContainers(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param container_id path int true "Container ID" -// @Param request body dto.UpdateContainerReq true "Container update request" -// @Success 202 {object} dto.GenericResponse[dto.ContainerResp] "Container updated successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid container ID/request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Container not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param container_id path int true "Container ID" +// @Param request body UpdateContainerReq true "Container update request" +// @Success 202 {object} dto.GenericResponse[ContainerResp] "Container updated successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid container ID/request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Container not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/containers/{container_id} [patch] -func UpdateContainer(c *gin.Context) { - containerIDStr := c.Param(consts.URLPathContainerID) - containerID, err := strconv.Atoi(containerIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container ID") +// @x-api-type {"portal":"true"} +func (h *Handler) UpdateContainer(c *gin.Context) { + containerID, ok := parseContainerID(c) + if !ok { return } - var req dto.UpdateContainerReq + var req UpdateContainerReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - resp, err := producer.UpdateContainer(&req, containerID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.UpdateContainer(c.Request.Context(), &req, containerID) + if httpx.HandleServiceError(c, err) { return } dto.JSONResponse[any](c, http.StatusAccepted, "Container updated successfully", resp) } -// ===================== Container Version ===================== +// ManageContainerCustomLabels manages container custom labels (key-value pairs) +// +// @Summary Manage container custom labels +// @Description Add or remove custom labels (key-value pairs) for a container +// @Tags Containers +// @ID manage_container_labels +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param container_id path int true "Container ID" +// @Param manage body ManageContainerLabelReq true "Label management request" +// @Success 200 {object} dto.GenericResponse[ContainerResp] "Labels managed successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid container ID or invalid request format/parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Container not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/containers/{container_id}/labels [patch] +// @x-api-type {"portal":"true"} +func (h *Handler) ManageContainerCustomLabels(c *gin.Context) { + containerID, ok := parseContainerID(c) + if !ok { + return + } + + var req ManageContainerLabelReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.ManageContainerLabels(c.Request.Context(), &req, containerID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.SuccessResponse(c, resp) +} // CreateContainerVersion handles container version creation for v2 API // @@ -218,31 +259,29 @@ func UpdateContainer(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param container_id path int true "Container ID" -// @Param request body dto.CreateContainerVersionReq true "Container version creation request" -// @Success 201 {object} dto.GenericResponse[dto.ContainerVersionResp] "Container version created successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid container ID or invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 409 {object} dto.GenericResponse[any] "Conflict error" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param container_id path int true "Container ID" +// @Param request body CreateContainerVersionReq true "Container version creation request" +// @Success 201 {object} dto.GenericResponse[ContainerVersionResp] "Container version created successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid container ID or invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 409 {object} dto.GenericResponse[any] "Conflict error" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/containers/{container_id}/versions [post] -// @x-api-type {"sdk":"true"} -func CreateContainerVersion(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) CreateContainerVersion(c *gin.Context) { userID, exists := middleware.GetCurrentUserID(c) if !exists || userID <= 0 { dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") return } - containerIDStr := c.Param(consts.URLPathContainerID) - containerID, err := strconv.Atoi(containerIDStr) - if err != nil || containerID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container ID") + containerID, ok := parseContainerID(c) + if !ok { return } - var req dto.CreateContainerVersionReq + var req CreateContainerVersionReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -253,8 +292,8 @@ func CreateContainerVersion(c *gin.Context) { return } - resp, err := producer.CreateContainerVersion(&req, containerID, userID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.CreateContainerVersion(c.Request.Context(), &req, containerID, userID) + if httpx.HandleServiceError(c, err) { return } @@ -278,16 +317,14 @@ func CreateContainerVersion(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "Container or version not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/containers/{container_id}/versions/{version_id} [delete] -func DeleteContainerVersion(c *gin.Context) { - versionIDStr := c.Param(consts.URLPathVersionID) - versionID, err := strconv.Atoi(versionIDStr) - if err != nil || versionID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container version ID") +// @x-api-type {"portal":"true"} +func (h *Handler) DeleteContainerVersion(c *gin.Context) { + versionID, ok := parseVersionID(c, "Invalid container version ID") + if !ok { return } - err = producer.DeleteContainerVersion(versionID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.DeleteContainerVersion(c.Request.Context(), versionID)) { return } @@ -302,33 +339,28 @@ func DeleteContainerVersion(c *gin.Context) { // @ID get_container_version_by_id // @Produce json // @Security BearerAuth -// @Param container_id path int true "Container ID" -// @Param version_id path int true "Container Version ID" -// @Success 200 {object} dto.GenericResponse[dto.ContainerVersionDetailResp] "Container version retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid container ID/container version ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Container or version not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param container_id path int true "Container ID" +// @Param version_id path int true "Container Version ID" +// @Success 200 {object} dto.GenericResponse[ContainerVersionDetailResp] "Container version retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid container ID/container version ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Container or version not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/containers/{container_id}/versions/{version_id} [get] -// @x-api-type {"sdk":"true"} -func GetContainerVersion(c *gin.Context) { - containerIDStr := c.Param(consts.URLPathContainerID) - containerID, err := strconv.Atoi(containerIDStr) - if err != nil || containerID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container ID") +// @x-api-type {"portal":"true"} +func (h *Handler) GetContainerVersion(c *gin.Context) { + containerID, ok := parseContainerID(c) + if !ok { return } - - versionIDStr := c.Param(consts.URLPathVersionID) - versionID, err := strconv.Atoi(versionIDStr) - if err != nil || versionID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container version ID") + versionID, ok := parseVersionID(c, "Invalid container version ID") + if !ok { return } - resp, err := producer.GetContainerVersionDetail(containerID, versionID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.GetContainerVersion(c.Request.Context(), containerID, versionID) + if httpx.HandleServiceError(c, err) { return } @@ -343,26 +375,24 @@ func GetContainerVersion(c *gin.Context) { // @ID list_container_versions // @Produce json // @Security BearerAuth -// @Param container_id path int true "Container ID" -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param status query consts.StatusType false "Container version status filter" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.ContainerVersionResp]] "Container versions retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param container_id path int true "Container ID" +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Param status query consts.StatusType false "Container version status filter" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[ContainerVersionResp]] "Container versions retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/containers/{container_id}/versions [get] -// @x-api-type {"sdk":"true"} -func ListContainerVersions(c *gin.Context) { - containerIDStr := c.Param(consts.URLPathContainerID) - containerID, err := strconv.Atoi(containerIDStr) - if err != nil || containerID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container ID") +// @x-api-type {"portal":"true"} +func (h *Handler) ListContainerVersions(c *gin.Context) { + containerID, ok := parseContainerID(c) + if !ok { return } - var req dto.ListContainerVersionReq + var req ListContainerVersionReq if err := c.ShouldBindQuery(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -373,8 +403,8 @@ func ListContainerVersions(c *gin.Context) { return } - resp, err := producer.ListContainerVersions(&req, containerID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ListContainerVersions(c.Request.Context(), &req, containerID) + if httpx.HandleServiceError(c, err) { return } @@ -390,39 +420,35 @@ func ListContainerVersions(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param container_id path int true "Container ID" -// @Param version_id path int true "Container Version ID" -// @Param request body dto.UpdateContainerVersionReq true "Container version update request" -// @Success 202 {object} dto.GenericResponse[dto.ContainerVersionResp] "Container version updated successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid container ID/container version ID/request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Container not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param container_id path int true "Container ID" +// @Param version_id path int true "Container Version ID" +// @Param request body UpdateContainerVersionReq true "Container version update request" +// @Success 202 {object} dto.GenericResponse[ContainerVersionResp] "Container version updated successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid container ID/container version ID/request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Container not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/containers/{container_id}/versions/{version_id} [patch] -func UpdateContainerVersion(c *gin.Context) { - containerIDStr := c.Param(consts.URLPathContainerID) - containerID, err := strconv.Atoi(containerIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container ID") +// @x-api-type {"portal":"true"} +func (h *Handler) UpdateContainerVersion(c *gin.Context) { + containerID, ok := parseContainerID(c) + if !ok { return } - - versionIDStr := c.Param(consts.URLPathVersionID) - versionID, err := strconv.Atoi(versionIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container version ID") + versionID, ok := parseVersionID(c, "Invalid container version ID") + if !ok { return } - var req dto.UpdateContainerVersionReq + var req UpdateContainerVersionReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - resp, err := producer.UpdateContainerVersion(&req, containerID, versionID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.UpdateContainerVersion(c.Request.Context(), &req, containerID, versionID) + if httpx.HandleServiceError(c, err) { return } @@ -440,8 +466,8 @@ func UpdateContainerVersion(c *gin.Context) { // @Produce json // @Security BearerAuth // @Param id path int true "Container Version ID" -// @Param request body dto.SetContainerVersionImageReq true "Image reference components" -// @Success 200 {object} dto.GenericResponse[dto.SetContainerVersionImageResp] "Image rewritten successfully" +// @Param request body SetContainerVersionImageReq true "Image reference components" +// @Success 200 {object} dto.GenericResponse[SetContainerVersionImageResp] "Image rewritten successfully" // @Failure 400 {object} dto.GenericResponse[any] "Invalid request" // @Failure 401 {object} dto.GenericResponse[any] "Authentication required" // @Failure 403 {object} dto.GenericResponse[any] "Permission denied" @@ -449,7 +475,7 @@ func UpdateContainerVersion(c *gin.Context) { // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/container-versions/{id}/image [patch] // @x-api-type {"sdk":"true"} -func SetContainerVersionImage(c *gin.Context) { +func (h *Handler) SetContainerVersionImage(c *gin.Context) { idStr := c.Param("id") versionID, err := strconv.Atoi(idStr) if err != nil || versionID <= 0 { @@ -457,7 +483,7 @@ func SetContainerVersionImage(c *gin.Context) { return } - var req dto.SetContainerVersionImageReq + var req SetContainerVersionImageReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -467,59 +493,14 @@ func SetContainerVersionImage(c *gin.Context) { return } - resp, err := producer.SetContainerVersionImage(&req, versionID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.SetContainerVersionImage(c.Request.Context(), &req, versionID) + if httpx.HandleServiceError(c, err) { return } dto.JSONResponse[any](c, http.StatusOK, "Container version image updated successfully", resp) } -// ManageContainerCustomLabels manages container custom labels (key-value pairs) -// -// @Summary Manage container custom labels -// @Description Add or remove custom labels (key-value pairs) for a container -// @Tags Containers -// @ID manage_container_labels -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param container_id path int true "Container ID" -// @Param manage body dto.ManageContainerLabelReq true "Label management request" -// @Success 200 {object} dto.GenericResponse[dto.ContainerResp] "Labels managed successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid container ID or invalid request format/parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Container not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/containers/{container_id}/labels [patch] -func ManageContainerCustomLabels(c *gin.Context) { - containerIDStr := c.Param(consts.URLPathContainerID) - containerID, err := strconv.Atoi(containerIDStr) - if err != nil || containerID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container ID") - return - } - - var req dto.ManageContainerLabelReq - if err := c.ShouldBindJSON(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) - return - } - - resp, err := producer.ManageContainerLabels(&req, containerID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, resp) -} - // SubmitContainerBuilding handles submitting a container build task // // @Summary Submit container building @@ -529,16 +510,16 @@ func ManageContainerCustomLabels(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param request body dto.SubmitBuildContainerReq true "Container build request" -// @Success 200 {object} dto.GenericResponse[dto.SubmitContainerBuildResp] "Container build task submitted successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Required files not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param request body SubmitBuildContainerReq true "Container build request" +// @Success 200 {object} dto.GenericResponse[SubmitContainerBuildResp] "Container build task submitted successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Required files not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/containers/build [post] -// @x-api-type {"sdk":"true"} -func SubmitContainerBuilding(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) SubmitContainerBuilding(c *gin.Context) { groupID := c.GetString("groupID") userID, exists := middleware.GetCurrentUserID(c) if !exists || userID <= 0 { @@ -546,15 +527,7 @@ func SubmitContainerBuilding(c *gin.Context) { return } - ctx, ok := c.Get(middleware.SpanContextKey) - if !ok { - logrus.Error("Failed to get span context from gin.Context in SubmitBuildContainer") - dto.ErrorResponse(c, http.StatusInternalServerError, "Internal server error") - return - } - spanCtx := ctx.(context.Context) - - var req dto.SubmitBuildContainerReq + var req SubmitBuildContainerReq if err := c.ShouldBind(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -565,8 +538,8 @@ func SubmitContainerBuilding(c *gin.Context) { return } - resp, err := producer.ProduceContainerBuildingTask(spanCtx, &req, groupID, userID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.SubmitContainerBuilding(spanContextFromGin(c), &req, groupID, userID) + if httpx.HandleServiceError(c, err) { return } @@ -582,34 +555,30 @@ func SubmitContainerBuilding(c *gin.Context) { // @Accept multipart/form-data // @Produce json // @Security BearerAuth -// @Param container_id path int true "Container ID" -// @Param version_id path int true "Container Version ID" -// @Param file formData file true "Helm chart package (.tgz)" -// @Success 200 {object} dto.GenericResponse[dto.UploadHelmChartResp] "Chart uploaded successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request or file" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Container or version not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param container_id path int true "Container ID" +// @Param version_id path int true "Container Version ID" +// @Param file formData file true "Helm chart package (.tgz)" +// @Success 200 {object} dto.GenericResponse[UploadHelmChartResp] "Chart uploaded successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request or file" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Container or version not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/containers/{container_id}/versions/{version_id}/helm-chart [post] -func UploadHelmChart(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) UploadHelmChart(c *gin.Context) { userID, exists := middleware.GetCurrentUserID(c) if !exists || userID <= 0 { dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") return } - containerIDStr := c.Param(consts.URLPathContainerID) - containerID, err := strconv.Atoi(containerIDStr) - if err != nil || containerID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container ID") + containerID, ok := parseContainerID(c) + if !ok { return } - - versionIDStr := c.Param(consts.URLPathVersionID) - versionID, err := strconv.Atoi(versionIDStr) - if err != nil || versionID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container version ID") + versionID, ok := parseVersionID(c, "Invalid container version ID") + if !ok { return } @@ -619,16 +588,14 @@ func UploadHelmChart(c *gin.Context) { return } - filename := file.Filename - ext := filepath.Ext(filename) - if ext != ".tgz" && ext != ".tar.gz" { + ext := filepath.Ext(file.Filename) + if ext != ".tgz" && ext != ".gz" { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid file type: only .tgz or .tar.gz files are allowed") return } - // Call service layer to handle chart upload - resp, err := producer.UploadHelmChart(file, containerID, versionID, userID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.UploadHelmChart(c.Request.Context(), file, containerID, versionID, userID) + if httpx.HandleServiceError(c, err) { return } @@ -644,56 +611,79 @@ func UploadHelmChart(c *gin.Context) { // @Accept multipart/form-data // @Produce json // @Security BearerAuth -// @Param container_id path int true "Container ID" -// @Param version_id path int true "Container Version ID" -// @Param file formData file true "Helm values YAML file" -// @Success 200 {object} dto.GenericResponse[dto.UploadHelmValueFileResp] "File uploaded successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request or file" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Container or version not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param container_id path int true "Container ID" +// @Param version_id path int true "Container Version ID" +// @Param file formData file true "Helm values YAML file" +// @Success 200 {object} dto.GenericResponse[UploadHelmValueFileResp] "File uploaded successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request or file" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Container or version not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/containers/{container_id}/versions/{version_id}/helm-values [post] -func UploadHelmValueFile(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) UploadHelmValueFile(c *gin.Context) { userID, exists := middleware.GetCurrentUserID(c) if !exists || userID <= 0 { dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") return } - containerIDStr := c.Param(consts.URLPathContainerID) - containerID, err := strconv.Atoi(containerIDStr) - if err != nil || containerID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container ID") + containerID, ok := parseContainerID(c) + if !ok { return } - - versionIDStr := c.Param(consts.URLPathVersionID) - versionID, err := strconv.Atoi(versionIDStr) - if err != nil || versionID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container version ID") + versionID, ok := parseVersionID(c, "Invalid container version ID") + if !ok { return } - // Get uploaded file file, err := c.FormFile("file") if err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "No file uploaded or invalid file: "+err.Error()) return } - filename := file.Filename - ext := filepath.Ext(filename) + ext := filepath.Ext(file.Filename) if ext != ".yaml" && ext != ".yml" { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid file type: only .yaml or .yml files are allowed") return } - // Call service layer to handle file upload - resp, err := producer.UploadHelmValueFile(file, containerID, versionID, userID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.UploadHelmValueFile(c.Request.Context(), file, containerID, versionID, userID) + if httpx.HandleServiceError(c, err) { return } dto.SuccessResponse(c, resp) } + +func parseContainerID(c *gin.Context) (int, bool) { + containerIDStr := c.Param(consts.URLPathContainerID) + containerID, err := strconv.Atoi(containerIDStr) + if err != nil || containerID <= 0 { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container ID") + return 0, false + } + return containerID, true +} + +func parseVersionID(c *gin.Context, message string) (int, bool) { + versionIDStr := c.Param(consts.URLPathVersionID) + versionID, err := strconv.Atoi(versionIDStr) + if err != nil || versionID <= 0 { + dto.ErrorResponse(c, http.StatusBadRequest, message) + return 0, false + } + return versionID, true +} + +func spanContextFromGin(c *gin.Context) context.Context { + ctx, ok := c.Get(middleware.SpanContextKey) + if ok { + if spanCtx, ok := ctx.(context.Context); ok { + return spanCtx + } + } + return c.Request.Context() +} diff --git a/src/module/container/handler_service.go b/src/module/container/handler_service.go new file mode 100644 index 00000000..bb0b1a2d --- /dev/null +++ b/src/module/container/handler_service.go @@ -0,0 +1,32 @@ +package container + +import ( + "context" + + "aegis/dto" + + "mime/multipart" +) + +// HandlerService captures the container operations consumed by the HTTP handler. +type HandlerService interface { + CreateContainer(context.Context, *CreateContainerReq, int) (*ContainerResp, error) + DeleteContainer(context.Context, int) error + GetContainer(context.Context, int) (*ContainerDetailResp, error) + ListContainers(context.Context, *ListContainerReq) (*dto.ListResp[ContainerResp], error) + UpdateContainer(context.Context, *UpdateContainerReq, int) (*ContainerResp, error) + ManageContainerLabels(context.Context, *ManageContainerLabelReq, int) (*ContainerResp, error) + CreateContainerVersion(context.Context, *CreateContainerVersionReq, int, int) (*ContainerVersionResp, error) + DeleteContainerVersion(context.Context, int) error + GetContainerVersion(context.Context, int, int) (*ContainerVersionDetailResp, error) + ListContainerVersions(context.Context, *ListContainerVersionReq, int) (*dto.ListResp[ContainerVersionResp], error) + UpdateContainerVersion(context.Context, *UpdateContainerVersionReq, int, int) (*ContainerVersionResp, error) + SetContainerVersionImage(context.Context, *SetContainerVersionImageReq, int) (*SetContainerVersionImageResp, error) + SubmitContainerBuilding(context.Context, *SubmitBuildContainerReq, string, int) (*SubmitContainerBuildResp, error) + UploadHelmChart(context.Context, *multipart.FileHeader, int, int, int) (*UploadHelmChartResp, error) + UploadHelmValueFile(context.Context, *multipart.FileHeader, int, int, int) (*UploadHelmValueFileResp, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/container/module.go b/src/module/container/module.go new file mode 100644 index 00000000..16ab6eb0 --- /dev/null +++ b/src/module/container/module.go @@ -0,0 +1,12 @@ +package container + +import "go.uber.org/fx" + +var Module = fx.Module("container", + fx.Provide(NewRepository), + fx.Provide(NewBuildGateway), + fx.Provide(NewHelmFileStore), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/module/container/repository.go b/src/module/container/repository.go new file mode 100644 index 00000000..5fd063b9 --- /dev/null +++ b/src/module/container/repository.go @@ -0,0 +1,456 @@ +package container + +import ( + "aegis/consts" + "aegis/model" + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + containerCommonOmitFields = "active_name" + containerModelOmitFields = "Versions" + containerVersionModelOmitFields = "active_version_key,HelmConfig,EnvVars" + helmConfigModelOmitFields = "Values" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) getRoleByName(name string) (*model.Role, error) { + var role model.Role + if err := r.db.Where("name = ? and status != ?", name, consts.CommonDeleted).First(&role).Error; err != nil { + return nil, fmt.Errorf("failed to find role with name %s: %w", name, err) + } + return &role, nil +} + +func (r *Repository) createContainer(container *model.Container) error { + if err := r.db.Omit(containerCommonOmitFields, containerModelOmitFields).Create(container).Error; err != nil { + return fmt.Errorf("failed to create container: %w", err) + } + return nil +} + +func (r *Repository) createUserContainer(userContainer *model.UserContainer) error { + if err := r.db.Omit("active_user_container").Create(userContainer).Error; err != nil { + return fmt.Errorf("failed to create user-container association: %w", err) + } + return nil +} + +func (r *Repository) batchDeleteContainerVersions(containerID int) (int64, error) { + result := r.db.Model(&model.ContainerVersion{}). + Where("container_id = ? AND status != ?", containerID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if result.Error != nil { + return 0, fmt.Errorf("failed to batch soft delete container versions for container %d: %w", containerID, result.Error) + } + return result.RowsAffected, nil +} + +func (r *Repository) removeUsersFromContainer(containerID int) (int64, error) { + result := r.db.Model(&model.UserContainer{}). + Where("container_id = ? AND status != ?", containerID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if err := result.Error; err != nil { + return 0, fmt.Errorf("failed to delete user-container associations for container %d: %w", containerID, err) + } + return result.RowsAffected, nil +} + +func (r *Repository) clearContainerLabels(containerIDs []int, labelIDs []int) error { + if len(containerIDs) == 0 { + return nil + } + + query := r.db.Table("container_labels").Where("container_id IN (?)", containerIDs) + if len(labelIDs) > 0 { + query = query.Where("label_id IN (?)", labelIDs) + } + if err := query.Delete(nil).Error; err != nil { + return fmt.Errorf("failed to clear container-label associations: %w", err) + } + return nil +} + +func (r *Repository) deleteContainer(containerID int) (int64, error) { + result := r.db.Model(&model.Container{}). + Where("id = ? AND status != ?", containerID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if err := result.Error; err != nil { + return 0, fmt.Errorf("failed to delete container %d: %w", containerID, err) + } + return result.RowsAffected, nil +} + +func (r *Repository) getContainerByID(containerID int) (*model.Container, error) { + var container model.Container + if err := r.db.Where("id = ? AND status != ?", containerID, consts.CommonDeleted).First(&container).Error; err != nil { + return nil, fmt.Errorf("failed to find container with id %d: %w", containerID, err) + } + return &container, nil +} + +func (r *Repository) listContainerVersionsByContainerID(containerID int) ([]model.ContainerVersion, error) { + var versions []model.ContainerVersion + if err := r.db. + Preload("Container"). + Preload("HelmConfig"). + Where("container_id = ?", containerID). + Find(&versions).Error; err != nil { + return nil, fmt.Errorf("failed to list container versions for container %d: %w", containerID, err) + } + return versions, nil +} + +func (r *Repository) batchGetContainerVersions(containerType consts.ContainerType, containerNames []string, userID int) ([]model.ContainerVersion, error) { + if len(containerNames) == 0 { + return []model.ContainerVersion{}, nil + } + + var versions []model.ContainerVersion + query := r.db.Table("container_versions cv"). + Preload("Container"). + Where("cv.status = ?", consts.CommonEnabled). + Order("cv.container_id DESC, cv.name_major DESC, cv.name_minor DESC, cv.name_patch DESC") + + query = query.Joins("INNER JOIN containers c ON c.id = cv.container_id"). + Where("c.type = ? AND c.name IN (?) AND c.status = ?", containerType, containerNames, consts.CommonEnabled) + + if userID > 0 { + query = query.Joins( + "LEFT JOIN user_containers uc ON uc.container_id = c.id AND uc.user_id = ? AND uc.status = ?", + userID, consts.CommonEnabled, + ).Where( + r.db.Where("c.is_public = ?", true).Or("uc.container_id IS NOT NULL"), + ) + } + + if err := query.Find(&versions).Error; err != nil { + return nil, fmt.Errorf("failed to query container versions: %w", err) + } + return versions, nil +} + +func (r *Repository) checkContainerExistsWithDifferentType(containerName string, requestedType consts.ContainerType, userID int) (bool, consts.ContainerType, error) { + var container model.Container + query := r.db.Table("containers"). + Where("containers.name = ? AND containers.type != ? AND containers.status = ?", containerName, requestedType, consts.CommonEnabled) + + if userID > 0 { + query = query.Joins( + "LEFT JOIN user_containers uc ON uc.container_id = containers.id AND uc.user_id = ? AND uc.status = ?", + userID, consts.CommonEnabled, + ).Where( + r.db.Where("containers.is_public = ?", true).Or("uc.container_id IS NOT NULL"), + ) + } + + if err := query.First(&container).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return false, 0, nil + } + return false, 0, fmt.Errorf("failed to check container existence: %w", err) + } + return true, container.Type, nil +} + +func (r *Repository) listContainers(limit, offset int, containerType *consts.ContainerType, isPublic *bool, status *consts.StatusType) ([]model.Container, int64, error) { + var ( + containers []model.Container + total int64 + ) + + query := r.db.Model(&model.Container{}) + if containerType != nil { + query = query.Where("type = ?", *containerType) + } + if isPublic != nil { + query = query.Where("is_public = ?", *isPublic) + } + if status != nil { + query = query.Where("status = ?", *status) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count containers: %w", err) + } + if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&containers).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list containers: %w", err) + } + return containers, total, nil +} + +func (r *Repository) listContainerLabels(containerIDs []int) (map[int][]model.Label, error) { + if len(containerIDs) == 0 { + return nil, nil + } + + type containerLabelResult struct { + model.Label + ContainerID int `gorm:"column:container_id"` + } + + var flatResults []containerLabelResult + if err := r.db.Model(&model.Label{}). + Joins("JOIN container_labels cl ON cl.label_id = labels.id"). + Where("cl.container_id IN (?)", containerIDs). + Select("labels.*, cl.container_id"). + Find(&flatResults).Error; err != nil { + return nil, fmt.Errorf("failed to batch query container labels: %w", err) + } + + labelsMap := make(map[int][]model.Label, len(containerIDs)) + for _, id := range containerIDs { + labelsMap[id] = []model.Label{} + } + for _, res := range flatResults { + labelsMap[res.ContainerID] = append(labelsMap[res.ContainerID], res.Label) + } + return labelsMap, nil +} + +func (r *Repository) updateContainer(container *model.Container) error { + if err := r.db.Omit(containerCommonOmitFields).Save(container).Error; err != nil { + return fmt.Errorf("failed to update container: %w", err) + } + return nil +} + +func (r *Repository) addContainerLabels(containerLabels []model.ContainerLabel) error { + if len(containerLabels) == 0 { + return nil + } + if err := r.db.Create(&containerLabels).Error; err != nil { + return fmt.Errorf("failed to add container-label associations: %w", err) + } + return nil +} + +func (r *Repository) listLabelIDsByKeyAndContainerID(containerID int, keys []string) ([]int, error) { + var labelIDs []int + if err := r.db.Table("labels l"). + Select("l.id"). + Joins("JOIN container_labels cl ON cl.label_id = l.id"). + Where("cl.container_id = ? AND l.label_key IN (?)", containerID, keys). + Pluck("l.id", &labelIDs).Error; err != nil { + return nil, fmt.Errorf("failed to find label IDs by keys for container %d: %w", containerID, err) + } + return labelIDs, nil +} + +func (r *Repository) batchDecreaseLabelUsages(labelIDs []int, decrement int) error { + if len(labelIDs) == 0 { + return nil + } + + expr := gorm.Expr("GREATEST(0, usage_count - ?)", decrement) + if err := r.db.Model(&model.Label{}). + Where("id IN (?)", labelIDs). + Clauses(clause.Returning{}). + UpdateColumn("usage_count", expr).Error; err != nil { + return fmt.Errorf("failed to batch decrease label usages: %w", err) + } + return nil +} + +func (r *Repository) listLabelsByContainerID(containerID int) ([]model.Label, error) { + var labels []model.Label + if err := r.db.Model(&model.Label{}). + Joins("JOIN container_labels cl ON cl.label_id = labels.id"). + Where("cl.container_id = ?", containerID). + Find(&labels).Error; err != nil { + return nil, fmt.Errorf("failed to list labels for container %d: %w", containerID, err) + } + return labels, nil +} + +func (r *Repository) batchCreateContainerVersions(versions []model.ContainerVersion) error { + if len(versions) == 0 { + return fmt.Errorf("no container versions to create") + } + if err := r.db.Omit(containerVersionModelOmitFields).Create(&versions).Error; err != nil { + return fmt.Errorf("failed to batch create container versions: %w", err) + } + return nil +} + +func (r *Repository) batchCreateOrFindParameterConfigs(params []model.ParameterConfig) error { + if len(params) == 0 { + return nil + } + if err := r.db.Clauses(clause.OnConflict{OnConstraint: "idx_unique_config", DoNothing: true}).Create(¶ms).Error; err != nil { + return fmt.Errorf("failed to batch create parameter configs: %w", err) + } + return nil +} + +func (r *Repository) listParameterConfigsByKeys(configs []model.ParameterConfig) ([]model.ParameterConfig, error) { + if len(configs) == 0 { + return []model.ParameterConfig{}, nil + } + + var results []model.ParameterConfig + query := r.db.Model(&model.ParameterConfig{}) + conditions := r.db.Where("1 = 0") + for _, cfg := range configs { + conditions = conditions.Or(r.db.Where("config_key = ? AND type = ? AND category = ?", cfg.Key, cfg.Type, cfg.Category)) + } + if err := query.Where(conditions).Find(&results).Error; err != nil { + return nil, fmt.Errorf("failed to list parameter configs by keys: %w", err) + } + return results, nil +} + +func (r *Repository) listContainerVersionEnvVars(keys []string, containerVersionID int) ([]model.ParameterConfig, error) { + query := r.db.Model(&model.ParameterConfig{}). + Joins("JOIN container_version_env_vars cvev ON cvev.parameter_config_id = parameter_configs.id"). + Where("cvev.container_version_id = ?", containerVersionID). + Where("parameter_configs.category = ?", consts.ParameterCategoryEnvVars) + + if len(keys) > 0 { + query = query.Where("parameter_configs.config_key IN (?)", keys) + } + + var params []model.ParameterConfig + if err := query.Find(¶ms).Error; err != nil { + return nil, fmt.Errorf("failed to list container env vars: %w", err) + } + return params, nil +} + +func (r *Repository) listHelmConfigValues(keys []string, helmConfigID int) ([]model.ParameterConfig, error) { + query := r.db.Model(&model.ParameterConfig{}). + Joins("JOIN helm_config_values hcv ON hcv.parameter_config_id = parameter_configs.id"). + Where("hcv.helm_config_id = ?", helmConfigID) + + if len(keys) > 0 { + query = query.Where("parameter_configs.config_key IN (?)", keys) + } + + var params []model.ParameterConfig + if err := query.Find(¶ms).Error; err != nil { + return nil, fmt.Errorf("failed to list helm values: %w", err) + } + return params, nil +} + +func (r *Repository) addContainerVersionEnvVars(envVars []model.ContainerVersionEnvVar) error { + if len(envVars) == 0 { + return nil + } + if err := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&envVars).Error; err != nil { + return fmt.Errorf("failed to add container version env vars: %w", err) + } + return nil +} + +func (r *Repository) batchCreateHelmConfigs(helmConfigs []*model.HelmConfig) error { + if len(helmConfigs) == 0 { + return fmt.Errorf("no helm configs to create") + } + if err := r.db.Omit(helmConfigModelOmitFields).Create(helmConfigs).Error; err != nil { + return fmt.Errorf("failed to batch create helm configs: %v", err) + } + return nil +} + +func (r *Repository) addHelmConfigValues(helmValues []model.HelmConfigValue) error { + if len(helmValues) == 0 { + return nil + } + if err := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&helmValues).Error; err != nil { + return fmt.Errorf("failed to add helm config values: %w", err) + } + return nil +} + +func (r *Repository) deleteContainerVersion(versionID int) (int64, error) { + result := r.db.Model(&model.ContainerVersion{}). + Where("id = ? AND status != ?", versionID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if result.Error != nil { + return 0, fmt.Errorf("failed to soft delete container version %d: %w", versionID, result.Error) + } + return result.RowsAffected, nil +} + +func (r *Repository) getContainerVersionByID(versionID int) (*model.ContainerVersion, error) { + var version model.ContainerVersion + if err := r.db. + Preload("Container"). + Preload("HelmConfig"). + Where("id = ?", versionID). + First(&version).Error; err != nil { + return nil, fmt.Errorf("failed to find container version with id %d: %w", versionID, err) + } + return &version, nil +} + +func (r *Repository) listContainerVersions(limit, offset int, containerID int, status *consts.StatusType) ([]model.ContainerVersion, int64, error) { + var ( + versions []model.ContainerVersion + total int64 + ) + + query := r.db.Model(&model.ContainerVersion{}).Where("container_id = ?", containerID) + if status != nil { + query = query.Where("status = ?", *status) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count container versions: %v", err) + } + if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&versions).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list container versions: %v", err) + } + return versions, total, nil +} + +// updateContainerVersionImageColumns atomically rewrites the four image +// reference columns on a container_versions row. Used by PATCH +// /api/v2/container-versions/:id/image. +func (r *Repository) updateContainerVersionImageColumns(versionID int, registry, namespace, repository, tag string) (int64, error) { + result := r.db.Model(&model.ContainerVersion{}). + Where("id = ?", versionID). + Updates(map[string]any{ + "registry": registry, + "namespace": namespace, + "repository": repository, + "tag": tag, + }) + if err := result.Error; err != nil { + return 0, fmt.Errorf("failed to update container version image columns: %w", err) + } + return result.RowsAffected, nil +} + +func (r *Repository) updateContainerVersion(version *model.ContainerVersion) error { + if err := r.db.Omit(containerVersionModelOmitFields).Save(version).Error; err != nil { + return fmt.Errorf("failed to update container version: %w", err) + } + return nil +} + +func (r *Repository) getHelmConfigByContainerVersionID(versionID int) (*model.HelmConfig, error) { + var helmConfig model.HelmConfig + if err := r.db.Preload("ContainerVersion").Where("container_version_id = ?", versionID).First(&helmConfig).Error; err != nil { + return nil, fmt.Errorf("failed to find helm config for version id %d: %w", versionID, err) + } + return &helmConfig, nil +} + +func (r *Repository) updateHelmConfig(helmConfig *model.HelmConfig) error { + if err := r.db.Save(helmConfig).Error; err != nil { + return fmt.Errorf("failed to update helm config: %w", err) + } + return nil +} diff --git a/src/service/common/container.go b/src/module/container/resolve.go similarity index 54% rename from src/service/common/container.go rename to src/module/container/resolve.go index b089ca0a..b85e49c5 100644 --- a/src/service/common/container.go +++ b/src/module/container/resolve.go @@ -1,56 +1,54 @@ -package common +package container import ( "aegis/consts" - "aegis/database" "aegis/dto" - "aegis/repository" + "aegis/model" "aegis/utils" "fmt" + "reflect" + "regexp" + "strings" ) -// ListContainerVersionEnvVars retrieves and validates environment variables for a container version based on provided specs -func ListContainerVersionEnvVars(specs []dto.ParameterSpec, version *database.ContainerVersion) ([]dto.ParameterItem, error) { - return listParameterItems(specs, repository.ListContainerVersionEnvVars, version.ID, version) +var templateVarRegex = regexp.MustCompile(`{{\s*\.([a-zA-Z0-9_]+)\s*}}`) + +func (r *Repository) ListContainerVersionEnvVars(specs []dto.ParameterSpec, version *model.ContainerVersion) ([]dto.ParameterItem, error) { + return listParameterItemsWithDB(r, specs, r.listContainerVersionEnvVars, version.ID, version) } -// ListHelmConfigValues retrieves and validates Helm values based on provided specs and Helm configuration -func ListHelmConfigValues(specs []dto.ParameterSpec, cfg *database.HelmConfig) ([]dto.ParameterItem, error) { - return listParameterItems(specs, repository.ListHelmConfigValues, cfg.ID, cfg.ContainerVersion) +func (r *Repository) ListHelmConfigValues(specs []dto.ParameterSpec, cfg *model.HelmConfig) ([]dto.ParameterItem, error) { + return listParameterItemsWithDB(r, specs, r.listHelmConfigValues, cfg.ID, cfg.ContainerVersion) } -// MapRefsToContainerVersions maps container refs to their corresponding container versions -func MapRefsToContainerVersions(refs []*dto.ContainerRef, containerType consts.ContainerType, userID int) (map[*dto.ContainerRef]database.ContainerVersion, error) { - versions, err := getUniqueVersionsForContainerRefs(refs, containerType, userID) +func (r *Repository) ResolveContainerVersions(refs []*dto.ContainerRef, containerType consts.ContainerType, userID int) (map[*dto.ContainerRef]model.ContainerVersion, error) { + versions, err := getUniqueVersionsForContainerRefs(r, refs, containerType, userID) if err != nil { return nil, fmt.Errorf("failed to batch get container versions: %w", err) } - flatMap := make(map[string][]database.ContainerVersion) - hierarchicalMap := make(map[string]map[string]database.ContainerVersion) - + flatMap := make(map[string][]model.ContainerVersion) + hierarchicalMap := make(map[string]map[string]model.ContainerVersion) for _, version := range versions { containerName := version.Container.Name versionName := version.Name flatMap[containerName] = append(flatMap[containerName], version) - if _, exists := hierarchicalMap[containerName]; !exists { - hierarchicalMap[containerName] = make(map[string]database.ContainerVersion) + hierarchicalMap[containerName] = make(map[string]model.ContainerVersion) } hierarchicalMap[containerName][versionName] = version } - results := make(map[*dto.ContainerRef]database.ContainerVersion, len(refs)) + results := make(map[*dto.ContainerRef]model.ContainerVersion, len(refs)) for _, ref := range refs { - var result database.ContainerVersion + var result model.ContainerVersion containerTypeName := consts.GetContainerTypeName(containerType) if ref.Version != "" { if _, exists := hierarchicalMap[ref.Name]; !exists { availableContainers := getAvailableContainerNames(hierarchicalMap) if len(availableContainers) == 0 { - // Check if container exists with different type - exists, actualType, err := repository.CheckContainerExistsWithDifferentType(database.DB, ref.Name, containerType, userID) + exists, actualType, err := r.checkContainerExistsWithDifferentType(ref.Name, containerType, userID) if err != nil { return nil, fmt.Errorf("failed to check container type: %w", err) } @@ -67,14 +65,12 @@ func MapRefsToContainerVersions(refs []*dto.ContainerRef, containerType consts.C if _, exists := hierarchicalMap[ref.Name][ref.Version]; !exists { return nil, fmt.Errorf("%s container version not found: %s:%s (available versions for %s: %v)", containerTypeName, ref.Name, ref.Version, ref.Name, getAvailableVersions(hierarchicalMap, ref.Name)) } - result = hierarchicalMap[ref.Name][ref.Version] } else { if _, exists := flatMap[ref.Name]; !exists { availableContainers := getAvailableContainerNames(hierarchicalMap) if len(availableContainers) == 0 { - // Check if container exists with different type - exists, actualType, err := repository.CheckContainerExistsWithDifferentType(database.DB, ref.Name, containerType, userID) + exists, actualType, err := r.checkContainerExistsWithDifferentType(ref.Name, containerType, userID) if err != nil { return nil, fmt.Errorf("failed to check container type: %w", err) } @@ -89,69 +85,56 @@ func MapRefsToContainerVersions(refs []*dto.ContainerRef, containerType consts.C } result = flatMap[ref.Name][0] } - results[ref] = result } return results, nil } -// getUniqueVersionsForContainerRefs retrieves unique container versions for the given container refs -func getUniqueVersionsForContainerRefs(refs []*dto.ContainerRef, containerType consts.ContainerType, userID int) ([]database.ContainerVersion, error) { +func getUniqueVersionsForContainerRefs(repo *Repository, refs []*dto.ContainerRef, containerType consts.ContainerType, userID int) ([]model.ContainerVersion, error) { containerNamesSet := make(map[string]struct{}, len(refs)) for _, ref := range refs { if ref.Name != "" { containerNamesSet[ref.Name] = struct{}{} } } - if len(containerNamesSet) == 0 { - return []database.ContainerVersion{}, nil + return []model.ContainerVersion{}, nil } requiredNames := make([]string, 0, len(containerNamesSet)) for name := range containerNamesSet { requiredNames = append(requiredNames, name) } - - versions, err := repository.BatchGetContainerVersions(database.DB, containerType, requiredNames, userID) - if err != nil { - return nil, fmt.Errorf("failed to batch get container versions: %w", err) - } - - return versions, nil + return repo.batchGetContainerVersions(containerType, requiredNames, userID) } -// listParameterItems retrieves and validates parameter items based on provided specs and a parameter config fetcher -func listParameterItems(specs []dto.ParameterSpec, fetcher repository.ParameterConfigFetcher, resourceID int, contextCfg any) ([]dto.ParameterItem, error) { +func listParameterItemsWithDB(repo *Repository, specs []dto.ParameterSpec, fetcher func([]string, int) ([]model.ParameterConfig, error), resourceID int, contextCfg any) ([]dto.ParameterItem, error) { keys := make([]string, 0, len(specs)) for _, item := range specs { keys = append(keys, item.Key) } - paramConfigs, err := fetcher(database.DB, keys, resourceID) + paramConfigs, err := fetcher(keys, resourceID) if err != nil { return nil, fmt.Errorf("failed to list configurations: %w", err) } - if len(paramConfigs) == 0 && len(specs) > 0 { return nil, fmt.Errorf("no configurations found for the provided specs") } - paramConfigMap := make(map[string]database.ParameterConfig, len(paramConfigs)) + paramConfigMap := make(map[string]model.ParameterConfig, len(paramConfigs)) for _, config := range paramConfigs { paramConfigMap[config.Key] = config } processedParamConfigs := make(map[string]struct{}) - items := make([]dto.ParameterItem, 0, len(specs)) for _, spec := range specs { config, exists := paramConfigMap[spec.Key] if !exists { return nil, fmt.Errorf("configuration not found for key: %s", spec.Key) } - processedParamConfigs[spec.Key] = struct{}{} item, err := processParameterConfig(config, spec.Value, contextCfg) @@ -163,23 +146,23 @@ func listParameterItems(specs []dto.ParameterSpec, fetcher repository.ParameterC } } - for _, paramConfigMap := range paramConfigMap { - if _, processed := processedParamConfigs[paramConfigMap.Key]; !processed { - item, err := processParameterConfig(paramConfigMap, nil, contextCfg) - if err != nil { - return nil, fmt.Errorf("failed to process parameter config for key %s: %w", paramConfigMap.Key, err) - } - if item != nil { - items = append(items, *item) - } + for _, paramConfig := range paramConfigMap { + if _, processed := processedParamConfigs[paramConfig.Key]; processed { + continue + } + item, err := processParameterConfig(paramConfig, nil, contextCfg) + if err != nil { + return nil, fmt.Errorf("failed to process parameter config for key %s: %w", paramConfig.Key, err) + } + if item != nil { + items = append(items, *item) } } return items, nil } -// processParameterConfig processes a single parameter configuration and returns the corresponding parameter item -func processParameterConfig(config database.ParameterConfig, userValue any, contextCfg any) (*dto.ParameterItem, error) { +func processParameterConfig(config model.ParameterConfig, userValue any, contextCfg any) (*dto.ParameterItem, error) { switch config.Type { case consts.ParameterTypeFixed: finalValue := userValue @@ -194,23 +177,14 @@ func processParameterConfig(config database.ParameterConfig, userValue any, cont finalValue = convertedValue } } - - return &dto.ParameterItem{ - Key: config.Key, - Value: finalValue, - }, nil - + return &dto.ParameterItem{Key: config.Key, Value: finalValue}, nil case consts.ParameterTypeDynamic: if config.TemplateString == nil || *config.TemplateString == "" { return nil, fmt.Errorf("dynamic parameter %s is missing a template string", config.Key) } - templateVars := extractTemplateVars(*config.TemplateString) if len(templateVars) == 0 { - return &dto.ParameterItem{ - Key: config.Key, - TemplateString: *config.TemplateString, - }, nil + return &dto.ParameterItem{Key: config.Key, TemplateString: *config.TemplateString}, nil } renderedValue, err := renderTemplate(*config.TemplateString, templateVars, contextCfg) @@ -221,35 +195,72 @@ func processParameterConfig(config database.ParameterConfig, userValue any, cont return nil, fmt.Errorf("required dynamic parameter %s rendered to an empty string", config.Key) } if renderedValue != "" { - return &dto.ParameterItem{ - Key: config.Key, - Value: renderedValue, - }, nil + return &dto.ParameterItem{Key: config.Key, Value: renderedValue}, nil } - return nil, nil default: - return nil, fmt.Errorf("unknown parameter type for key %s", config.Key) + return nil, fmt.Errorf("unsupported parameter type: %v", config.Type) + } +} + +func extractTemplateVars(templateString string) []string { + matches := templateVarRegex.FindAllStringSubmatch(templateString, -1) + if matches == nil { + return nil + } + + variables := make([]string, 0, len(matches)) + for _, match := range matches { + if len(match) > 1 { + variables = append(variables, match[1]) + } + } + return variables +} + +func renderTemplate(templateStr string, vars []string, context any) (string, error) { + contextValue := reflect.ValueOf(context) + if contextValue.Kind() == reflect.Ptr { + contextValue = contextValue.Elem() } + + renderedString := templateStr + contextType := contextValue.Type() + for _, varName := range vars { + fieldValue := contextValue.FieldByName(varName) + if !fieldValue.IsValid() { + return "", fmt.Errorf("variable '%s' not found in context structure", varName) + } + + fieldType, found := contextType.FieldByName(varName) + if !found || fieldType.PkgPath != "" { + return "", fmt.Errorf("variable '%s' is not an exported field in context", varName) + } + + strValue, err := utils.ConvertSimpleTypeToString(fieldValue.Interface()) + if err != nil { + return "", fmt.Errorf("failed to convert context value for %s: %w", varName, err) + } + + renderedString = strings.ReplaceAll(renderedString, fmt.Sprintf("{{ .%s }}", varName), strValue) + renderedString = strings.ReplaceAll(renderedString, fmt.Sprintf("{{.%s}}", varName), strValue) + } + return renderedString, nil } -// getAvailableContainerNames returns a list of available container names from the hierarchical map -func getAvailableContainerNames(hierarchicalMap map[string]map[string]database.ContainerVersion) []string { - names := make([]string, 0, len(hierarchicalMap)) - for name := range hierarchicalMap { +func getAvailableContainerNames(versions map[string]map[string]model.ContainerVersion) []string { + names := make([]string, 0, len(versions)) + for name := range versions { names = append(names, name) } return names } -// getAvailableVersions returns a list of available versions for a specific container -func getAvailableVersions(hierarchicalMap map[string]map[string]database.ContainerVersion, containerName string) []string { - if versions, exists := hierarchicalMap[containerName]; exists { - versionNames := make([]string, 0, len(versions)) - for versionName := range versions { - versionNames = append(versionNames, versionName) - } - return versionNames +func getAvailableVersions(versions map[string]map[string]model.ContainerVersion, containerName string) []string { + items := versions[containerName] + results := make([]string, 0, len(items)) + for version := range items { + results = append(results, version) } - return []string{} + return results } diff --git a/src/module/container/service.go b/src/module/container/service.go new file mode 100644 index 00000000..2e06a5e2 --- /dev/null +++ b/src/module/container/service.go @@ -0,0 +1,693 @@ +package container + +import ( + "context" + "errors" + "fmt" + "mime/multipart" + + "aegis/consts" + "aegis/dto" + redis "aegis/infra/redis" + "aegis/model" + label "aegis/module/label" + "aegis/service/common" + + "gorm.io/gorm" +) + +type Service struct { + repo *Repository + build *BuildGateway + helmFiles *HelmFileStore + redis *redis.Gateway +} + +func NewService(repo *Repository, build *BuildGateway, helmFiles *HelmFileStore, redis *redis.Gateway) *Service { + return &Service{repo: repo, build: build, helmFiles: helmFiles, redis: redis} +} + +func (s *Service) CreateContainer(_ context.Context, req *CreateContainerReq, userID int) (*ContainerResp, error) { + if req == nil { + return nil, fmt.Errorf("request cannot be nil") + } + + container := req.ConvertToContainer() + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + createdContainer, err := s.createContainerCore(NewRepository(tx), container, userID) + if err != nil { + return fmt.Errorf("failed to create container: %w", err) + } + container = createdContainer + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to create container: %w", err) + } + + return NewContainerResp(container), nil +} + +func (s *Service) DeleteContainer(_ context.Context, containerID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if _, err := repo.batchDeleteContainerVersions(containerID); err != nil { + return fmt.Errorf("failed to delete container versions: %w", err) + } + if _, err := repo.removeUsersFromContainer(containerID); err != nil { + return fmt.Errorf("failed to remove all users from container: %w", err) + } + if err := repo.clearContainerLabels([]int{containerID}, nil); err != nil { + return fmt.Errorf("failed to clear container labels: %w", err) + } + rows, err := repo.deleteContainer(containerID) + if err != nil { + return fmt.Errorf("failed to delete container: %w", err) + } + if rows == 0 { + return fmt.Errorf("%w: container id %d not found", consts.ErrNotFound, containerID) + } + return nil + }) +} + +func (s *Service) GetContainer(_ context.Context, containerID int) (*ContainerDetailResp, error) { + container, err := s.repo.getContainerByID(containerID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: container id: %d", consts.ErrNotFound, containerID) + } + return nil, fmt.Errorf("failed to get container: %w", err) + } + + versions, err := s.repo.listContainerVersionsByContainerID(container.ID) + if err != nil { + return nil, fmt.Errorf("failed to get container versions: %w", err) + } + + resp := NewContainerDetailResp(container) + for _, version := range versions { + resp.Versions = append(resp.Versions, *NewContainerVersionResp(&version)) + } + + return resp, nil +} + +func (s *Service) ListContainers(_ context.Context, req *ListContainerReq) (*dto.ListResp[ContainerResp], error) { + limit, offset := req.ToGormParams() + + containers, total, err := s.repo.listContainers(limit, offset, req.Type, req.IsPublic, req.Status) + if err != nil { + return nil, fmt.Errorf("failed to list containers: %w", err) + } + + containerIDs := make([]int, 0, len(containers)) + for _, container := range containers { + containerIDs = append(containerIDs, container.ID) + } + + labelsMap, err := s.repo.listContainerLabels(containerIDs) + if err != nil { + return nil, fmt.Errorf("failed to list container labels: %w", err) + } + + items := make([]ContainerResp, 0, len(containers)) + for i := range containers { + if labels, ok := labelsMap[containers[i].ID]; ok { + containers[i].Labels = labels + } + items = append(items, *NewContainerResp(&containers[i])) + } + + return &dto.ListResp[ContainerResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) UpdateContainer(_ context.Context, req *UpdateContainerReq, containerID int) (*ContainerResp, error) { + var updatedContainer *model.Container + + if err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + container, err := repo.getContainerByID(containerID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: container with id %d not found", consts.ErrNotFound, containerID) + } + return fmt.Errorf("failed to get container: %w", err) + } + + req.PatchContainerModel(container) + if err := repo.updateContainer(container); err != nil { + return fmt.Errorf("failed to update container: %w", err) + } + + updatedContainer = container + return nil + }); err != nil { + return nil, err + } + + return NewContainerResp(updatedContainer), nil +} + +func (s *Service) ManageContainerLabels(_ context.Context, req *ManageContainerLabelReq, containerID int) (*ContainerResp, error) { + if req == nil { + return nil, fmt.Errorf("request cannot be nil") + } + + var managedContainer *model.Container + if err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + container, err := repo.getContainerByID(containerID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: container not found", consts.ErrNotFound) + } + return err + } + + if len(req.AddLabels) > 0 { + labels, err := label.NewRepository(tx).CreateOrUpdateLabelsFromItems(tx, req.AddLabels, consts.ContainerCategory) + if err != nil { + return fmt.Errorf("failed to create or update labels: %w", err) + } + + containerLabels := make([]model.ContainerLabel, 0, len(labels)) + for _, label := range labels { + containerLabels = append(containerLabels, model.ContainerLabel{ + ContainerID: containerID, + LabelID: label.ID, + }) + } + + if err := repo.addContainerLabels(containerLabels); err != nil { + return fmt.Errorf("failed to add container labels: %w", err) + } + } + + if len(req.RemoveLabels) > 0 { + labelIDs, err := repo.listLabelIDsByKeyAndContainerID(containerID, req.RemoveLabels) + if err != nil { + return fmt.Errorf("failed to find label IDs: %w", err) + } + + if len(labelIDs) > 0 { + if err := repo.clearContainerLabels([]int{containerID}, labelIDs); err != nil { + return fmt.Errorf("failed to delete container-label associations: %w", err) + } + + if err := repo.batchDecreaseLabelUsages(labelIDs, 1); err != nil { + return fmt.Errorf("failed to decrease label usage counts: %w", err) + } + } + } + + labels, err := repo.listLabelsByContainerID(container.ID) + if err != nil { + return fmt.Errorf("failed to get container labels: %w", err) + } + + container.Labels = labels + managedContainer = container + return nil + }); err != nil { + return nil, err + } + + return NewContainerResp(managedContainer), nil +} + +func (s *Service) CreateContainerVersion(_ context.Context, req *CreateContainerVersionReq, containerID, userID int) (*ContainerVersionResp, error) { + if req == nil { + return nil, fmt.Errorf("create container version request is nil") + } + + version := req.ConvertToContainerVersion() + version.ContainerID = containerID + version.UserID = userID + + var createdVersion *model.ContainerVersion + if err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + versions, err := s.createContainerVersionsCore(repo, []model.ContainerVersion{*version}) + if err != nil { + return fmt.Errorf("failed to create container version: %w", err) + } + + createdVersion = &versions[0] + return nil + }); err != nil { + return nil, fmt.Errorf("failed to create container version: %w", err) + } + + return NewContainerVersionResp(createdVersion), nil +} + +func (s *Service) DeleteContainerVersion(_ context.Context, versionID int) error { + rows, err := s.repo.deleteContainerVersion(versionID) + if err != nil { + return fmt.Errorf("failed to delete container version: %w", err) + } + if rows == 0 { + return fmt.Errorf("%w: container version id %d not found", consts.ErrNotFound, versionID) + } + return nil +} + +func (s *Service) GetContainerVersion(_ context.Context, containerID, versionID int) (*ContainerVersionDetailResp, error) { + if _, err := s.repo.getContainerByID(containerID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: container id: %d", consts.ErrNotFound, containerID) + } + return nil, fmt.Errorf("failed to get container: %w", err) + } + + version, err := s.repo.getContainerVersionByID(versionID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) + } + return nil, fmt.Errorf("failed to get container version: %w", err) + } + + resp := NewContainerVersionDetailResp(version) + + helmConfig, err := s.repo.getHelmConfigByContainerVersionID(version.ID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("failed to get helm config: %w", err) + } + if helmConfig != nil { + helmConfigResp, err := NewHelmConfigDetailResp(helmConfig) + if err != nil { + return nil, fmt.Errorf("failed to convert helm config: %w", err) + } + resp.HelmConfig = helmConfigResp + } + + return resp, nil +} + +func (s *Service) ListContainerVersions(_ context.Context, req *ListContainerVersionReq, containerID int) (*dto.ListResp[ContainerVersionResp], error) { + limit, offset := req.ToGormParams() + + versions, total, err := s.repo.listContainerVersions(limit, offset, containerID, req.Status) + if err != nil { + return nil, fmt.Errorf("failed to list container versions: %w", err) + } + + items := make([]ContainerVersionResp, len(versions)) + for i := range versions { + items[i] = *NewContainerVersionResp(&versions[i]) + } + + return &dto.ListResp[ContainerVersionResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) UpdateContainerVersion(_ context.Context, req *UpdateContainerVersionReq, containerID, versionID int) (*ContainerVersionResp, error) { + _ = containerID + + var updatedVersion *model.ContainerVersion + if err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + version, err := repo.getContainerVersionByID(versionID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) + } + return fmt.Errorf("failed to get container version: %w", err) + } + + req.PatchContainerVersionModel(version) + if err := repo.updateContainerVersion(version); err != nil { + return fmt.Errorf("failed to update container version: %w", err) + } + + updatedVersion = version + + if req.HelmConfigRequest != nil { + helmConfig, err := repo.getHelmConfigByContainerVersionID(version.ID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("helm config not found for version id %d", versionID) + } + return fmt.Errorf("failed to get helm config: %w", err) + } + + if err := req.HelmConfigRequest.PatchHelmConfigModel(helmConfig); err != nil { + return fmt.Errorf("failed to patch helm config model: %w", err) + } + if err := repo.updateHelmConfig(helmConfig); err != nil { + return fmt.Errorf("failed to update helm config: %w", err) + } + } + + return nil + }); err != nil { + return nil, err + } + + return NewContainerVersionResp(updatedVersion), nil +} + +// SetContainerVersionImage atomically rewrites the four image reference +// columns (registry, namespace, repository, tag) on a container_versions row. +func (s *Service) SetContainerVersionImage(_ context.Context, req *SetContainerVersionImageReq, versionID int) (*SetContainerVersionImageResp, error) { + var updated *model.ContainerVersion + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if _, err := repo.getContainerVersionByID(versionID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) + } + return fmt.Errorf("failed to get container version: %w", err) + } + + rows, err := repo.updateContainerVersionImageColumns(versionID, req.Registry, req.Namespace, req.Repository, req.Tag) + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) + } + + refreshed, err := repo.getContainerVersionByID(versionID) + if err != nil { + return fmt.Errorf("failed to reload container version: %w", err) + } + updated = refreshed + return nil + }) + if err != nil { + return nil, err + } + + return NewSetContainerVersionImageResp(updated), nil +} + +func (s *Service) SubmitContainerBuilding(ctx context.Context, req *SubmitBuildContainerReq, groupID string, userID int) (*SubmitContainerBuildResp, error) { + if req == nil { + return nil, fmt.Errorf("build container request is nil") + } + db := s.repo.db + + sourcePath, err := s.build.PrepareGitHubSource(req) + if err != nil { + return nil, fmt.Errorf("failed to process GitHub source: %w", err) + } + + if err := req.ValidateInfoContent(sourcePath); err != nil { + return nil, fmt.Errorf("invalid container info content: %w", err) + } + if err := req.Options.ValidateRequiredFiles(sourcePath); err != nil { + return nil, fmt.Errorf("invalid container options: %w", err) + } + + imageRef := s.build.BuildImageRef(req.ImageName, req.Tag) + payload := map[string]any{ + consts.BuildImageRef: imageRef, + consts.BuildSourcePath: sourcePath, + consts.BuildBuildOptions: req.Options, + } + + task := &dto.UnifiedTask{ + Type: consts.TaskTypeBuildContainer, + Immediate: true, + Payload: payload, + GroupID: groupID, + UserID: userID, + State: consts.TaskPending, + } + task.SetGroupCtx(ctx) + + if err := common.SubmitTaskWithDB(ctx, db, s.redis, task); err != nil { + return nil, fmt.Errorf("failed to submit container building task: %w", err) + } + + return &SubmitContainerBuildResp{ + GroupID: task.GroupID, + TraceID: task.TraceID, + TaskID: task.TaskID, + }, nil +} + +func (s *Service) UploadHelmChart(_ context.Context, file *multipart.FileHeader, containerID, versionID, userID int) (*UploadHelmChartResp, error) { + _ = userID + + containerVersion, err := s.validateHelmConfigVersion(containerID, versionID) + if err != nil { + return nil, err + } + + targetPath, checksum, err := s.helmFiles.SaveChart(containerVersion.Container.Name, file) + if err != nil { + return nil, err + } + filename := file.Filename + containerVersion.HelmConfig.LocalPath = targetPath + containerVersion.HelmConfig.Checksum = checksum + if err := s.repo.updateHelmConfig(containerVersion.HelmConfig); err != nil { + return nil, fmt.Errorf("failed to update helm config: %w", err) + } + + return &UploadHelmChartResp{ + FilePath: targetPath, + FileName: filename, + Checksum: checksum, + }, nil +} + +func (s *Service) UploadHelmValueFile(_ context.Context, file *multipart.FileHeader, containerID, versionID, userID int) (*UploadHelmValueFileResp, error) { + _ = userID + + containerVersion, err := s.validateHelmConfigVersion(containerID, versionID) + if err != nil { + return nil, err + } + + if err := s.uploadHelmValueFileCore(containerVersion.Container.Name, containerVersion.HelmConfig, file, ""); err != nil { + return nil, fmt.Errorf("failed to upload helm value file: %w", err) + } + + return &UploadHelmValueFileResp{ + FilePath: containerVersion.HelmConfig.ValueFile, + FileName: file.Filename, + }, nil +} + +func (s *Service) createContainerCore(repo *Repository, container *model.Container, userID int) (*model.Container, error) { + role, err := repo.getRoleByName(consts.RoleContainerAdmin.String()) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: role %v not found", consts.ErrNotFound, consts.RoleContainerAdmin) + } + return nil, fmt.Errorf("failed to get project owner role: %w", err) + } + + if err := repo.createContainer(container); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return nil, consts.ErrAlreadyExists + } + return nil, err + } + + if err := repo.createUserContainer(&model.UserContainer{ + UserID: userID, + ContainerID: container.ID, + RoleID: role.ID, + Status: consts.CommonEnabled, + }); err != nil { + return nil, fmt.Errorf("failed to associate container with user: %w", err) + } + + if len(container.Versions) > 0 { + for i := range container.Versions { + container.Versions[i].ContainerID = container.ID + container.Versions[i].UserID = userID + } + + if _, err := s.createContainerVersionsCore(repo, container.Versions); err != nil { + return nil, fmt.Errorf("failed to create container versions: %w", err) + } + } + + return container, nil +} + +func (s *Service) createContainerVersionsCore(repo *Repository, versions []model.ContainerVersion) ([]model.ContainerVersion, error) { + if len(versions) == 0 { + return nil, nil + } + + if err := repo.batchCreateContainerVersions(versions); err != nil { + return nil, fmt.Errorf("failed to create container versions: %w", err) + } + + type envVarWithVersionIdx struct { + envVar model.ParameterConfig + versionIdx int + } + + envVarsWithIdx := make([]envVarWithVersionIdx, 0) + for versionIdx, version := range versions { + for _, envVar := range version.EnvVars { + envVarsWithIdx = append(envVarsWithIdx, envVarWithVersionIdx{ + envVar: envVar, + versionIdx: versionIdx, + }) + } + } + + if len(envVarsWithIdx) > 0 { + envVars := make([]model.ParameterConfig, len(envVarsWithIdx)) + for i, item := range envVarsWithIdx { + envVars[i] = item.envVar + } + + if err := repo.batchCreateOrFindParameterConfigs(envVars); err != nil { + return nil, fmt.Errorf("failed to create parameter configs: %w", err) + } + + actualEnvVars, err := repo.listParameterConfigsByKeys(envVars) + if err != nil { + return nil, fmt.Errorf("failed to list parameter configs: %w", err) + } + + configMap := make(map[string]int, len(actualEnvVars)) + for _, cfg := range actualEnvVars { + key := fmt.Sprintf("%s:%d:%d", cfg.Key, cfg.Type, cfg.Category) + configMap[key] = cfg.ID + } + + relations := make([]model.ContainerVersionEnvVar, 0, len(envVarsWithIdx)) + for _, item := range envVarsWithIdx { + cfg := item.envVar + key := fmt.Sprintf("%s:%d:%d", cfg.Key, cfg.Type, cfg.Category) + paramID, ok := configMap[key] + if !ok { + return nil, fmt.Errorf("parameter config not found after creation: %s", key) + } + relations = append(relations, model.ContainerVersionEnvVar{ + ContainerVersionID: versions[item.versionIdx].ID, + ParameterConfigID: paramID, + }) + } + + if err := repo.addContainerVersionEnvVars(relations); err != nil { + return nil, fmt.Errorf("failed to create container version env var relations: %w", err) + } + } + + helmConfigs := make([]*model.HelmConfig, 0) + for versionIdx := range versions { + if versions[versionIdx].HelmConfig != nil { + versions[versionIdx].HelmConfig.ContainerVersionID = versions[versionIdx].ID + helmConfigs = append(helmConfigs, versions[versionIdx].HelmConfig) + } + } + + if len(helmConfigs) == 0 { + return versions, nil + } + + if err := repo.batchCreateHelmConfigs(helmConfigs); err != nil { + return nil, fmt.Errorf("failed to create helm configs: %w", err) + } + + type helmValueWithConfigIdx struct { + value model.ParameterConfig + helmConfigIdx int + } + + helmValuesWithIdx := make([]helmValueWithConfigIdx, 0) + for helmConfigIdx, helmConfig := range helmConfigs { + for _, value := range helmConfig.DynamicValues { + helmValuesWithIdx = append(helmValuesWithIdx, helmValueWithConfigIdx{ + value: value, + helmConfigIdx: helmConfigIdx, + }) + } + } + + if len(helmValuesWithIdx) == 0 { + return versions, nil + } + + helmValues := make([]model.ParameterConfig, len(helmValuesWithIdx)) + for i, item := range helmValuesWithIdx { + helmValues[i] = item.value + } + + if err := repo.batchCreateOrFindParameterConfigs(helmValues); err != nil { + return nil, fmt.Errorf("failed to create helm parameter configs: %w", err) + } + + actualHelmValues, err := repo.listParameterConfigsByKeys(helmValues) + if err != nil { + return nil, fmt.Errorf("failed to list helm parameter configs: %w", err) + } + + configMap := make(map[string]int, len(actualHelmValues)) + for _, cfg := range actualHelmValues { + key := fmt.Sprintf("%s:%d:%d", cfg.Key, cfg.Type, cfg.Category) + configMap[key] = cfg.ID + } + + relations := make([]model.HelmConfigValue, 0, len(helmValuesWithIdx)) + for _, item := range helmValuesWithIdx { + cfg := item.value + key := fmt.Sprintf("%s:%d:%d", cfg.Key, cfg.Type, cfg.Category) + paramID, ok := configMap[key] + if !ok { + return nil, fmt.Errorf("helm parameter config not found after creation: %s", key) + } + relations = append(relations, model.HelmConfigValue{ + HelmConfigID: helmConfigs[item.helmConfigIdx].ID, + ParameterConfigID: paramID, + }) + } + + if err := repo.addHelmConfigValues(relations); err != nil { + return nil, fmt.Errorf("failed to create helm config value relations: %w", err) + } + + return versions, nil +} + +func (s *Service) uploadHelmValueFileCore(containerName string, helmConfig *model.HelmConfig, srcFileHeader *multipart.FileHeader, srcFilePath string) error { + targetPath, err := s.helmFiles.SaveValueFile(containerName, srcFileHeader, srcFilePath) + if err != nil { + return err + } + helmConfig.ValueFile = targetPath + if err := s.repo.updateHelmConfig(helmConfig); err != nil { + return fmt.Errorf("failed to update helm config: %w", err) + } + + return nil +} +func (s *Service) validateHelmConfigVersion(containerID, versionID int) (*model.ContainerVersion, error) { + containerVersion, err := s.repo.getContainerVersionByID(versionID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: container version %d not found", consts.ErrNotFound, versionID) + } + return nil, fmt.Errorf("failed to get container version: %w", err) + } + + if containerVersion.ContainerID != containerID { + return nil, fmt.Errorf("version %d does not belong to container %d", versionID, containerID) + } + if containerVersion.Container == nil || containerVersion.Container.Type != consts.ContainerTypePedestal { + return nil, fmt.Errorf("only pedestal container versions support Helm configurations") + } + if containerVersion.HelmConfig == nil { + return nil, fmt.Errorf("container version %d does not have an associated Helm configuration", versionID) + } + + return containerVersion, nil +} diff --git a/src/module/dataset/api_types.go b/src/module/dataset/api_types.go new file mode 100644 index 00000000..8af8589f --- /dev/null +++ b/src/module/dataset/api_types.go @@ -0,0 +1,417 @@ +package dataset + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/model" + "aegis/utils" + + chaos "github.com/OperationsPAI/chaos-experiment/handler" +) + +// CreateDatasetReq represents dataset creation request. +type CreateDatasetReq struct { + Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required"` + Description string `json:"description" binding:"omitempty"` + IsPublic *bool `json:"is_public" binding:"omitempty"` + + VersionReq *CreateDatasetVersionReq `json:"version" binding:"omitempty"` +} + +func (req *CreateDatasetReq) Validate() error { + req.Name = strings.TrimSpace(req.Name) + req.Type = strings.TrimSpace(req.Type) + + if req.Name == "" { + return fmt.Errorf("dataset name cannot be empty") + } + if req.Type == "" { + return fmt.Errorf("dataset type cannot be empty") + } + if req.IsPublic == nil { + req.IsPublic = utils.BoolPtr(true) + } + if req.VersionReq != nil { + if err := req.VersionReq.Validate(); err != nil { + return fmt.Errorf("invalid dataset version request: %v", err) + } + } + return nil +} + +func (req *CreateDatasetReq) ConvertToDataset() *model.Dataset { + return &model.Dataset{ + Name: req.Name, + Type: req.Type, + Description: req.Description, + IsPublic: *req.IsPublic, + Status: consts.CommonEnabled, + } +} + +// ListDatasetReq represents dataset list query parameters. +type ListDatasetReq struct { + dto.PaginationReq + Type string `form:"type" binding:"omitempty"` + IsPublic *bool `form:"is_public" binding:"omitempty"` + Status *consts.StatusType `form:"status" binding:"omitempty"` +} + +func (req *ListDatasetReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + return validateStatus(req.Status, false) +} + +// UpdateDatasetReq represents dataset update request. +type UpdateDatasetReq struct { + Description *string `json:"description" binding:"omitempty"` + IsPublic *bool `json:"is_public" binding:"omitempty"` + Status *consts.StatusType `json:"status" binding:"omitempty"` +} + +func (req *UpdateDatasetReq) Validate() error { + return validateStatus(req.Status, true) +} + +func (req *UpdateDatasetReq) PatchDatasetModel(target *model.Dataset) { + if req.Description != nil { + target.Description = *req.Description + } + if req.IsPublic != nil { + target.IsPublic = *req.IsPublic + } + if req.Status != nil { + target.Status = *req.Status + } +} + +// ManageDatasetLabelReq represents dataset label management request. +type ManageDatasetLabelReq struct { + AddLabels []dto.LabelItem `json:"add_labels" binding:"omitempty"` + RemoveLabels []string `json:"remove_labels" binding:"omitempty"` +} + +func (req *ManageDatasetLabelReq) Validate() error { + if len(req.AddLabels) == 0 && len(req.RemoveLabels) == 0 { + return fmt.Errorf("at least one of add_labels or remove_labels must be provided") + } + if err := validateLabelItems(req.AddLabels); err != nil { + return err + } + for i, key := range req.RemoveLabels { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("empty label key at index %d in remove_labels", i) + } + } + return nil +} + +// DatasetResp represents dataset summary information. +type DatasetResp struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + IsPublic bool `json:"is_public"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Labels []dto.LabelItem `json:"labels,omitempty"` +} + +func NewDatasetResp(dataset *model.Dataset) *DatasetResp { + resp := &DatasetResp{ + ID: dataset.ID, + Name: dataset.Name, + Type: dataset.Type, + IsPublic: dataset.IsPublic, + Status: consts.GetStatusTypeName(dataset.Status), + CreatedAt: dataset.CreatedAt, + UpdatedAt: dataset.UpdatedAt, + } + + if len(dataset.Labels) > 0 { + resp.Labels = make([]dto.LabelItem, 0, len(dataset.Labels)) + for _, l := range dataset.Labels { + resp.Labels = append(resp.Labels, dto.LabelItem{Key: l.Key, Value: l.Value}) + } + } + return resp +} + +// DatasetDetailResp represents detailed dataset information. +type DatasetDetailResp struct { + DatasetResp + Description string `json:"description"` + Versions []DatasetVersionResp `json:"versions"` +} + +func NewDatasetDetailResp(dataset *model.Dataset) *DatasetDetailResp { + return &DatasetDetailResp{ + DatasetResp: *NewDatasetResp(dataset), + Description: dataset.Description, + } +} + +func validateStatus(statusPtr *consts.StatusType, isMutation bool) error { + if statusPtr == nil { + return nil + } + status := *statusPtr + if _, exists := consts.ValidStatuses[status]; !exists { + return fmt.Errorf("invalid status value: %d", status) + } + if isMutation && status == consts.CommonDeleted { + return fmt.Errorf("status value cannot be set to deleted (%d) directly through this update/create operation", consts.CommonDeleted) + } + return nil +} + +func validateLabelItems(items []dto.LabelItem) error { + for i, label := range items { + if strings.TrimSpace(label.Key) == "" { + return fmt.Errorf("empty label key at index %d in add_labels", i) + } + if strings.TrimSpace(label.Value) == "" { + return fmt.Errorf("empty label value at index %d in add_labels", i) + } + } + return nil +} + +// SearchDatasetReq represents advanced dataset search. +type SearchDatasetReq struct { + dto.AdvancedSearchReq[consts.DatasetField] + + NamePattern string `json:"name_pattern" binding:"omitempty"` + IncludeVersions bool `json:"include_versions" binding:"omitempty"` +} + +func (req *SearchDatasetReq) Validate() error { + if err := req.AdvancedSearchReq.Validate(); err != nil { + return err + } + for i, sortField := range req.Sort { + if _, valid := consts.DatasetAllowedFields[sortField.Field]; !valid { + return fmt.Errorf("invalid sort_by field at index %d: %s", i, sortField.Field) + } + } + for i, field := range req.GroupBy { + if _, valid := consts.DatasetAllowedFields[field]; !valid { + return fmt.Errorf("invalid group_by field at index %d: %s", i, field) + } + } + return nil +} + +func (req *SearchDatasetReq) ConvertToSearchReq() *dto.SearchReq[consts.DatasetField] { + sr := req.ConvertAdvancedToSearch() + + if req.NamePattern != "" { + sr.AddFilter("name", dto.OpLike, req.NamePattern) + } + if req.IncludeVersions { + sr.AddInclude("Versions") + } + + return sr +} + +// ManageDatasetVersionInjectionReq represents datapack membership changes for a dataset version. +type ManageDatasetVersionInjectionReq struct { + AddDatapacks []string `json:"add_datapacks" binding:"omitempty"` + RemoveDatapacks []string `json:"remove_datapacks" binding:"omitempty"` +} + +func (req *ManageDatasetVersionInjectionReq) Validate() error { + if len(req.AddDatapacks) == 0 && len(req.RemoveDatapacks) == 0 { + return fmt.Errorf("at least one of add_injections or remove_injections must be provided") + } + + for i, datapack := range req.AddDatapacks { + if strings.TrimSpace(datapack) == "" { + return fmt.Errorf("empty datapack name at index %d in add_datapacks", i) + } + } + for i, datapack := range req.RemoveDatapacks { + if strings.TrimSpace(datapack) == "" { + return fmt.Errorf("empty datapack name at index %d in add_datapacks", i) + } + } + + return nil +} + +// CreateDatasetVersionReq represents dataset version creation. +type CreateDatasetVersionReq struct { + Name string `json:"name" binding:"required"` + Datapacks []string `json:"datapacks" binding:"omitempty"` +} + +func (req *CreateDatasetVersionReq) Validate() error { + req.Name = strings.TrimSpace(req.Name) + + if req.Name == "" { + return fmt.Errorf("name cannot be empty") + } + if _, _, _, err := utils.ParseSemanticVersion(req.Name); err != nil { + return fmt.Errorf("invalid semantic version: %s, %v", req.Name, err) + } + for i, datapack := range req.Datapacks { + if strings.TrimSpace(datapack) == "" { + return fmt.Errorf("empty datapack name at index %d", i) + } + } + + return nil +} + +func (req *CreateDatasetVersionReq) ConvertToDatasetVersion() *model.DatasetVersion { + return &model.DatasetVersion{ + Name: req.Name, + Status: consts.CommonEnabled, + } +} + +// ListDatasetVersionReq represents dataset version list query parameters. +type ListDatasetVersionReq struct { + dto.PaginationReq + Status *consts.StatusType `json:"status" binding:"omitempty"` +} + +func (req *ListDatasetVersionReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + return validateStatus(req.Status, false) +} + +// UpdateDatasetVersionReq represents mutable dataset version fields. +type UpdateDatasetVersionReq struct { + Status *consts.StatusType `json:"status" binding:"omitempty"` +} + +func (req *UpdateDatasetVersionReq) Validate() error { + return validateStatus(req.Status, true) +} + +func (req *UpdateDatasetVersionReq) PatchDatasetVersionModel(target *model.DatasetVersion) { + if req.Status != nil { + target.Status = *req.Status + } +} + +// DatasetVersionResp represents dataset version summary information. +type DatasetVersionResp struct { + ID int `json:"id"` + Name string `json:"name"` + Checksum string `json:"checksum"` + FileCount int `json:"file_count"` + UpdatedAt time.Time `json:"updated_at"` +} + +func NewDatasetVersionResp(version *model.DatasetVersion) *DatasetVersionResp { + return &DatasetVersionResp{ + ID: version.ID, + Name: version.Name, + Checksum: version.Checksum, + FileCount: version.FileCount, + UpdatedAt: version.UpdatedAt, + } +} + +// DatasetVersionDetailResp represents dataset version details including datapacks. +type DatasetVersionDetailResp struct { + DatasetVersionResp + + Datapacks []DatasetDatapackResp `json:"datapacks,omitempty"` +} + +func NewDatasetVersionDetailResp(version *model.DatasetVersion) *DatasetVersionDetailResp { + resp := &DatasetVersionDetailResp{ + DatasetVersionResp: *NewDatasetVersionResp(version), + } + + if len(version.Datapacks) > 0 { + resp.Datapacks = make([]DatasetDatapackResp, 0, len(version.Datapacks)) + for _, datapack := range version.Datapacks { + resp.Datapacks = append(resp.Datapacks, *NewDatasetDatapackResp(&datapack)) + } + } + + return resp +} + +type DatasetDatapackResp struct { + ID int `json:"id"` + Name string `json:"name"` + Source string `json:"source"` + FaultType string `json:"fault_type"` + Category string `json:"category"` + DisplayConfig map[string]any `json:"display_config,omitempty" swaggertype:"object"` + PreDuration int `json:"pre_duration"` + StartTime *time.Time `json:"start_time,omitempty"` + EndTime *time.Time `json:"end_time,omitempty"` + State consts.DatapackState `json:"state" swaggertype:"string"` + Status string `json:"status"` + GroundtruthSource string `json:"groundtruth_source"` + BenchmarkID *int `json:"benchmark_id"` + BenchmarkName string `json:"benchmark_name"` + PedestalID *int `json:"pedestal_id"` + PedestalName string `json:"pedestal_name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Labels []dto.LabelItem `json:"labels,omitempty"` +} + +func NewDatasetDatapackResp(injection *model.FaultInjection) *DatasetDatapackResp { + resp := &DatasetDatapackResp{ + ID: injection.ID, + Name: injection.Name, + Source: string(injection.Source), + Category: injection.Category.String(), + PreDuration: injection.PreDuration, + StartTime: injection.StartTime, + EndTime: injection.EndTime, + State: injection.State, + Status: consts.GetStatusTypeName(injection.Status), + GroundtruthSource: injection.GroundtruthSource, + BenchmarkID: injection.BenchmarkID, + PedestalID: injection.PedestalID, + CreatedAt: injection.CreatedAt, + UpdatedAt: injection.UpdatedAt, + } + + if injection.FaultType == consts.Hybrid { + resp.FaultType = "hybrid" + } else { + resp.FaultType = chaos.ChaosTypeMap[injection.FaultType] + } + + if injection.DisplayConfig != nil { + var displayConfigData map[string]any + _ = json.Unmarshal([]byte(*injection.DisplayConfig), &displayConfigData) + resp.DisplayConfig = displayConfigData + } + + if injection.Benchmark != nil && injection.Benchmark.Container != nil { + resp.BenchmarkName = injection.Benchmark.Container.Name + } + if injection.Pedestal != nil && injection.Pedestal.Container != nil { + resp.PedestalName = injection.Pedestal.Container.Name + } + if len(injection.Labels) > 0 { + resp.Labels = make([]dto.LabelItem, 0, len(injection.Labels)) + for _, l := range injection.Labels { + resp.Labels = append(resp.Labels, dto.LabelItem{Key: l.Key, Value: l.Value, IsSystem: l.IsSystem}) + } + } + return resp +} diff --git a/src/module/dataset/core.go b/src/module/dataset/core.go new file mode 100644 index 00000000..77c7a638 --- /dev/null +++ b/src/module/dataset/core.go @@ -0,0 +1,10 @@ +package dataset + +import ( + "aegis/model" +) + +func (r *Repository) CreateDatasetCore(dataset *model.Dataset, versions []model.DatasetVersion, userID int) (*model.Dataset, error) { + service := NewService(r, NewDatapackFileStore()) + return service.createDatasetCore(r, dataset, versions, userID) +} diff --git a/src/module/dataset/file_store.go b/src/module/dataset/file_store.go new file mode 100644 index 00000000..4d13a414 --- /dev/null +++ b/src/module/dataset/file_store.go @@ -0,0 +1,69 @@ +package dataset + +import ( + "archive/zip" + "fmt" + "io/fs" + "path/filepath" + + "aegis/config" + "aegis/consts" + "aegis/model" + "aegis/utils" +) + +type DatapackFileStore struct { + basePath string +} + +func NewDatapackFileStore() *DatapackFileStore { + return &DatapackFileStore{basePath: config.GetString("jfs.dataset_path")} +} + +func (s *DatapackFileStore) PackageToZip(zipWriter *zip.Writer, datapacks []model.FaultInjection, excludeRules []utils.ExculdeRule) error { + for i := range datapacks { + if err := s.packageDatapackToZip(zipWriter, &datapacks[i], excludeRules); err != nil { + return err + } + } + return nil +} + +func (s *DatapackFileStore) packageDatapackToZip(zipWriter *zip.Writer, datapack *model.FaultInjection, excludeRules []utils.ExculdeRule) error { + if datapack.State < consts.DatapackBuildSuccess { + return fmt.Errorf("datapack %s is not in a downloadable state", datapack.Name) + } + + workDir := filepath.Join(s.basePath, datapack.Name) + if !utils.IsAllowedPath(workDir) { + return fmt.Errorf("invalid path access to %s", workDir) + } + + err := filepath.WalkDir(workDir, func(path string, dir fs.DirEntry, err error) error { + if err != nil || dir.IsDir() { + return err + } + + relPath, _ := filepath.Rel(workDir, path) + fullRelPath := filepath.Join(consts.DownloadFilename, filepath.Base(workDir), relPath) + fileName := filepath.Base(path) + + for _, rule := range excludeRules { + if utils.MatchFile(fileName, rule) { + return nil + } + } + + fileInfo, err := dir.Info() + if err != nil { + return err + } + + return utils.AddToZip(zipWriter, fileInfo, path, filepath.ToSlash(fullRelPath)) + }) + if err != nil { + return fmt.Errorf("failed to package datapack %s: %w", datapack.Name, err) + } + + return nil +} diff --git a/src/module/dataset/file_store_test.go b/src/module/dataset/file_store_test.go new file mode 100644 index 00000000..6d662142 --- /dev/null +++ b/src/module/dataset/file_store_test.go @@ -0,0 +1,75 @@ +package dataset + +import ( + "archive/zip" + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "aegis/consts" + "aegis/model" + "aegis/utils" + + "github.com/spf13/viper" +) + +func TestDatapackFileStorePackageToZip(t *testing.T) { + tmpDir := t.TempDir() + viper.Set("jfs.dataset_path", tmpDir) + + datapackDir := filepath.Join(tmpDir, "datapack-a") + if err := os.MkdirAll(filepath.Join(datapackDir, "nested"), 0o755); err != nil { + t.Fatalf("mkdir datapack dir: %v", err) + } + if err := os.WriteFile(filepath.Join(datapackDir, "nested", "keep.txt"), []byte("hello"), 0o644); err != nil { + t.Fatalf("write datapack file: %v", err) + } + if err := os.WriteFile(filepath.Join(datapackDir, "skip.log"), []byte("skip"), 0o644); err != nil { + t.Fatalf("write excluded file: %v", err) + } + + store := &DatapackFileStore{basePath: tmpDir} + buf := &bytes.Buffer{} + zipWriter := zip.NewWriter(buf) + err := store.PackageToZip(zipWriter, []model.FaultInjection{{ + Name: "datapack-a", + State: consts.DatapackBuildSuccess, + }}, []utils.ExculdeRule{{Pattern: "*.log", IsGlob: true}}) + if err != nil { + t.Fatalf("PackageToZip failed: %v", err) + } + if err := zipWriter.Close(); err != nil { + t.Fatalf("close zip writer: %v", err) + } + + reader, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil { + t.Fatalf("open zip reader: %v", err) + } + + files := make(map[string]string, len(reader.File)) + for _, file := range reader.File { + rc, err := file.Open() + if err != nil { + t.Fatalf("open zip file %s: %v", file.Name, err) + } + content, err := io.ReadAll(rc) + _ = rc.Close() + if err != nil { + t.Fatalf("read zip file %s: %v", file.Name, err) + } + files[file.Name] = string(content) + } + + expected := filepath.ToSlash(filepath.Join(consts.DownloadFilename, "datapack-a", "nested", "keep.txt")) + if files[expected] != "hello" { + t.Fatalf("expected zip to contain %s with hello, got %q", expected, files[expected]) + } + + excluded := filepath.ToSlash(filepath.Join(consts.DownloadFilename, "datapack-a", "skip.log")) + if _, ok := files[excluded]; ok { + t.Fatalf("expected %s to be excluded", excluded) + } +} diff --git a/src/handlers/v2/datasets.go b/src/module/dataset/handler.go similarity index 50% rename from src/handlers/v2/datasets.go rename to src/module/dataset/handler.go index 002daadc..c425f956 100644 --- a/src/handlers/v2/datasets.go +++ b/src/module/dataset/handler.go @@ -1,6 +1,7 @@ -package v2 +package dataset import ( + "aegis/httpx" "archive/zip" "fmt" "net/http" @@ -8,14 +9,20 @@ import ( "aegis/consts" "aegis/dto" - "aegis/handlers" "aegis/middleware" - producer "aegis/service/producer" "aegis/utils" "github.com/gin-gonic/gin" ) +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + // CreateDataset handles dataset creation // // @Summary Create dataset @@ -25,23 +32,23 @@ import ( // @Accept json // @Produce json // @Security BearerAuth -// @Param request body dto.CreateDatasetReq true "Dataset creation request" -// @Success 201 {object} dto.GenericResponse[dto.DatasetResp] "Dataset created successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 409 {object} dto.GenericResponse[any] "Conflict error" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param request body CreateDatasetReq true "Dataset creation request" +// @Success 201 {object} dto.GenericResponse[DatasetResp] "Dataset created successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 409 {object} dto.GenericResponse[any] "Conflict error" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/datasets [post] -// @x-api-type {"sdk":"true"} -func CreateDataset(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) CreateDataset(c *gin.Context) { userID, exists := middleware.GetCurrentUserID(c) if !exists { dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") return } - var req dto.CreateDatasetReq + var req CreateDatasetReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -52,8 +59,8 @@ func CreateDataset(c *gin.Context) { return } - resp, err := producer.CreateDataset(&req, userID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.CreateDataset(c.Request.Context(), &req, userID) + if httpx.HandleServiceError(c, err) { return } @@ -76,16 +83,14 @@ func CreateDataset(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "Dataset not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/datasets/{dataset_id} [delete] -func DeleteDataset(c *gin.Context) { - datasetIDStr := c.Param(consts.URLPathDatasetID) - datasetID, err := strconv.Atoi(datasetIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset ID") +// @x-api-type {"portal":"true"} +func (h *Handler) DeleteDataset(c *gin.Context) { + datasetID, ok := parseDatasetID(c) + if !ok { return } - err = producer.DeleteDataset(datasetID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.DeleteDataset(c.Request.Context(), datasetID)) { return } @@ -100,25 +105,23 @@ func DeleteDataset(c *gin.Context) { // @ID get_dataset_by_id // @Produce json // @Security BearerAuth -// @Param dataset_id path int true "Dataset ID" -// @Success 200 {object} dto.GenericResponse[dto.DatasetDetailResp] "Dataset retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid dataset ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Dataset not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param dataset_id path int true "Dataset ID" +// @Success 200 {object} dto.GenericResponse[DatasetDetailResp] "Dataset retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid dataset ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Dataset not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/datasets/{dataset_id} [get] -// @x-api-type {"sdk":"true"} -func GetDataset(c *gin.Context) { - datasetIDStr := c.Param(consts.URLPathDatasetID) - datasetID, err := strconv.Atoi(datasetIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset ID") +// @x-api-type {"portal":"true"} +func (h *Handler) GetDataset(c *gin.Context) { + datasetID, ok := parseDatasetID(c) + if !ok { return } - resp, err := producer.GetDatasetDetail(datasetID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.GetDataset(c.Request.Context(), datasetID) + if httpx.HandleServiceError(c, err) { return } @@ -133,20 +136,20 @@ func GetDataset(c *gin.Context) { // @ID list_datasets // @Produce json // @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param type query string false "Dataset type filter" -// @Param is_public query bool false "Dataset public visibility filter" -// @Param status query consts.StatusType false "Dataset status filter" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.DatasetResp]] "Datasets retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Param type query string false "Dataset type filter" +// @Param is_public query bool false "Dataset public visibility filter" +// @Param status query consts.StatusType false "Dataset status filter" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[DatasetResp]] "Datasets retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/datasets [get] -// @x-api-type {"sdk":"true"} -func ListDatasets(c *gin.Context) { - var req dto.ListDatasetReq +// @x-api-type {"portal":"true"} +func (h *Handler) ListDatasets(c *gin.Context) { + var req ListDatasetReq if err := c.ShouldBindQuery(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -157,8 +160,8 @@ func ListDatasets(c *gin.Context) { return } - resp, err := producer.ListDatasets(&req) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ListDatasets(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { return } @@ -174,16 +177,16 @@ func ListDatasets(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param request body dto.SearchDatasetReq true "Dataset search request" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.DatasetDetailResp]] "Datasets retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param request body SearchDatasetReq true "Dataset search request" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[DatasetDetailResp]] "Datasets retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/datasets/search [post] -// @x-api-type {"sdk":"true"} -func SearchDataset(c *gin.Context) { - var req dto.SearchDatasetReq +// @x-api-type {"portal":"true"} +func (h *Handler) SearchDataset(c *gin.Context) { + var req SearchDatasetReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -194,8 +197,8 @@ func SearchDataset(c *gin.Context) { return } - resp, err := producer.SearchDatasets(&req) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.SearchDatasets(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { return } @@ -211,39 +214,41 @@ func SearchDataset(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param dataset_id path int true "Dataset ID" -// @Param request body dto.UpdateDatasetReq true "Dataset update request" -// @Success 202 {object} dto.GenericResponse[dto.DatasetResp] "Dataset updated successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid dataset ID/request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Dataset not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param dataset_id path int true "Dataset ID" +// @Param request body UpdateDatasetReq true "Dataset update request" +// @Success 202 {object} dto.GenericResponse[DatasetResp] "Dataset updated successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid dataset ID/request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Dataset not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/datasets/{dataset_id} [patch] -func UpdateDataset(c *gin.Context) { - datasetIDStr := c.Param(consts.URLPathDatasetID) - datasetID, err := strconv.Atoi(datasetIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset ID") +// @x-api-type {"portal":"true"} +func (h *Handler) UpdateDataset(c *gin.Context) { + datasetID, ok := parseDatasetID(c) + if !ok { return } - var req dto.UpdateDatasetReq + var req UpdateDatasetReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - resp, err := producer.UpdateDataset(&req, datasetID) - if handlers.HandleServiceError(c, err) { + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.UpdateDataset(c.Request.Context(), &req, datasetID) + if httpx.HandleServiceError(c, err) { return } dto.JSONResponse[any](c, http.StatusAccepted, "Dataset updated successfully", resp) } -// ===================== Dataset-Label API ===================== - // ManageDatasetCustomLabels manages dataset custom labels (key-value pairs) // // @Summary Manage dataset custom labels @@ -253,24 +258,23 @@ func UpdateDataset(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param dataset_id path int true "Dataset ID" -// @Param manage body dto.ManageDatasetLabelReq true "Label management request" -// @Success 200 {object} dto.GenericResponse[dto.DatasetResp] "Labels managed successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid dataset ID or invalid request format/parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Dataset not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param dataset_id path int true "Dataset ID" +// @Param manage body ManageDatasetLabelReq true "Label management request" +// @Success 200 {object} dto.GenericResponse[DatasetResp] "Labels managed successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid dataset ID or invalid request format/parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Dataset not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/datasets/{dataset_id}/labels [patch] -func ManageDatasetCustomLabels(c *gin.Context) { - datasetIDStr := c.Param(consts.URLPathDatasetID) - datasetID, err := strconv.Atoi(datasetIDStr) - if err != nil || datasetID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset ID") +// @x-api-type {"portal":"true"} +func (h *Handler) ManageDatasetCustomLabels(c *gin.Context) { + datasetID, ok := parseDatasetID(c) + if !ok { return } - var req dto.ManageDatasetLabelReq + var req ManageDatasetLabelReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -281,8 +285,8 @@ func ManageDatasetCustomLabels(c *gin.Context) { return } - resp, err := producer.ManageDatasetLabels(&req, datasetID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ManageDatasetLabels(c.Request.Context(), &req, datasetID) + if httpx.HandleServiceError(c, err) { return } @@ -298,31 +302,29 @@ func ManageDatasetCustomLabels(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param dataset_id path int true "Dataset ID" -// @Param request body dto.CreateDatasetVersionReq true "Dataset version creation request" -// @Success 201 {object} dto.GenericResponse[dto.DatasetVersionResp] "Dataset version created successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 409 {object} dto.GenericResponse[any] "Conflict error" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param dataset_id path int true "Dataset ID" +// @Param request body CreateDatasetVersionReq true "Dataset version creation request" +// @Success 201 {object} dto.GenericResponse[DatasetVersionResp] "Dataset version created successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 409 {object} dto.GenericResponse[any] "Conflict error" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/datasets/{dataset_id}/versions [post] -// @x-api-type {"sdk":"true"} -func CreateDatasetVersion(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) CreateDatasetVersion(c *gin.Context) { userID, exists := middleware.GetCurrentUserID(c) if !exists { dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") return } - datasetIDStr := c.Param(consts.URLPathDatasetID) - datasetID, err := strconv.Atoi(datasetIDStr) - if err != nil || datasetID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset ID") + datasetID, ok := parseDatasetID(c) + if !ok { return } - var req dto.CreateDatasetVersionReq + var req CreateDatasetVersionReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -333,8 +335,8 @@ func CreateDatasetVersion(c *gin.Context) { return } - resp, err := producer.CreateDatasetVersion(&req, datasetID, userID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.CreateDatasetVersion(c.Request.Context(), &req, datasetID, userID) + if httpx.HandleServiceError(c, err) { return } @@ -358,16 +360,14 @@ func CreateDatasetVersion(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "Dataset or version not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/datasets/{dataset_id}/versions/{version_id} [delete] -func DeleteDatasetVersion(c *gin.Context) { - versionIDStr := c.Param(consts.URLPathVersionID) - versionID, err := strconv.Atoi(versionIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset version ID") +// @x-api-type {"portal":"true"} +func (h *Handler) DeleteDatasetVersion(c *gin.Context) { + versionID, ok := parseDatasetVersionID(c) + if !ok { return } - err = producer.DeleteDatasetVersion(versionID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.DeleteDatasetVersion(c.Request.Context(), versionID)) { return } @@ -382,33 +382,28 @@ func DeleteDatasetVersion(c *gin.Context) { // @ID get_dataset_version_by_id // @Produce json // @Security BearerAuth -// @Param dataset_id path int true "Dataset ID" -// @Param version_id path int true "Dataset Version ID" -// @Success 200 {object} dto.GenericResponse[dto.DatasetVersionDetailResp] "Dataset version retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid dataset ID/dataset version ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Dataset or version not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param dataset_id path int true "Dataset ID" +// @Param version_id path int true "Dataset Version ID" +// @Success 200 {object} dto.GenericResponse[DatasetVersionDetailResp] "Dataset version retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid dataset ID/dataset version ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Dataset or version not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/datasets/{dataset_id}/versions/{version_id} [get] -// @x-api-type {"sdk":"true"} -func GetDatasetVersion(c *gin.Context) { - datasetIDStr := c.Param(consts.URLPathDatasetID) - datasetID, err := strconv.Atoi(datasetIDStr) - if err != nil || datasetID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset ID") +// @x-api-type {"portal":"true"} +func (h *Handler) GetDatasetVersion(c *gin.Context) { + datasetID, ok := parseDatasetID(c) + if !ok { return } - - versionIDStr := c.Param(consts.URLPathVersionID) - versionID, err := strconv.Atoi(versionIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset version ID") + versionID, ok := parseDatasetVersionID(c) + if !ok { return } - resp, err := producer.GetDatasetVersionDetail(datasetID, versionID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.GetDatasetVersion(c.Request.Context(), datasetID, versionID) + if httpx.HandleServiceError(c, err) { return } @@ -423,26 +418,24 @@ func GetDatasetVersion(c *gin.Context) { // @ID list_dataset_versions // @Produce json // @Security BearerAuth -// @Param dataset_id path int true "Dataset ID" -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param status query consts.StatusType false "Dataset version status filter" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.DatasetVersionResp]] "Dataset versions retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param dataset_id path int true "Dataset ID" +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Param status query consts.StatusType false "Dataset version status filter" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[DatasetVersionResp]] "Dataset versions retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/datasets/{dataset_id}/versions [get] -// @x-api-type {"sdk":"true"} -func ListDatasetVersions(c *gin.Context) { - datasetIDStr := c.Param(consts.URLPathDatasetID) - datasetID, err := strconv.Atoi(datasetIDStr) - if err != nil || datasetID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset ID") +// @x-api-type {"portal":"true"} +func (h *Handler) ListDatasetVersions(c *gin.Context) { + datasetID, ok := parseDatasetID(c) + if !ok { return } - var req dto.ListDatasetVersionReq + var req ListDatasetVersionReq if err := c.ShouldBindQuery(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -453,8 +446,8 @@ func ListDatasetVersions(c *gin.Context) { return } - resp, err := producer.ListDatasetVersions(&req, datasetID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ListDatasetVersions(c.Request.Context(), &req, datasetID) + if httpx.HandleServiceError(c, err) { return } @@ -470,32 +463,28 @@ func ListDatasetVersions(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param dataset_id path int true "Dataset ID" -// @Param version_id path int true "Dataset Version ID" -// @Param request body dto.UpdateDatasetVersionReq true "Dataset version update request" -// @Success 202 {object} dto.GenericResponse[dto.DatasetVersionResp] "Dataset version updated successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid dataset ID/dataset version ID/request format/request parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Dataset not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param dataset_id path int true "Dataset ID" +// @Param version_id path int true "Dataset Version ID" +// @Param request body UpdateDatasetVersionReq true "Dataset version update request" +// @Success 202 {object} dto.GenericResponse[DatasetVersionResp] "Dataset version updated successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid dataset ID/dataset version ID/request format/request parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Dataset not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/datasets/{dataset_id}/versions/{version_id} [patch] -func UpdateDatasetVersion(c *gin.Context) { - datasetIDStr := c.Param(consts.URLPathDatasetID) - datasetID, err := strconv.Atoi(datasetIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset ID") +// @x-api-type {"portal":"true"} +func (h *Handler) UpdateDatasetVersion(c *gin.Context) { + datasetID, ok := parseDatasetID(c) + if !ok { return } - - versionIDStr := c.Param(consts.URLPathVersionID) - versionID, err := strconv.Atoi(versionIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset version ID") + versionID, ok := parseDatasetVersionID(c) + if !ok { return } - var req dto.UpdateDatasetVersionReq + var req UpdateDatasetVersionReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -506,8 +495,8 @@ func UpdateDatasetVersion(c *gin.Context) { return } - resp, err := producer.UpdateDatasetVersion(&req, datasetID, versionID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.UpdateDatasetVersion(c.Request.Context(), &req, datasetID, versionID) + if httpx.HandleServiceError(c, err) { return } @@ -530,24 +519,19 @@ func UpdateDatasetVersion(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "Dataset not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/datasets/{dataset_id}/versions/{version_id}/download [get] -// @x-api-type {"sdk":"true"} -func DownloadDatasetVersion(c *gin.Context) { - datasetIDStr := c.Param(consts.URLPathDatasetID) - datasetID, err := strconv.Atoi(datasetIDStr) - if err != nil || datasetID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset ID") +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) DownloadDatasetVersion(c *gin.Context) { + datasetID, ok := parseDatasetID(c) + if !ok { return } - - versionIDStr := c.Param(consts.URLPathVersionID) - versionID, err := strconv.Atoi(versionIDStr) - if err != nil || versionID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid version ID") + versionID, ok := parseDatasetVersionID(c) + if !ok { return } - filename, err := producer.GetDatasetVersionFilename(datasetID, versionID) - if handlers.HandleServiceError(c, err) { + filename, err := h.service.GetDatasetVersionFilename(c.Request.Context(), datasetID, versionID) + if httpx.HandleServiceError(c, err) { return } @@ -557,15 +541,13 @@ func DownloadDatasetVersion(c *gin.Context) { zipWriter := zip.NewWriter(c.Writer) defer func() { _ = zipWriter.Close() }() - if err := producer.DownloadDatasetVersion(zipWriter, []utils.ExculdeRule{}, versionID); err != nil { + if err := h.service.DownloadDatasetVersion(c.Request.Context(), zipWriter, []utils.ExculdeRule{}, versionID); err != nil { delete(c.Writer.Header(), "Content-Disposition") c.Header("Content-Type", "application/json; charset=utf-8") - handlers.HandleServiceError(c, err) + httpx.HandleServiceError(c, err) } } -// ===================== DatasetVersion-Injection API ===================== - // ManageDatasetInjections manages dataset injections // // @Summary Manage dataset injections @@ -575,33 +557,28 @@ func DownloadDatasetVersion(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param dataset_id path int true "Dataset ID" -// @Param version_id path int true "Dataset Version ID" -// @Param manage body dto.ManageDatasetVersionInjectionReq true "Injection management request" -// @Success 200 {object} dto.GenericResponse[dto.DatasetVersionDetailResp] "Injections managed successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid dataset ID or invalid request format/parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Dataset not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param dataset_id path int true "Dataset ID" +// @Param version_id path int true "Dataset Version ID" +// @Param manage body ManageDatasetVersionInjectionReq true "Injection management request" +// @Success 200 {object} dto.GenericResponse[DatasetVersionDetailResp] "Injections managed successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid dataset ID or invalid request format/parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Dataset not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/datasets/{dataset_id}/version/{version_id}/injections [patch] -// @x-api-type {"sdk":"true"} -func ManageDatasetVersionInjections(c *gin.Context) { - datasetIDStr := c.Param(consts.URLPathDatasetID) - datasetID, err := strconv.Atoi(datasetIDStr) - if err != nil || datasetID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset ID") +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) ManageDatasetVersionInjections(c *gin.Context) { + _, ok := parseDatasetID(c) + if !ok { return } - - versionIDStr := c.Param(consts.URLPathVersionID) - versionID, err := strconv.Atoi(versionIDStr) - if err != nil || versionID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset version ID") + versionID, ok := parseDatasetVersionID(c) + if !ok { return } - var req dto.ManageDatasetVersionInjectionReq + var req ManageDatasetVersionInjectionReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -612,10 +589,30 @@ func ManageDatasetVersionInjections(c *gin.Context) { return } - resp, err := producer.ManageDatasetVersionInjections(&req, versionID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ManageDatasetVersionInjections(c.Request.Context(), &req, versionID) + if httpx.HandleServiceError(c, err) { return } dto.SuccessResponse(c, resp) } + +func parseDatasetID(c *gin.Context) (int, bool) { + datasetIDStr := c.Param(consts.URLPathDatasetID) + datasetID, err := strconv.Atoi(datasetIDStr) + if err != nil || datasetID <= 0 { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset ID") + return 0, false + } + return datasetID, true +} + +func parseDatasetVersionID(c *gin.Context) (int, bool) { + versionIDStr := c.Param(consts.URLPathVersionID) + versionID, err := strconv.Atoi(versionIDStr) + if err != nil || versionID <= 0 { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset version ID") + return 0, false + } + return versionID, true +} diff --git a/src/module/dataset/handler_service.go b/src/module/dataset/handler_service.go new file mode 100644 index 00000000..53fcad78 --- /dev/null +++ b/src/module/dataset/handler_service.go @@ -0,0 +1,32 @@ +package dataset + +import ( + "archive/zip" + "context" + + "aegis/dto" + "aegis/utils" +) + +// HandlerService captures the dataset operations consumed by the HTTP handler. +type HandlerService interface { + CreateDataset(context.Context, *CreateDatasetReq, int) (*DatasetResp, error) + DeleteDataset(context.Context, int) error + GetDataset(context.Context, int) (*DatasetDetailResp, error) + ListDatasets(context.Context, *ListDatasetReq) (*dto.ListResp[DatasetResp], error) + SearchDatasets(context.Context, *SearchDatasetReq) (*dto.ListResp[DatasetDetailResp], error) + UpdateDataset(context.Context, *UpdateDatasetReq, int) (*DatasetResp, error) + ManageDatasetLabels(context.Context, *ManageDatasetLabelReq, int) (*DatasetResp, error) + CreateDatasetVersion(context.Context, *CreateDatasetVersionReq, int, int) (*DatasetVersionResp, error) + DeleteDatasetVersion(context.Context, int) error + GetDatasetVersion(context.Context, int, int) (*DatasetVersionDetailResp, error) + ListDatasetVersions(context.Context, *ListDatasetVersionReq, int) (*dto.ListResp[DatasetVersionResp], error) + UpdateDatasetVersion(context.Context, *UpdateDatasetVersionReq, int, int) (*DatasetVersionResp, error) + GetDatasetVersionFilename(context.Context, int, int) (string, error) + DownloadDatasetVersion(context.Context, *zip.Writer, []utils.ExculdeRule, int) error + ManageDatasetVersionInjections(context.Context, *ManageDatasetVersionInjectionReq, int) (*DatasetVersionDetailResp, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/dataset/module.go b/src/module/dataset/module.go new file mode 100644 index 00000000..bfa18f77 --- /dev/null +++ b/src/module/dataset/module.go @@ -0,0 +1,11 @@ +package dataset + +import "go.uber.org/fx" + +var Module = fx.Module("dataset", + fx.Provide(NewRepository), + fx.Provide(NewDatapackFileStore), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/module/dataset/repository.go b/src/module/dataset/repository.go new file mode 100644 index 00000000..4fe7366f --- /dev/null +++ b/src/module/dataset/repository.go @@ -0,0 +1,398 @@ +package dataset + +import ( + "aegis/consts" + "aegis/dto" + "aegis/model" + "aegis/searchx" + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + datasetCommonOmitFields = "active_name" + datasetVersionModelOmitFields = "active_version_key" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) getRoleByName(name string) (*model.Role, error) { + var role model.Role + if err := r.db.Where("name = ? and status != ?", name, consts.CommonDeleted).First(&role).Error; err != nil { + return nil, fmt.Errorf("failed to find role with name %s: %w", name, err) + } + return &role, nil +} + +func (r *Repository) createDataset(dataset *model.Dataset) error { + if err := r.db.Omit(datasetCommonOmitFields).Create(dataset).Error; err != nil { + return fmt.Errorf("failed to create dataset: %v", err) + } + return nil +} + +func (r *Repository) createUserDataset(userDataset *model.UserDataset) error { + if err := r.db.Omit("active_user_dataset").Create(userDataset).Error; err != nil { + return fmt.Errorf("failed to create user-dataset association: %w", err) + } + return nil +} + +func (r *Repository) batchDeleteDatasetVersions(datasetID int) (int64, error) { + result := r.db.Model(&model.DatasetVersion{}). + Where("dataset_id = ? AND status != ?", datasetID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if result.Error != nil { + return 0, fmt.Errorf("failed to batch soft delete dataset versions for dataset %d: %w", datasetID, result.Error) + } + return result.RowsAffected, nil +} + +func (r *Repository) removeUsersFromDataset(datasetID int) (int64, error) { + result := r.db.Model(&model.UserDataset{}). + Where("dataset_id = ? AND status != ?", datasetID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if err := result.Error; err != nil { + return 0, fmt.Errorf("failed to delete user-dataset associations for dataset %d: %w", datasetID, err) + } + return result.RowsAffected, nil +} + +func (r *Repository) deleteDataset(datasetID int) (int64, error) { + result := r.db.Model(&model.Dataset{}). + Where("id = ? AND status != ?", datasetID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if err := result.Error; err != nil { + return 0, fmt.Errorf("failed to delete dataset: %v", err) + } + return result.RowsAffected, nil +} + +func (r *Repository) getDatasetByID(datasetID int) (*model.Dataset, error) { + var dataset model.Dataset + if err := r.db.Where("id = ? AND status != ?", datasetID, consts.CommonDeleted).First(&dataset).Error; err != nil { + return nil, fmt.Errorf("failed to get dataset: %v", err) + } + return &dataset, nil +} + +func (r *Repository) listDatasetVersionsByDatasetID(datasetID int) ([]model.DatasetVersion, error) { + var versions []model.DatasetVersion + if err := r.db.Where("dataset_id = ?", datasetID).Find(&versions).Error; err != nil { + return nil, fmt.Errorf("failed to list dataset versions for dataset %d: %w", datasetID, err) + } + return versions, nil +} + +func (r *Repository) batchGetDatasetVersions(datasetNames []string, userID int) ([]model.DatasetVersion, error) { + if len(datasetNames) == 0 { + return []model.DatasetVersion{}, nil + } + + var versions []model.DatasetVersion + query := r.db.Table("dataset_versions dv"). + Preload("Dataset"). + Where("dv.status = ?", consts.CommonEnabled). + Order("dv.dataset_id DESC, dv.name_major DESC, dv.name_minor DESC, dv.name_patch DESC") + + query = query.Joins("INNER JOIN datasets d ON d.id = dv.dataset_id"). + Where("d.name IN (?) AND d.status = ?", datasetNames, consts.CommonEnabled) + + if userID > 0 { + query = query.Joins( + "LEFT JOIN user_datasets ud ON ud.dataset_id = d.id AND ud.user_id = ? AND ud.status = ?", + userID, consts.CommonEnabled, + ).Where( + r.db.Where("d.is_public = ?", true).Or("ud.dataset_id IS NOT NULL"), + ) + } + + if err := query.Find(&versions).Error; err != nil { + return nil, fmt.Errorf("failed to query dataset versions: %w", err) + } + return versions, nil +} + +func (r *Repository) listDatasets(limit, offset int, datasetType string, isPublic *bool, status *consts.StatusType) ([]model.Dataset, int64, error) { + var ( + datasets []model.Dataset + total int64 + ) + + query := r.db.Model(&model.Dataset{}) + if datasetType != "" { + query = query.Where("type = ?", datasetType) + } + if isPublic != nil { + query = query.Where("is_public = ?", *isPublic) + } + if status != nil { + query = query.Where("status = ?", *status) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count datasets: %v", err) + } + if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&datasets).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list datasets: %v", err) + } + return datasets, total, nil +} + +func (r *Repository) searchDatasets(searchReq *dto.SearchReq[consts.DatasetField]) ([]model.Dataset, int64, error) { + qb := searchx.NewQueryBuilder(r.db, consts.DatasetAllowedFields) + qb.ApplySearchReq(searchReq.Filters, searchReq.Keyword, searchReq.Sort, searchReq.GroupBy, model.Dataset{}) + qb.ApplyIncludes(searchReq.Includes) + qb.ApplyIncludeFields(searchReq.IncludeFields) + qb.ApplyExcludeFields(searchReq.ExcludeFields, model.Dataset{}) + + total, err := qb.GetCount() + if err != nil { + return nil, 0, fmt.Errorf("failed to count searched datasets: %w", err) + } + + query := qb.Query() + if searchReq.Size != 0 && searchReq.Page != 0 { + query = query.Offset(searchReq.GetOffset()).Limit(int(searchReq.Size)) + } + + var items []model.Dataset + if err := query.Find(&items).Error; err != nil { + return nil, 0, fmt.Errorf("failed to execute dataset search: %w", err) + } + return items, total, nil +} + +func (r *Repository) listDatasetLabels(datasetIDs []int) (map[int][]model.Label, error) { + if len(datasetIDs) == 0 { + return nil, nil + } + + type datasetLabelResult struct { + model.Label + DatasetID int `gorm:"column:dataset_id"` + } + + var flatResults []datasetLabelResult + if err := r.db.Model(&model.Label{}). + Joins("JOIN dataset_labels dl ON dl.label_id = labels.id"). + Where("dl.dataset_id IN (?)", datasetIDs). + Select("labels.*, dl.dataset_id"). + Find(&flatResults).Error; err != nil { + return nil, fmt.Errorf("failed to batch query dataset labels: %w", err) + } + + labelsMap := make(map[int][]model.Label, len(datasetIDs)) + for _, id := range datasetIDs { + labelsMap[id] = []model.Label{} + } + for _, res := range flatResults { + labelsMap[res.DatasetID] = append(labelsMap[res.DatasetID], res.Label) + } + return labelsMap, nil +} + +func (r *Repository) updateDataset(dataset *model.Dataset) error { + if err := r.db.Omit(datasetCommonOmitFields).Save(dataset).Error; err != nil { + return fmt.Errorf("failed to update dataset: %v", err) + } + return nil +} + +func (r *Repository) addDatasetLabels(datasetLabels []model.DatasetLabel) error { + if len(datasetLabels) == 0 { + return nil + } + if err := r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "dataset_id"}, {Name: "label_id"}}, + DoNothing: true, + }).Create(&datasetLabels).Error; err != nil { + return fmt.Errorf("failed to add dataset-label associations: %w", err) + } + return nil +} + +func (r *Repository) listLabelIDsByKeyAndDatasetID(datasetID int, keys []string) ([]int, error) { + var labelIDs []int + if err := r.db.Table("labels l"). + Select("l.id"). + Joins("JOIN dataset_labels dl ON dl.label_id = l.id"). + Where("dl.dataset_id = ? AND l.label_key IN (?)", datasetID, keys). + Pluck("l.id", &labelIDs).Error; err != nil { + return nil, fmt.Errorf("failed to find label IDs by key '%s': %w", keys, err) + } + return labelIDs, nil +} + +func (r *Repository) clearDatasetLabels(datasetIDs []int, labelIDs []int) error { + if len(datasetIDs) == 0 { + return nil + } + + query := r.db.Table("dataset_labels").Where("dataset_id IN (?)", datasetIDs) + if len(labelIDs) > 0 { + query = query.Where("label_id IN (?)", labelIDs) + } + if err := query.Delete(nil).Error; err != nil { + return fmt.Errorf("failed to clear dataset-label associations: %w", err) + } + return nil +} + +func (r *Repository) batchDecreaseLabelUsages(labelIDs []int, decrement int) error { + if len(labelIDs) == 0 { + return nil + } + + expr := gorm.Expr("GREATEST(0, usage_count - ?)", decrement) + if err := r.db.Model(&model.Label{}). + Where("id IN (?)", labelIDs). + Clauses(clause.Returning{}). + UpdateColumn("usage_count", expr).Error; err != nil { + return fmt.Errorf("failed to batch decrease label usages: %w", err) + } + return nil +} + +func (r *Repository) listLabelsByDatasetID(datasetID int) ([]model.Label, error) { + var labels []model.Label + if err := r.db.Model(&model.Label{}). + Joins("JOIN dataset_labels dl ON dl.label_id = labels.id"). + Where("dl.dataset_id = ?", datasetID). + Find(&labels).Error; err != nil { + return nil, fmt.Errorf("failed to list labels for dataset %d: %w", datasetID, err) + } + return labels, nil +} + +func (r *Repository) batchCreateDatasetVersions(versions []model.DatasetVersion) error { + if len(versions) == 0 { + return fmt.Errorf("no dataset versions to create") + } + if err := r.db.Omit(datasetVersionModelOmitFields).Create(&versions).Error; err != nil { + return fmt.Errorf("failed to batch create dataset versions: %w", err) + } + return nil +} + +func (r *Repository) deleteDatasetVersion(versionID int) (int64, error) { + result := r.db.Model(&model.DatasetVersion{}). + Where("id = ? AND status != ?", versionID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if result.Error != nil { + return 0, fmt.Errorf("failed to soft delete dataset version %d: %w", versionID, result.Error) + } + return result.RowsAffected, nil +} + +func (r *Repository) getDatasetVersionByID(versionID int) (*model.DatasetVersion, error) { + var version model.DatasetVersion + if err := r.db.Preload("Datapacks").Where("id = ?", versionID).First(&version).Error; err != nil { + return nil, fmt.Errorf("failed to get dataset version: %v", err) + } + return &version, nil +} + +func (r *Repository) listDatasetVersions(limit, offset int, datasetID int, status *consts.StatusType) ([]model.DatasetVersion, int64, error) { + var ( + versions []model.DatasetVersion + total int64 + ) + + query := r.db.Model(&model.DatasetVersion{}).Where("dataset_id = ?", datasetID) + if status != nil { + query = query.Where("status = ?", *status) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count dataset versions: %v", err) + } + if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&versions).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list dataset versions: %v", err) + } + return versions, total, nil +} + +func (r *Repository) updateDatasetVersion(version *model.DatasetVersion) error { + if err := r.db.Omit(datasetVersionModelOmitFields).Save(version).Error; err != nil { + return fmt.Errorf("failed to update dataset version: %w", err) + } + return nil +} + +func (r *Repository) listInjectionIDsByNames(names []string) (map[string]int, error) { + if len(names) == 0 { + return map[string]int{}, nil + } + + var records []struct { + Name string `gorm:"column:name"` + ID int `gorm:"column:id"` + } + if err := r.db.Model(&model.FaultInjection{}). + Select("name, id"). + Where("state = ? AND status = ?", consts.DatapackBuildSuccess, consts.CommonEnabled). + Where("name IN (?)", names). + Find(&records).Error; err != nil { + return nil, fmt.Errorf("failed to query injection IDs: %w", err) + } + + result := make(map[string]int, len(records)) + for _, record := range records { + result[record.Name] = record.ID + } + return result, nil +} + +func (r *Repository) addDatasetVersionInjections(items []model.DatasetVersionInjection) error { + if len(items) == 0 { + return nil + } + if err := r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "dataset_version_id"}, {Name: "injection_id"}}, + DoNothing: true, + }).Create(&items).Error; err != nil { + return fmt.Errorf("failed to add dataset-version-injection associations: %w", err) + } + return nil +} + +func (r *Repository) clearDatasetVersionInjections(datasetVersionIDs []int, injectionIDs []int) error { + if len(datasetVersionIDs) == 0 { + return nil + } + + query := r.db.Table("dataset_version_injections").Where("dataset_version_id IN (?)", datasetVersionIDs) + if len(injectionIDs) > 0 { + query = query.Where("injection_id IN (?)", injectionIDs) + } + if err := query.Delete(nil).Error; err != nil { + return fmt.Errorf("failed to clear dataset-version-injection associations: %w", err) + } + return nil +} + +func (r *Repository) ListInjectionsByDatasetVersionID(versionID int, includeLabels bool) ([]model.FaultInjection, error) { + query := r.db.Model(&model.FaultInjection{}) + if includeLabels { + query = query.Preload("Labels") + } + + var injections []model.FaultInjection + if err := query. + Joins("JOIN dataset_version_injections dvi ON dvi.injection_id = id"). + Where("state = ? AND status != ?", consts.DatapackBuildSuccess, consts.CommonDeleted). + Where("dvi.dataset_version_id = ?", versionID). + Find(&injections).Error; err != nil { + return nil, fmt.Errorf("failed to list fault injections for dataset version %d: %w", versionID, err) + } + return injections, nil +} diff --git a/src/service/common/dataset.go b/src/module/dataset/resolve.go similarity index 55% rename from src/service/common/dataset.go rename to src/module/dataset/resolve.go index 3700f55e..96d19826 100644 --- a/src/service/common/dataset.go +++ b/src/module/dataset/resolve.go @@ -1,46 +1,39 @@ -package common +package dataset import ( - "aegis/database" "aegis/dto" - "aegis/repository" + "aegis/model" "fmt" ) -// mapRefsToDatasetVersions maps dataset refs to their corresponding dataset versions -func MapRefsToDatasetVersions(refs []*dto.DatasetRef, userID int) (map[*dto.DatasetRef]database.DatasetVersion, error) { - versions, err := getUniqueVersionsForDatasetRefs(refs, userID) +func (r *Repository) ResolveDatasetVersions(refs []*dto.DatasetRef, userID int) (map[*dto.DatasetRef]model.DatasetVersion, error) { + versions, err := getUniqueVersionsForDatasetRefs(r, refs, userID) if err != nil { return nil, fmt.Errorf("failed to batch get dataset versions: %w", err) } - flatMap := make(map[string][]database.DatasetVersion) - hierarchicalMap := make(map[string]map[string]database.DatasetVersion) - + flatMap := make(map[string][]model.DatasetVersion) + hierarchicalMap := make(map[string]map[string]model.DatasetVersion) for _, version := range versions { datasetName := version.Dataset.Name versionName := version.Name - flatMap[datasetName] = append(flatMap[datasetName], version) - if _, exists := hierarchicalMap[datasetName]; !exists { - hierarchicalMap[datasetName] = make(map[string]database.DatasetVersion) + hierarchicalMap[datasetName] = make(map[string]model.DatasetVersion) } hierarchicalMap[datasetName][versionName] = version } - results := make(map[*dto.DatasetRef]database.DatasetVersion, len(refs)) + results := make(map[*dto.DatasetRef]model.DatasetVersion, len(refs)) for _, ref := range refs { - var result database.DatasetVersion + var result model.DatasetVersion if ref.Version != "" { if _, exists := hierarchicalMap[ref.Name]; !exists { return nil, fmt.Errorf("dataset not found: %s", ref.Name) } - if _, exists := hierarchicalMap[ref.Name][ref.Version]; !exists { return nil, fmt.Errorf("dataset version not found: %s:%s", ref.Name, ref.Version) } - result = hierarchicalMap[ref.Name][ref.Version] } else { if _, exists := flatMap[ref.Name]; !exists { @@ -48,35 +41,25 @@ func MapRefsToDatasetVersions(refs []*dto.DatasetRef, userID int) (map[*dto.Data } result = flatMap[ref.Name][0] } - results[ref] = result } - return results, nil } -// getUniqueVersionsForDatasetrefs retrieves unique dataset versions for the given dataset refs -func getUniqueVersionsForDatasetRefs(refs []*dto.DatasetRef, userID int) ([]database.DatasetVersion, error) { +func getUniqueVersionsForDatasetRefs(repo *Repository, refs []*dto.DatasetRef, userID int) ([]model.DatasetVersion, error) { datasetNamesSet := make(map[string]struct{}, len(refs)) for _, ref := range refs { if ref.Name != "" { datasetNamesSet[ref.Name] = struct{}{} } } - if len(datasetNamesSet) == 0 { - return []database.DatasetVersion{}, nil + return []model.DatasetVersion{}, nil } requiredNames := make([]string, 0, len(datasetNamesSet)) for name := range datasetNamesSet { requiredNames = append(requiredNames, name) } - - versions, err := repository.BatchGetDatasetVersions(database.DB, requiredNames, userID) - if err != nil { - return nil, fmt.Errorf("failed to batch get dataset versions: %w", err) - } - - return versions, nil + return repo.batchGetDatasetVersions(requiredNames, userID) } diff --git a/src/module/dataset/service.go b/src/module/dataset/service.go new file mode 100644 index 00000000..aad5fc53 --- /dev/null +++ b/src/module/dataset/service.go @@ -0,0 +1,529 @@ +package dataset + +import ( + "archive/zip" + "context" + "errors" + "fmt" + + "aegis/consts" + "aegis/dto" + "aegis/model" + label "aegis/module/label" + "aegis/utils" + + "gorm.io/gorm" +) + +type Service struct { + repo *Repository + datapacks *DatapackFileStore +} + +func NewService(repo *Repository, datapacks *DatapackFileStore) *Service { + return &Service{repo: repo, datapacks: datapacks} +} + +func (s *Service) CreateDataset(_ context.Context, req *CreateDatasetReq, userID int) (*DatasetResp, error) { + if req == nil { + return nil, fmt.Errorf("request cannot be nil") + } + + dataset := req.ConvertToDataset() + var versions []model.DatasetVersion + if req.VersionReq != nil { + versions = append(versions, *req.VersionReq.ConvertToDatasetVersion()) + } + + if err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + createdDataset, err := s.createDatasetCore(repo, dataset, versions, userID) + if err != nil { + return fmt.Errorf("failed to create dataset: %w", err) + } + dataset = createdDataset + return nil + }); err != nil { + return nil, fmt.Errorf("failed to create dataset: %w", err) + } + + return NewDatasetResp(dataset), nil +} + +func (s *Service) DeleteDataset(_ context.Context, datasetID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if _, err := repo.batchDeleteDatasetVersions(datasetID); err != nil { + return fmt.Errorf("failed to delete dataset versions: %w", err) + } + if _, err := repo.removeUsersFromDataset(datasetID); err != nil { + return fmt.Errorf("failed to remove all users from dataset: %w", err) + } + rows, err := repo.deleteDataset(datasetID) + if err != nil { + return fmt.Errorf("failed to delete dataset: %w", err) + } + if rows == 0 { + return fmt.Errorf("%w: dataset id %d not found", consts.ErrNotFound, datasetID) + } + return nil + }) +} + +func (s *Service) GetDataset(_ context.Context, datasetID int) (*DatasetDetailResp, error) { + dataset, err := s.repo.getDatasetByID(datasetID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: dataset id: %d", consts.ErrNotFound, datasetID) + } + return nil, fmt.Errorf("failed to get dataset: %w", err) + } + + versions, err := s.repo.listDatasetVersionsByDatasetID(dataset.ID) + if err != nil { + return nil, fmt.Errorf("failed to get dataset versions: %w", err) + } + + resp := NewDatasetDetailResp(dataset) + for _, version := range versions { + resp.Versions = append(resp.Versions, *NewDatasetVersionResp(&version)) + } + + return resp, nil +} + +func (s *Service) ListDatasets(_ context.Context, req *ListDatasetReq) (*dto.ListResp[DatasetResp], error) { + limit, offset := req.ToGormParams() + + datasets, total, err := s.repo.listDatasets(limit, offset, req.Type, req.IsPublic, req.Status) + if err != nil { + return nil, fmt.Errorf("failed to list datasets: %w", err) + } + + datasetIDs := make([]int, 0, len(datasets)) + for _, dataset := range datasets { + datasetIDs = append(datasetIDs, dataset.ID) + } + + labelsMap, err := s.repo.listDatasetLabels(datasetIDs) + if err != nil { + return nil, fmt.Errorf("failed to list dataset labels: %w", err) + } + + items := make([]DatasetResp, 0, len(datasets)) + for i := range datasets { + if labels, ok := labelsMap[datasets[i].ID]; ok { + datasets[i].Labels = labels + } + items = append(items, *NewDatasetResp(&datasets[i])) + } + + return &dto.ListResp[DatasetResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) SearchDatasets(_ context.Context, req *SearchDatasetReq) (*dto.ListResp[DatasetDetailResp], error) { + if req == nil { + return nil, fmt.Errorf("search dataset request is nil") + } + + results, total, err := s.repo.searchDatasets(req.ConvertToSearchReq()) + if err != nil { + return nil, fmt.Errorf("failed to search datasets: %w", err) + } + + items := make([]DatasetDetailResp, 0, len(results)) + for i := range results { + items = append(items, *NewDatasetDetailResp(&results[i])) + } + + return &dto.ListResp[DatasetDetailResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) UpdateDataset(_ context.Context, req *UpdateDatasetReq, datasetID int) (*DatasetResp, error) { + var updatedDataset *model.Dataset + + if err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + dataset, err := repo.getDatasetByID(datasetID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: dataset id: %d", consts.ErrNotFound, datasetID) + } + return fmt.Errorf("failed to get dataset: %w", err) + } + + req.PatchDatasetModel(dataset) + if err := repo.updateDataset(dataset); err != nil { + return fmt.Errorf("failed to update dataset: %w", err) + } + + updatedDataset = dataset + return nil + }); err != nil { + return nil, err + } + + return NewDatasetResp(updatedDataset), nil +} + +func (s *Service) ManageDatasetLabels(_ context.Context, req *ManageDatasetLabelReq, datasetID int) (*DatasetResp, error) { + if req == nil { + return nil, fmt.Errorf("manage dataset labels request is nil") + } + + var managedDataset *model.Dataset + if err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + dataset, err := repo.getDatasetByID(datasetID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: dataset id: %d", consts.ErrNotFound, datasetID) + } + return fmt.Errorf("failed to get dataset: %w", err) + } + + if len(req.AddLabels) > 0 { + labels, err := label.NewRepository(tx).CreateOrUpdateLabelsFromItems(tx, req.AddLabels, consts.DatasetCategory) + if err != nil { + return fmt.Errorf("failed to create or update labels: %w", err) + } + + datasetLabels := make([]model.DatasetLabel, 0, len(labels)) + for _, label := range labels { + datasetLabels = append(datasetLabels, model.DatasetLabel{ + DatasetID: datasetID, + LabelID: label.ID, + }) + } + + if err := repo.addDatasetLabels(datasetLabels); err != nil { + return fmt.Errorf("failed to add dataset labels: %w", err) + } + } + + if len(req.RemoveLabels) > 0 { + labelIDs, err := repo.listLabelIDsByKeyAndDatasetID(datasetID, req.RemoveLabels) + if err != nil { + return fmt.Errorf("failed to find label ids by keys: %w", err) + } + + if len(labelIDs) > 0 { + if err := repo.clearDatasetLabels([]int{datasetID}, labelIDs); err != nil { + return fmt.Errorf("failed to clear dataset labels: %w", err) + } + + if err := repo.batchDecreaseLabelUsages(labelIDs, 1); err != nil { + return fmt.Errorf("failed to decrease label usage counts: %w", err) + } + } + } + + labels, err := repo.listLabelsByDatasetID(dataset.ID) + if err != nil { + return fmt.Errorf("failed to get dataset labels: %w", err) + } + + dataset.Labels = labels + managedDataset = dataset + return nil + }); err != nil { + return nil, err + } + + return NewDatasetResp(managedDataset), nil +} + +func (s *Service) CreateDatasetVersion(_ context.Context, req *CreateDatasetVersionReq, datasetID, userID int) (*DatasetVersionResp, error) { + if req == nil { + return nil, fmt.Errorf("create dataset version request is nil") + } + + version := req.ConvertToDatasetVersion() + version.DatasetID = datasetID + version.UserID = userID + + var createdVersion *model.DatasetVersion + if err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + versions, err := s.createDatasetVersionsCore(repo, []model.DatasetVersion{*version}) + if err != nil { + return fmt.Errorf("failed to create dataset version: %w", err) + } + + version := versions[0] + if len(req.Datapacks) > 0 { + if err := s.linkDatapacksToDatasetVersion(repo, version.ID, req.Datapacks); err != nil { + return fmt.Errorf("failed to link datapacks to dataset version: %w", err) + } + } + + createdVersion = &version + return nil + }); err != nil { + return nil, fmt.Errorf("failed to create dataset version: %w", err) + } + + return NewDatasetVersionResp(createdVersion), nil +} + +func (s *Service) DeleteDatasetVersion(_ context.Context, versionID int) error { + rows, err := s.repo.deleteDatasetVersion(versionID) + if err != nil { + return fmt.Errorf("failed to delete dataset version: %w", err) + } + if rows == 0 { + return fmt.Errorf("%w: dataset version id %d not found", consts.ErrNotFound, versionID) + } + return nil +} + +func (s *Service) GetDatasetVersion(_ context.Context, datasetID, versionID int) (*DatasetVersionDetailResp, error) { + if _, err := s.repo.getDatasetByID(datasetID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: dataset id: %d", consts.ErrNotFound, datasetID) + } + return nil, fmt.Errorf("failed to get dataset: %w", err) + } + + version, err := s.repo.getDatasetVersionByID(versionID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) + } + return nil, fmt.Errorf("failed to get dataset version: %w", err) + } + + return NewDatasetVersionDetailResp(version), nil +} + +func (s *Service) ListDatasetVersions(_ context.Context, req *ListDatasetVersionReq, datasetID int) (*dto.ListResp[DatasetVersionResp], error) { + limit, offset := req.ToGormParams() + + versions, total, err := s.repo.listDatasetVersions(limit, offset, datasetID, req.Status) + if err != nil { + return nil, fmt.Errorf("failed to list dataset versions: %w", err) + } + + items := make([]DatasetVersionResp, 0, len(versions)) + for i := range versions { + items = append(items, *NewDatasetVersionResp(&versions[i])) + } + + return &dto.ListResp[DatasetVersionResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) UpdateDatasetVersion(_ context.Context, req *UpdateDatasetVersionReq, datasetID, versionID int) (*DatasetVersionResp, error) { + _ = datasetID + + var updatedVersion *model.DatasetVersion + if err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + version, err := repo.getDatasetVersionByID(versionID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) + } + return fmt.Errorf("failed to get dataset version: %w", err) + } + + req.PatchDatasetVersionModel(version) + if err := repo.updateDatasetVersion(version); err != nil { + return fmt.Errorf("failed to update dataset version: %w", err) + } + + updatedVersion = version + return nil + }); err != nil { + return nil, fmt.Errorf("failed to update dataset version: %w", err) + } + + return NewDatasetVersionResp(updatedVersion), nil +} + +func (s *Service) GetDatasetVersionFilename(_ context.Context, datasetID, versionID int) (string, error) { + dataset, err := s.repo.getDatasetByID(datasetID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", fmt.Errorf("%w: dataset id: %d", consts.ErrNotFound, datasetID) + } + return "", fmt.Errorf("failed to get dataset: %w", err) + } + + version, err := s.repo.getDatasetVersionByID(versionID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) + } + return "", fmt.Errorf("failed to get dataset version: %w", err) + } + + return fmt.Sprintf("%s-%s", dataset.Name, version.Name), nil +} + +func (s *Service) DownloadDatasetVersion(_ context.Context, zipWriter *zip.Writer, excludeRules []utils.ExculdeRule, versionID int) error { + if zipWriter == nil { + return fmt.Errorf("zip writer cannot be nil") + } + + datapacks, err := s.repo.ListInjectionsByDatasetVersionID(versionID, false) + if err != nil { + return fmt.Errorf("failed to list datapacks for dataset version: %w", err) + } + + if err := s.datapacks.PackageToZip(zipWriter, datapacks, excludeRules); err != nil { + return fmt.Errorf("failed to package dataset to zip: %w", err) + } + + return nil +} + +func (s *Service) ManageDatasetVersionInjections(_ context.Context, req *ManageDatasetVersionInjectionReq, versionID int) (*DatasetVersionDetailResp, error) { + if req == nil { + return nil, fmt.Errorf("manage dataset version injections request is nil") + } + + var managedVersion *model.DatasetVersion + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + version, err := repo.getDatasetVersionByID(versionID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: dataset version id: %d", consts.ErrNotFound, versionID) + } + return fmt.Errorf("failed to get dataset version: %w", err) + } + + if len(req.AddDatapacks) > 0 { + if err := s.linkDatapacksToDatasetVersion(repo, versionID, req.AddDatapacks); err != nil { + return fmt.Errorf("failed to link datapacks to dataset version: %w", err) + } + } + + if len(req.RemoveDatapacks) > 0 { + injectionIDMap, err := repo.listInjectionIDsByNames(req.RemoveDatapacks) + if err != nil { + return fmt.Errorf("failed to list injections by names: %w", err) + } + if len(injectionIDMap) != len(req.RemoveDatapacks) { + return fmt.Errorf("some datapacks to remove were not found") + } + + injectionIDs := make([]int, 0, len(req.RemoveDatapacks)) + for _, datapack := range req.RemoveDatapacks { + injectionID, ok := injectionIDMap[datapack] + if !ok { + return fmt.Errorf("injection not found: %s", datapack) + } + injectionIDs = append(injectionIDs, injectionID) + } + + if err := repo.clearDatasetVersionInjections([]int{version.ID}, injectionIDs); err != nil { + return fmt.Errorf("failed to remove dataset version datapacks: %w", err) + } + } + + datapacks, err := repo.ListInjectionsByDatasetVersionID(version.ID, false) + if err != nil { + return fmt.Errorf("failed to list datapacks for dataset version: %w", err) + } + + version.Datapacks = datapacks + version.FileCount = version.FileCount + len(req.AddDatapacks) - len(req.RemoveDatapacks) + if err := repo.updateDatasetVersion(version); err != nil { + return fmt.Errorf("failed to update dataset version file count: %w", err) + } + + managedVersion = version + return nil + }) + if err != nil { + return nil, err + } + + return NewDatasetVersionDetailResp(managedVersion), nil +} + +func (s *Service) createDatasetCore(repo *Repository, dataset *model.Dataset, versions []model.DatasetVersion, userID int) (*model.Dataset, error) { + role, err := repo.getRoleByName(consts.RoleDatasetAdmin.String()) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: role %v not found", consts.ErrNotFound, consts.RoleDatasetAdmin) + } + return nil, fmt.Errorf("failed to get dataset owner role: %w", err) + } + + if err := repo.createDataset(dataset); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return nil, consts.ErrAlreadyExists + } + return nil, err + } + + if err := repo.createUserDataset(&model.UserDataset{ + UserID: userID, + DatasetID: dataset.ID, + RoleID: role.ID, + Status: consts.CommonEnabled, + }); err != nil { + return nil, fmt.Errorf("failed to associate dataset with user: %w", err) + } + + if len(versions) > 0 { + for i := range versions { + versions[i].DatasetID = dataset.ID + versions[i].UserID = userID + } + + if _, err := s.createDatasetVersionsCore(repo, versions); err != nil { + return nil, fmt.Errorf("failed to create dataset versions: %w", err) + } + } + + return dataset, nil +} + +func (s *Service) createDatasetVersionsCore(repo *Repository, versions []model.DatasetVersion) ([]model.DatasetVersion, error) { + if len(versions) == 0 { + return nil, nil + } + + if err := repo.batchCreateDatasetVersions(versions); err != nil { + return nil, fmt.Errorf("failed to create dataset versions: %w", err) + } + + return versions, nil +} + +func (s *Service) linkDatapacksToDatasetVersion(repo *Repository, versionID int, datapacks []string) error { + injectionIDMap, err := repo.listInjectionIDsByNames(datapacks) + if err != nil { + return fmt.Errorf("failed to list injections by names: %w", err) + } + + items := make([]model.DatasetVersionInjection, 0, len(datapacks)) + for _, datapack := range datapacks { + injectionID, ok := injectionIDMap[datapack] + if !ok { + return fmt.Errorf("injection not found: %s", datapack) + } + items = append(items, model.DatasetVersionInjection{ + DatasetVersionID: versionID, + InjectionID: injectionID, + }) + } + + if err := repo.addDatasetVersionInjections(items); err != nil { + return fmt.Errorf("failed to add dataset version injections: %w", err) + } + + return nil +} diff --git a/src/handlers/docs.go b/src/module/docs/swagger_models.go similarity index 86% rename from src/handlers/docs.go rename to src/module/docs/swagger_models.go index 9d7fa339..c9e45787 100644 --- a/src/handlers/docs.go +++ b/src/module/docs/swagger_models.go @@ -1,9 +1,13 @@ -package handlers +package docs import ( + group "aegis/module/group" + "github.com/gin-gonic/gin" ) +type GroupStreamEvent = group.GroupStreamEvent + // SwaggerModelsDoc is a documentation-only endpoint that ensures all DTO models are included in Swagger. // This endpoint should NEVER be registered in the actual router. // @@ -13,7 +17,7 @@ import ( // @Accept json // @Produce json // @Success 200 {object} dto.TraceStreamEvent "Trace-level stream event structure" -// @Success 200 {object} dto.GroupStreamEvent "Group-level stream event structure" +// @Success 200 {object} GroupStreamEvent "Group-level stream event structure" // @Success 200 {object} dto.DatapackInfo "Datapack information structure" // @Success 200 {object} dto.DatapackResult "Datapack result structure" // @Success 200 {object} dto.ExecutionInfo "Execution information structure" @@ -32,7 +36,7 @@ import ( // @Success 200 {object} consts.StatusType "Status type constants" // @Success 200 {object} consts.TaskState "Task state constants" // @Success 200 {object} consts.TaskType "Task type constants" -// @Success 200 {object} consts.SSEEventName "SSE event name constants" +// @Success 200 {object} consts.SSEEventName "SSE event name constants" // @Router /api/_docs/models [get] -// @x-api-type {"sdk":"true"} +// @x-api-type {"portal":"true","sdk":"true"} func SwaggerModelsDoc(c *gin.Context) {} diff --git a/src/dto/evaluation.go b/src/module/evaluation/api_types.go similarity index 67% rename from src/dto/evaluation.go rename to src/module/evaluation/api_types.go index 38a79e0a..ca1bc3be 100644 --- a/src/dto/evaluation.go +++ b/src/module/evaluation/api_types.go @@ -1,24 +1,23 @@ -package dto +package evaluation import ( - "aegis/config" - "aegis/database" "fmt" "time" + "aegis/config" + "aegis/dto" + "aegis/model" + execution "aegis/module/execution" + chaos "github.com/OperationsPAI/chaos-experiment/handler" ) -// ===================================================================== -// Evaluation CRUD DTOs -// ===================================================================== - -// ListEvaluationReq represents the request for listing evaluations +// ListEvaluationReq represents the request for listing evaluations. type ListEvaluationReq struct { - PaginationReq + dto.PaginationReq } -// EvaluationResp represents an evaluation in API responses +// EvaluationResp represents an evaluation in API responses. type EvaluationResp struct { ID int `json:"id"` ProjectID *int `json:"project_id,omitempty"` @@ -37,8 +36,7 @@ type EvaluationResp struct { UpdatedAt time.Time `json:"updated_at"` } -// NewEvaluationResp creates an EvaluationResp from a database Evaluation -func NewEvaluationResp(eval *database.Evaluation) *EvaluationResp { +func NewEvaluationResp(eval *model.Evaluation) *EvaluationResp { return &EvaluationResp{ ID: eval.ID, ProjectID: eval.ProjectID, @@ -58,29 +56,25 @@ func NewEvaluationResp(eval *database.Evaluation) *EvaluationResp { } } -// Execution represents execution data for evaluation +// Execution represents execution data for evaluation. type Execution struct { - Items []GranularityResultItem `json:"items"` + Items []execution.GranularityResultItem `json:"items"` } -// Conclusion represents evaluation conclusion +// Conclusion represents evaluation conclusion. type Conclusion struct { - Level string `json:"level"` // For example service level - Metric string `json:"metric"` // For example topk + Level string `json:"level"` + Metric string `json:"metric"` Rate float64 `json:"rate"` } -// EvaluateMetric represents evaluation metric function type +// EvaluateMetric represents evaluation metric function type. type EvaluateMetric func([]Execution) ([]Conclusion, error) -// ===================================================================== -// Batch Evaluate Datapack DTOs -// ===================================================================== - type EvaluateDatapackSpec struct { - Algorithm ContainerRef `json:"algorithm" binding:"required"` - Datapack string `json:"datapack" binding:"required"` - FilterLabels []LabelItem `json:"filter_labels" binding:"omitempty"` + Algorithm dto.ContainerRef `json:"algorithm" binding:"required"` + Datapack string `json:"datapack" binding:"required"` + FilterLabels []dto.LabelItem `json:"filter_labels" binding:"omitempty"` } func (spec *EvaluateDatapackSpec) Validate() error { @@ -90,12 +84,10 @@ func (spec *EvaluateDatapackSpec) Validate() error { if spec.Algorithm.Name == config.GetDetectorName() { return fmt.Errorf("detector algorithm cannot be used for evaluation") } - if spec.Datapack == "" { return fmt.Errorf("datapack cannot be empty") } - - return validateLabelItemsFiled(spec.FilterLabels) + return validateLabelItems(spec.FilterLabels) } type BatchEvaluateDatapackReq struct { @@ -115,9 +107,9 @@ func (req *BatchEvaluateDatapackReq) Validate() error { } type EvaluateDatapackRef struct { - Datapack string `json:"datapack"` - Groundtruths []chaos.Groundtruth `json:"groundtruths"` - ExecutionRefs []ExecutionRef `json:"execution_refs"` + Datapack string `json:"datapack"` + Groundtruths []chaos.Groundtruth `json:"groundtruths"` + ExecutionRefs []execution.ExecutionRef `json:"execution_refs"` } type EvaluateDatapackItem struct { @@ -133,14 +125,10 @@ type BatchEvaluateDatapackResp struct { SuccessItems []EvaluateDatapackItem `json:"success_items"` } -// ===================================================================== -// Batch Evaluate Dataset DTOs -// ===================================================================== - type EvaluateDatasetSpec struct { - Algorithm ContainerRef `json:"algorithm" binding:"required"` - Dataset DatasetRef `json:"dataset" binding:"required"` - FilterLabels []LabelItem `json:"filter_labels" binding:"omitempty"` + Algorithm dto.ContainerRef `json:"algorithm" binding:"required"` + Dataset dto.DatasetRef `json:"dataset" binding:"required"` + FilterLabels []dto.LabelItem `json:"filter_labels" binding:"omitempty"` } func (spec *EvaluateDatasetSpec) Validate() error { @@ -150,12 +138,10 @@ func (spec *EvaluateDatasetSpec) Validate() error { if spec.Algorithm.Name == config.GetDetectorName() { return fmt.Errorf("detector algorithm cannot be used for evaluation") } - if err := spec.Dataset.Validate(); err != nil { return fmt.Errorf("invalid dataset: %w", err) } - - return validateLabelItemsFiled(spec.FilterLabels) + return validateLabelItems(spec.FilterLabels) } type BatchEvaluateDatasetReq struct { @@ -175,13 +161,13 @@ func (req *BatchEvaluateDatasetReq) Validate() error { } type EvaluateDatasetItem struct { - Algorithm string `json:"algorithm"` // Algorithm name - AlgorithmVersion string `json:"algorithm_version"` // Algorithm version - Dataset string `json:"dataset"` // Dataset name - DatasetVersion string `json:"dataset_version"` // Dataset version - TotalCount int `json:"total_count"` // Total number of datapacks in dataset - EvaluateRefs []EvaluateDatapackRef `json:"evalaute_refs"` // Evaluation refs for each dataset - NotExecutedDatapacks []string `json:"not_executed_datapacks"` // Datapacks that were not executed + Algorithm string `json:"algorithm"` + AlgorithmVersion string `json:"algorithm_version"` + Dataset string `json:"dataset"` + DatasetVersion string `json:"dataset_version"` + TotalCount int `json:"total_count"` + EvaluateRefs []EvaluateDatapackRef `json:"evalaute_refs"` + NotExecutedDatapacks []string `json:"not_executed_datapacks"` } type BatchEvaluateDatasetResp struct { @@ -190,3 +176,15 @@ type BatchEvaluateDatasetResp struct { SuccessCount int `json:"success_count"` SuccessItems []EvaluateDatasetItem `json:"success_items"` } + +func validateLabelItems(items []dto.LabelItem) error { + for i, label := range items { + if label.Key == "" { + return fmt.Errorf("empty label key at index %d", i) + } + if label.Value == "" { + return fmt.Errorf("empty label value at index %d", i) + } + } + return nil +} diff --git a/src/module/evaluation/execution_query.go b/src/module/evaluation/execution_query.go new file mode 100644 index 00000000..6782cf7e --- /dev/null +++ b/src/module/evaluation/execution_query.go @@ -0,0 +1,76 @@ +package evaluation + +import ( + "context" + "fmt" + + "aegis/internalclient/orchestratorclient" + execution "aegis/module/execution" + + "go.uber.org/fx" +) + +type executionQuerySource interface { + ListEvaluationExecutionsByDatapack(context.Context, *execution.EvaluationExecutionsByDatapackReq) ([]execution.EvaluationExecutionItem, error) + ListEvaluationExecutionsByDataset(context.Context, *execution.EvaluationExecutionsByDatasetReq) ([]execution.EvaluationExecutionItem, error) +} + +type executionQueryAdapter struct { + orchestrator *orchestratorclient.Client + local *execution.Service + requireRemote bool +} + +type executionQuerySourceParams struct { + fx.In + + Orchestrator *orchestratorclient.Client `optional:"true"` + Local *execution.Service `optional:"true"` +} + +func newExecutionQuerySource(params executionQuerySourceParams) executionQuerySource { + return executionQueryAdapter{ + orchestrator: params.Orchestrator, + local: params.Local, + requireRemote: false, + } +} + +func newRemoteExecutionQuerySource(params executionQuerySourceParams) executionQuerySource { + return executionQueryAdapter{ + orchestrator: params.Orchestrator, + local: params.Local, + requireRemote: true, + } +} + +func (a executionQueryAdapter) ListEvaluationExecutionsByDatapack(ctx context.Context, req *execution.EvaluationExecutionsByDatapackReq) ([]execution.EvaluationExecutionItem, error) { + if a.orchestrator != nil && a.orchestrator.Enabled() { + return a.orchestrator.ListEvaluationExecutionsByDatapack(ctx, req) + } + if a.requireRemote { + return nil, fmt.Errorf("orchestrator-service query source is not configured") + } + if a.local == nil { + return nil, fmt.Errorf("evaluation execution query source is not configured") + } + return a.local.ListEvaluationExecutionsByDatapack(ctx, req) +} + +func (a executionQueryAdapter) ListEvaluationExecutionsByDataset(ctx context.Context, req *execution.EvaluationExecutionsByDatasetReq) ([]execution.EvaluationExecutionItem, error) { + if a.orchestrator != nil && a.orchestrator.Enabled() { + return a.orchestrator.ListEvaluationExecutionsByDataset(ctx, req) + } + if a.requireRemote { + return nil, fmt.Errorf("orchestrator-service query source is not configured") + } + if a.local == nil { + return nil, fmt.Errorf("evaluation execution query source is not configured") + } + return a.local.ListEvaluationExecutionsByDataset(ctx, req) +} + +// RemoteQueryOption forces the dedicated resource-service path to use orchestrator RPC only. +func RemoteQueryOption() fx.Option { + return fx.Decorate(newRemoteExecutionQuerySource) +} diff --git a/src/handlers/v2/evaluations.go b/src/module/evaluation/handler.go similarity index 56% rename from src/handlers/v2/evaluations.go rename to src/module/evaluation/handler.go index 6e51394a..22ef4942 100644 --- a/src/handlers/v2/evaluations.go +++ b/src/module/evaluation/handler.go @@ -1,18 +1,24 @@ -package v2 +package evaluation import ( + "aegis/httpx" "net/http" "aegis/consts" "aegis/dto" - "aegis/handlers" "aegis/middleware" - "aegis/service/analyzer" - producer "aegis/service/producer" "github.com/gin-gonic/gin" ) +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + // ListDatapackEvaluationResults retrieves evaluation data for multiple algorithm-datapack pairs // // @Summary List Datapack Evaluation Results @@ -22,22 +28,22 @@ import ( // @Accept json // @Produce json // @Security BearerAuth -// @Param request body dto.BatchEvaluateDatapackReq true "Batch evaluation request containing multiple algorithm-datapack pairs" -// @Success 200 {object} dto.GenericResponse[dto.BatchEvaluateDatapackResp] "Batch algorithm datapack evaluation data retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format/parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param request body BatchEvaluateDatapackReq true "Batch evaluation request containing multiple algorithm-datapack pairs" +// @Success 200 {object} dto.GenericResponse[BatchEvaluateDatapackResp] "Batch algorithm datapack evaluation data retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format/parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/evaluations/datapacks [post] // @x-api-type {"sdk":"true"} -func ListDatapackEvaluationResults(c *gin.Context) { +func (h *Handler) ListDatapackEvaluationResults(c *gin.Context) { userID, exists := middleware.GetCurrentUserID(c) if !exists || userID <= 0 { dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") return } - var req dto.BatchEvaluateDatapackReq + var req BatchEvaluateDatapackReq if err := c.ShouldBindBodyWithJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -48,8 +54,8 @@ func ListDatapackEvaluationResults(c *gin.Context) { return } - resp, err := analyzer.ListDatapackEvaluationResults(&req, userID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ListDatapackEvaluationResults(c.Request.Context(), &req, userID) + if httpx.HandleServiceError(c, err) { return } @@ -65,22 +71,22 @@ func ListDatapackEvaluationResults(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param request body dto.BatchEvaluateDatasetReq true "Batch evaluation request containing multiple algorithm-dataset pairs" -// @Success 200 {object} dto.GenericResponse[dto.BatchEvaluateDatasetResp] "Batch algorithm dataset evaluation data retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format/parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param request body BatchEvaluateDatasetReq true "Batch evaluation request containing multiple algorithm-dataset pairs" +// @Success 200 {object} dto.GenericResponse[BatchEvaluateDatasetResp] "Batch algorithm dataset evaluation data retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format/parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/evaluations/datasets [post] // @x-api-type {"sdk":"true"} -func ListDatasetEvaluationResults(c *gin.Context) { +func (h *Handler) ListDatasetEvaluationResults(c *gin.Context) { userID, exists := middleware.GetCurrentUserID(c) if !exists || userID <= 0 { dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") return } - var req dto.BatchEvaluateDatasetReq + var req BatchEvaluateDatasetReq if err := c.ShouldBindBodyWithJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -91,8 +97,8 @@ func ListDatasetEvaluationResults(c *gin.Context) { return } - resp, err := analyzer.ListDatasetEvaluationResults(&req, userID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ListDatasetEvaluationResults(c.Request.Context(), &req, userID) + if httpx.HandleServiceError(c, err) { return } @@ -107,16 +113,16 @@ func ListDatasetEvaluationResults(c *gin.Context) { // @ID list_evaluations // @Produce json // @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.EvaluationResp]] "Evaluations retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Success 200 {object} dto.GenericResponse[dto.ListResp[EvaluationResp]] "Evaluations retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/evaluations [get] // @x-api-type {"sdk":"true"} -func ListEvaluations(c *gin.Context) { - var req dto.ListEvaluationReq +func (h *Handler) ListEvaluations(c *gin.Context) { + var req ListEvaluationReq if err := c.ShouldBindQuery(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -127,8 +133,8 @@ func ListEvaluations(c *gin.Context) { return } - resp, err := producer.ListEvaluations(&req) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ListEvaluations(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { return } @@ -143,21 +149,21 @@ func ListEvaluations(c *gin.Context) { // @ID get_evaluation_by_id // @Produce json // @Security BearerAuth -// @Param id path int true "Evaluation ID" -// @Success 200 {object} dto.GenericResponse[dto.EvaluationResp] "Evaluation retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid evaluation ID" -// @Failure 404 {object} dto.GenericResponse[any] "Evaluation not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param id path int true "Evaluation ID" +// @Success 200 {object} dto.GenericResponse[EvaluationResp] "Evaluation retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid evaluation ID" +// @Failure 404 {object} dto.GenericResponse[any] "Evaluation not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/evaluations/{id} [get] // @x-api-type {"sdk":"true"} -func GetEvaluation(c *gin.Context) { - id, ok := handlers.ParsePositiveID(c, c.Param(consts.URLPathID), "evaluation ID") +func (h *Handler) GetEvaluation(c *gin.Context) { + id, ok := httpx.ParsePositiveID(c, c.Param(consts.URLPathID), "evaluation ID") if !ok { return } - resp, err := producer.GetEvaluation(id) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.GetEvaluation(c.Request.Context(), id) + if httpx.HandleServiceError(c, err) { return } @@ -178,15 +184,14 @@ func GetEvaluation(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "Evaluation not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/evaluations/{id} [delete] -// @x-api-type {"sdk":"true"} -func DeleteEvaluation(c *gin.Context) { - id, ok := handlers.ParsePositiveID(c, c.Param(consts.URLPathID), "evaluation ID") +// @x-api-type {"portal":"true"} +func (h *Handler) DeleteEvaluation(c *gin.Context) { + id, ok := httpx.ParsePositiveID(c, c.Param(consts.URLPathID), "evaluation ID") if !ok { return } - err := producer.DeleteEvaluation(id) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.DeleteEvaluation(c.Request.Context(), id)) { return } diff --git a/src/module/evaluation/handler_service.go b/src/module/evaluation/handler_service.go new file mode 100644 index 00000000..1fc6fdbd --- /dev/null +++ b/src/module/evaluation/handler_service.go @@ -0,0 +1,20 @@ +package evaluation + +import ( + "context" + + "aegis/dto" +) + +// HandlerService captures evaluation operations consumed by the HTTP handler. +type HandlerService interface { + ListDatapackEvaluationResults(context.Context, *BatchEvaluateDatapackReq, int) (*BatchEvaluateDatapackResp, error) + ListDatasetEvaluationResults(context.Context, *BatchEvaluateDatasetReq, int) (*BatchEvaluateDatasetResp, error) + ListEvaluations(context.Context, *ListEvaluationReq) (*dto.ListResp[EvaluationResp], error) + GetEvaluation(context.Context, int) (*EvaluationResp, error) + DeleteEvaluation(context.Context, int) error +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/evaluation/module.go b/src/module/evaluation/module.go new file mode 100644 index 00000000..3b88adf9 --- /dev/null +++ b/src/module/evaluation/module.go @@ -0,0 +1,11 @@ +package evaluation + +import "go.uber.org/fx" + +var Module = fx.Module("evaluation", + fx.Provide(NewRepository), + fx.Provide(newExecutionQuerySource), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/repository/evaluation.go b/src/module/evaluation/repository.go similarity index 58% rename from src/repository/evaluation.go rename to src/module/evaluation/repository.go index cf7cce99..2a737e27 100644 --- a/src/repository/evaluation.go +++ b/src/module/evaluation/repository.go @@ -1,37 +1,42 @@ -package repository +package evaluation import ( - "fmt" - "aegis/consts" - "aegis/database" + "aegis/model" + "fmt" "gorm.io/gorm" ) -func ListEvaluations(db *gorm.DB, limit, offset int) ([]database.Evaluation, int64, error) { - var evaluations []database.Evaluation - var total int64 +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) ListEvaluations(limit, offset int) ([]model.Evaluation, int64, error) { + var ( + evaluations []model.Evaluation + total int64 + ) - query := db.Model(&database.Evaluation{}). + query := r.db.Model(&model.Evaluation{}). Where("status != ?", consts.CommonDeleted) - if err := query.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count evaluations: %w", err) } - - if err := query.Select("id, project_id, algorithm_name, algorithm_version, datapack_name, dataset_name, dataset_version, eval_type, precision, recall, f1_score, accuracy, status, created_at, updated_at").Limit(limit).Offset(offset).Order("updated_at DESC").Find(&evaluations).Error; err != nil { + if err := query.Select("id, project_id, algorithm_name, algorithm_version, datapack_name, dataset_name, dataset_version, eval_type, precision, recall, f1_score, accuracy, status, created_at, updated_at"). + Limit(limit).Offset(offset).Order("updated_at DESC").Find(&evaluations).Error; err != nil { return nil, 0, fmt.Errorf("failed to list evaluations: %w", err) } - return evaluations, total, nil } -func GetEvaluationByID(db *gorm.DB, id int) (*database.Evaluation, error) { - var evaluation database.Evaluation - if err := db. - Where("id = ? AND status != ?", id, consts.CommonDeleted). - First(&evaluation).Error; err != nil { +func (r *Repository) GetEvaluationByID(id int) (*model.Evaluation, error) { + var evaluation model.Evaluation + if err := r.db.Where("id = ? AND status != ?", id, consts.CommonDeleted).First(&evaluation).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("evaluation with id %d: %w", id, consts.ErrNotFound) } @@ -40,15 +45,8 @@ func GetEvaluationByID(db *gorm.DB, id int) (*database.Evaluation, error) { return &evaluation, nil } -func CreateEvaluation(db *gorm.DB, eval *database.Evaluation) error { - if err := db.Create(eval).Error; err != nil { - return fmt.Errorf("failed to create evaluation: %w", err) - } - return nil -} - -func DeleteEvaluation(db *gorm.DB, id int) error { - result := db.Model(&database.Evaluation{}). +func (r *Repository) DeleteEvaluation(id int) error { + result := r.db.Model(&model.Evaluation{}). Where("id = ? AND status != ?", id, consts.CommonDeleted). Update("status", consts.CommonDeleted) if err := result.Error; err != nil { diff --git a/src/module/evaluation/service.go b/src/module/evaluation/service.go new file mode 100644 index 00000000..c00cee0d --- /dev/null +++ b/src/module/evaluation/service.go @@ -0,0 +1,297 @@ +package evaluation + +import ( + "context" + "encoding/json" + "fmt" + + "aegis/consts" + "aegis/dto" + "aegis/model" + container "aegis/module/container" + dataset "aegis/module/dataset" + execution "aegis/module/execution" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type Service struct { + repo *Repository + query executionQuerySource +} + +func NewService(repo *Repository, query executionQuerySource) *Service { + return &Service{ + repo: repo, + query: query, + } +} + +func (s *Service) ListDatapackEvaluationResults(ctx context.Context, req *BatchEvaluateDatapackReq, userID int) (*BatchEvaluateDatapackResp, error) { + if req == nil { + return nil, fmt.Errorf("batch evaluate datapack request is nil") + } + + algorithms := make([]*dto.ContainerRef, 0, len(req.Specs)) + for i := range req.Specs { + algorithms = append(algorithms, &req.Specs[i].Algorithm) + } + + algorithmVersionResults, err := container.NewRepository(s.repo.db).ResolveContainerVersions(algorithms, consts.ContainerTypeAlgorithm, userID) + if err != nil { + return nil, fmt.Errorf("failed to map container refs to versions: %w", err) + } + + successItems := make([]EvaluateDatapackItem, 0, len(req.Specs)) + failedItems := make([]string, 0) + + for i := range req.Specs { + spec := &req.Specs[i] + specIdentifier := fmt.Sprintf("spec[%d]: algorithm=%s, datapack=%s", i, spec.Algorithm.Name, spec.Datapack) + + algorithmVersion, exists := algorithmVersionResults[algorithms[i]] + if !exists { + failedItems = append(failedItems, fmt.Sprintf("%s - algorithm version not found", specIdentifier)) + continue + } + + executions, err := s.listEvaluationExecutionsByDatapack(ctx, &execution.EvaluationExecutionsByDatapackReq{ + AlgorithmVersionID: algorithmVersion.ID, + DatapackName: spec.Datapack, + FilterLabels: spec.FilterLabels, + }) + if err != nil { + failedItems = append(failedItems, fmt.Sprintf("%s - failed to query executions: %v", specIdentifier, err)) + continue + } + if len(executions) == 0 { + failedItems = append(failedItems, fmt.Sprintf("%s - no executions found", specIdentifier)) + continue + } + + refs := make([]execution.ExecutionRef, 0, len(executions)) + for _, execution := range executions { + refs = append(refs, execution.ExecutionRef) + } + + evaluateRef := EvaluateDatapackRef{ + Datapack: spec.Datapack, + ExecutionRefs: refs, + } + + if len(executions[0].Groundtruths) > 0 { + evaluateRef.Groundtruths = executions[0].Groundtruths + } + + successItems = append(successItems, EvaluateDatapackItem{ + Algorithm: algorithmVersion.Container.Name, + AlgorithmVersion: algorithmVersion.Name, + EvaluateDatapackRef: evaluateRef, + }) + } + + persistEvaluations(s.repo.db, "datapack", successItems, func(item *EvaluateDatapackItem) *model.Evaluation { + return &model.Evaluation{ + AlgorithmName: item.Algorithm, + AlgorithmVersion: item.AlgorithmVersion, + DatapackName: item.Datapack, + EvalType: consts.EvalTypeDatapack, + Status: consts.CommonEnabled, + } + }) + + return &BatchEvaluateDatapackResp{ + SuccessCount: len(successItems), + SuccessItems: successItems, + FailedCount: len(failedItems), + FailedItems: failedItems, + }, nil +} + +func (s *Service) ListDatasetEvaluationResults(ctx context.Context, req *BatchEvaluateDatasetReq, userID int) (*BatchEvaluateDatasetResp, error) { + if req == nil { + return nil, fmt.Errorf("batch evaluate datapack request is nil") + } + + algorithms := make([]*dto.ContainerRef, 0, len(req.Specs)) + datasets := make([]*dto.DatasetRef, 0, len(req.Specs)) + for i := range req.Specs { + algorithms = append(algorithms, &req.Specs[i].Algorithm) + datasets = append(datasets, &req.Specs[i].Dataset) + } + + algorithmVersionResults, err := container.NewRepository(s.repo.db).ResolveContainerVersions(algorithms, consts.ContainerTypeAlgorithm, userID) + if err != nil { + return nil, fmt.Errorf("failed to map container refs to versions: %w", err) + } + + datasetVersionResults, err := dataset.NewRepository(s.repo.db).ResolveDatasetVersions(datasets, userID) + if err != nil { + return nil, fmt.Errorf("failed to map dataset refs to versions: %w", err) + } + + successItems := make([]EvaluateDatasetItem, 0, len(req.Specs)) + failedItems := make([]string, 0) + + for i := range req.Specs { + spec := &req.Specs[i] + specIdentifier := fmt.Sprintf("spec[%d]: algorithm=%s, dataset=%s", i, spec.Algorithm.Name, spec.Dataset.Name) + + algorithmVersion, exists := algorithmVersionResults[algorithms[i]] + if !exists { + failedItems = append(failedItems, fmt.Sprintf("%s - algorithm version not found", specIdentifier)) + continue + } + + datasetVersion, exists := datasetVersionResults[datasets[i]] + if !exists { + failedItems = append(failedItems, fmt.Sprintf("%s - dataset version not found", specIdentifier)) + continue + } + + executions, err := s.listEvaluationExecutionsByDataset(ctx, &execution.EvaluationExecutionsByDatasetReq{ + AlgorithmVersionID: algorithmVersion.ID, + DatasetVersionID: datasetVersion.ID, + FilterLabels: spec.FilterLabels, + }) + if err != nil { + failedItems = append(failedItems, fmt.Sprintf("%s - failed to query executions: %v", specIdentifier, err)) + continue + } + if len(executions) == 0 { + failedItems = append(failedItems, fmt.Sprintf("%s - no executions found", specIdentifier)) + continue + } + + executionMap := make(map[string][]execution.EvaluationExecutionItem) + for _, executionItem := range executions { + name := executionItem.Datapack + if _, exists := executionMap[name]; !exists { + executionMap[name] = make([]execution.EvaluationExecutionItem, 0) + } + executionMap[name] = append(executionMap[name], executionItem) + } + + notExecutedDatapacks := make([]string, 0) + for _, datapack := range datasetVersion.Datapacks { + if _, exists := executionMap[datapack.Name]; !exists { + notExecutedDatapacks = append(notExecutedDatapacks, datapack.Name) + } + } + + evaluateRefs := make([]EvaluateDatapackRef, 0, len(executionMap)) + for datapackName, groupedExecutions := range executionMap { + refs := make([]execution.ExecutionRef, 0, len(groupedExecutions)) + for _, executionItem := range groupedExecutions { + refs = append(refs, executionItem.ExecutionRef) + } + + evaluateRef := EvaluateDatapackRef{ + Datapack: datapackName, + ExecutionRefs: refs, + } + + if len(groupedExecutions[0].Groundtruths) > 0 { + evaluateRef.Groundtruths = groupedExecutions[0].Groundtruths + } + + evaluateRefs = append(evaluateRefs, evaluateRef) + } + + successItems = append(successItems, EvaluateDatasetItem{ + Algorithm: algorithmVersion.Container.Name, + AlgorithmVersion: algorithmVersion.Name, + Dataset: datasetVersion.Dataset.Name, + DatasetVersion: datasetVersion.Name, + TotalCount: len(datasetVersion.Datapacks), + EvaluateRefs: evaluateRefs, + NotExecutedDatapacks: notExecutedDatapacks, + }) + } + + persistEvaluations(s.repo.db, "dataset", successItems, func(item *EvaluateDatasetItem) *model.Evaluation { + return &model.Evaluation{ + AlgorithmName: item.Algorithm, + AlgorithmVersion: item.AlgorithmVersion, + DatasetName: item.Dataset, + DatasetVersion: item.DatasetVersion, + EvalType: consts.EvalTypeDataset, + Status: consts.CommonEnabled, + } + }) + + return &BatchEvaluateDatasetResp{ + SuccessCount: len(successItems), + SuccessItems: successItems, + FailedCount: len(failedItems), + FailedItems: failedItems, + }, nil +} + +func (s *Service) ListEvaluations(_ context.Context, req *ListEvaluationReq) (*dto.ListResp[EvaluationResp], error) { + limit, offset := req.ToGormParams() + evaluations, total, err := s.repo.ListEvaluations(limit, offset) + if err != nil { + return nil, fmt.Errorf("failed to list evaluations: %w", err) + } + + items := make([]EvaluationResp, 0, len(evaluations)) + for _, evaluation := range evaluations { + items = append(items, *NewEvaluationResp(&evaluation)) + } + + return &dto.ListResp[EvaluationResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) GetEvaluation(_ context.Context, id int) (*EvaluationResp, error) { + evaluation, err := s.repo.GetEvaluationByID(id) + if err != nil { + return nil, err + } + return NewEvaluationResp(evaluation), nil +} + +func (s *Service) DeleteEvaluation(_ context.Context, id int) error { + return s.repo.DeleteEvaluation(id) +} + +func (s *Service) listEvaluationExecutionsByDatapack(ctx context.Context, req *execution.EvaluationExecutionsByDatapackReq) ([]execution.EvaluationExecutionItem, error) { + if s.query == nil { + return nil, fmt.Errorf("evaluation execution query source is not configured") + } + return s.query.ListEvaluationExecutionsByDatapack(ctx, req) +} + +func (s *Service) listEvaluationExecutionsByDataset(ctx context.Context, req *execution.EvaluationExecutionsByDatasetReq) ([]execution.EvaluationExecutionItem, error) { + if s.query == nil { + return nil, fmt.Errorf("evaluation execution query source is not configured") + } + return s.query.ListEvaluationExecutionsByDataset(ctx, req) +} + +func persistEvaluations[T any](db *gorm.DB, evalType string, items []T, toEval func(*T) *model.Evaluation) { + if len(items) == 0 { + return + } + + evals := make([]model.Evaluation, 0, len(items)) + for i := range items { + eval := toEval(&items[i]) + resultJSON, err := json.Marshal(&items[i]) + if err != nil { + logrus.Warnf("failed to marshal %s evaluation result: %v", evalType, err) + eval.ResultJSON = "{}" + } else { + eval.ResultJSON = string(resultJSON) + } + evals = append(evals, *eval) + } + + if err := db.Create(&evals).Error; err != nil { + logrus.Warnf("failed to batch persist %d %s evaluations: %v", len(evals), evalType, err) + } +} diff --git a/src/module/evaluation/service_test.go b/src/module/evaluation/service_test.go new file mode 100644 index 00000000..4f261924 --- /dev/null +++ b/src/module/evaluation/service_test.go @@ -0,0 +1,21 @@ +package evaluation + +import ( + "testing" + + execution "aegis/module/execution" +) + +func TestListEvaluationExecutionsRequiresQuerySource(t *testing.T) { + service := &Service{} + + _, err := service.listEvaluationExecutionsByDatapack(t.Context(), &execution.EvaluationExecutionsByDatapackReq{}) + if err == nil { + t.Fatalf("expected datapack query to fail without orchestrator or execution service") + } + + _, err = service.listEvaluationExecutionsByDataset(t.Context(), &execution.EvaluationExecutionsByDatasetReq{}) + if err == nil { + t.Fatalf("expected dataset query to fail without orchestrator or execution service") + } +} diff --git a/src/dto/execution.go b/src/module/execution/api_types.go similarity index 63% rename from src/dto/execution.go rename to src/module/execution/api_types.go index 337143a0..0e6c35e3 100644 --- a/src/dto/execution.go +++ b/src/module/execution/api_types.go @@ -1,24 +1,28 @@ -package dto +package execution import ( - "aegis/config" - "aegis/consts" - "aegis/database" "fmt" "strings" "time" + + "aegis/config" + "aegis/consts" + "aegis/dto" + "aegis/model" + + chaos "github.com/OperationsPAI/chaos-experiment/handler" ) -// ExecutionRef represents execution granularity results for evaluation +// ExecutionRef represents execution granularity results for evaluation. type ExecutionRef struct { - ExecutionID int `json:"execution_id"` // Execution ID - ExecutionDuration float64 `json:"execution_duration"` // Execution duration in seconds - DetectorResults []DetectorResultItem `json:"detector_results"` // Detector results - Predictions []GranularityResultItem `json:"predictions"` // Algorithm predictions - ExecutedAt time.Time `json:"executed_at"` // Execution time + ExecutionID int `json:"execution_id"` + ExecutionDuration float64 `json:"execution_duration"` + DetectorResults []DetectorResultItem `json:"detector_results"` + Predictions []GranularityResultItem `json:"predictions"` + ExecutedAt time.Time `json:"executed_at"` } -func NewExecutionGranularityRef(execution *database.Execution) ExecutionRef { +func NewExecutionGranularityRef(execution *model.Execution) ExecutionRef { ref := &ExecutionRef{ ExecutionID: execution.ID, ExecutionDuration: execution.Duration, @@ -44,10 +48,31 @@ func NewExecutionGranularityRef(execution *database.Execution) ExecutionRef { return *ref } -// BatchDeleteExecutionReq represents the request to batch delete executions +// EvaluationExecutionsByDatapackReq resolves execution results for one algorithm/datapack pair. +type EvaluationExecutionsByDatapackReq struct { + AlgorithmVersionID int `json:"algorithm_version_id"` + DatapackName string `json:"datapack_name"` + FilterLabels []dto.LabelItem `json:"filter_labels,omitempty"` +} + +// EvaluationExecutionsByDatasetReq resolves execution results for one algorithm/dataset pair. +type EvaluationExecutionsByDatasetReq struct { + AlgorithmVersionID int `json:"algorithm_version_id"` + DatasetVersionID int `json:"dataset_version_id"` + FilterLabels []dto.LabelItem `json:"filter_labels,omitempty"` +} + +// EvaluationExecutionItem is the orchestrator-owned execution payload used by evaluation queries. +type EvaluationExecutionItem struct { + Datapack string `json:"datapack"` + Groundtruths []chaos.Groundtruth `json:"groundtruths,omitempty"` + ExecutionRef +} + +// BatchDeleteExecutionReq represents the request to batch delete executions. type BatchDeleteExecutionReq struct { - IDs []int `json:"ids" binding:"omitempty"` // List of injection IDs for deletion - Labels []LabelItem `json:"labels" binding:"omitempty"` // List of label keys to match for deletion + IDs []int `json:"ids" binding:"omitempty"` + Labels []dto.LabelItem `json:"labels" binding:"omitempty"` } func (req *BatchDeleteExecutionReq) Validate() error { @@ -91,12 +116,13 @@ func (req *BatchDeleteExecutionReq) Validate() error { return nil } +// ListExecutionReq represents execution list query parameters. type ListExecutionReq struct { - PaginationReq + dto.PaginationReq State *consts.ExecutionState `form:"state" binding:"omitempty"` Status *consts.StatusType `form:"status" binding:"omitempty"` Labels []string `form:"labels" binding:"omitempty"` - DatapackID *int `form:"datapack_id" binding:"omitempty"` // Filter by datapack ID + DatapackID *int `form:"datapack_id" binding:"omitempty"` } func (req *ListExecutionReq) Validate() error { @@ -106,19 +132,19 @@ func (req *ListExecutionReq) Validate() error { if err := validateExecutionStates(req.State); err != nil { return err } - if err := validateStatusField(req.Status, false); err != nil { + if err := validateExecutionStatus(req.Status); err != nil { return err } - if err := validateLabelsField(req.Labels); err != nil { + if err := validateExecutionLabels(req.Labels); err != nil { return err } return nil } -// ManageExecutionLabelReq Represents the request to manage labels for an execution +// ManageExecutionLabelReq represents the request to manage labels for an execution. type ManageExecutionLabelReq struct { - AddLabels []LabelItem `json:"add_labels"` // List of labels to add - RemoveLabels []string `json:"remove_labels"` // List of label keys to remove + AddLabels []dto.LabelItem `json:"add_labels"` + RemoveLabels []string `json:"remove_labels"` } func (req *ManageExecutionLabelReq) Validate() error { @@ -144,10 +170,11 @@ func (req *ManageExecutionLabelReq) Validate() error { return nil } +// ExecutionSpec represents a single execution request item. type ExecutionSpec struct { - Algorithm ContainerSpec `json:"algorithm" binding:"required"` - Datapack *string `json:"datapack" binding:"omitempty"` - Dataset *DatasetRef `json:"dataset" binding:"omitempty"` + Algorithm dto.ContainerSpec `json:"algorithm" binding:"required"` + Datapack *string `json:"datapack" binding:"omitempty"` + Dataset *dto.DatasetRef `json:"dataset" binding:"omitempty"` } func (spec *ExecutionSpec) Validate() error { @@ -160,11 +187,8 @@ func (spec *ExecutionSpec) Validate() error { if hasDatapack && hasDataset { return fmt.Errorf("cannot specify both datapack and dataset") } - - if hasDatapack { - if *spec.Datapack == "" { - return fmt.Errorf("datapack name cannot be empty") - } + if hasDatapack && *spec.Datapack == "" { + return fmt.Errorf("datapack name cannot be empty") } if hasDataset { @@ -183,31 +207,29 @@ func (spec *ExecutionSpec) Validate() error { return nil } -// SubmitExecutionReq represents the request to submit execution tasks +// SubmitExecutionReq represents the request to submit execution tasks. type SubmitExecutionReq struct { ProjectName string `json:"project_name" binding:"required"` Specs []ExecutionSpec `json:"specs" binding:"required"` - Labels []LabelItem `json:"labels" binding:"omitempty"` + Labels []dto.LabelItem `json:"labels" binding:"omitempty"` } func (req *SubmitExecutionReq) Validate() error { if req.ProjectName == "" { return fmt.Errorf("project_name is required") } - if len(req.Specs) == 0 { return fmt.Errorf("at least one execution spec is required") } - for i, spec := range req.Specs { if err := spec.Validate(); err != nil { return fmt.Errorf("invalid execution spec at index %d: %w", i, err) } } - - return validateLabelItemsFiled(req.Labels) + return validateExecutionLabelItems(req.Labels) } +// ExecutionResp represents execution summary information. type ExecutionResp struct { ID int `json:"id"` Duration float64 `json:"duration"` @@ -222,11 +244,10 @@ type ExecutionResp struct { DatapackName string `json:"datapack_name,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - - Labels []LabelItem `json:"labels,omitempty"` + Labels []dto.LabelItem `json:"labels,omitempty"` } -func NewExecutionResp(execution *database.Execution, labels []database.Label) *ExecutionResp { +func NewExecutionResp(execution *model.Execution, labels []model.Label) *ExecutionResp { resp := &ExecutionResp{ ID: execution.ID, Duration: execution.Duration, @@ -247,17 +268,15 @@ func NewExecutionResp(execution *database.Execution, labels []database.Label) *E } if len(labels) > 0 { - resp.Labels = make([]LabelItem, 0, len(execution.Labels)) - for _, l := range execution.Labels { - resp.Labels = append(resp.Labels, LabelItem{ - Key: l.Key, - Value: l.Value, - }) + resp.Labels = make([]dto.LabelItem, 0, len(labels)) + for _, label := range labels { + resp.Labels = append(resp.Labels, dto.LabelItem{Key: label.Key, Value: label.Value}) } } return resp } +// ExecutionDetailResp represents execution detail information. type ExecutionDetailResp struct { ExecutionResp @@ -265,12 +284,13 @@ type ExecutionDetailResp struct { GranularityResults []GranularityResultItem `json:"granularity_results,omitempty"` } -func NewExecutionDetailResp(execution *database.Execution, labels []database.Label) *ExecutionDetailResp { +func NewExecutionDetailResp(execution *model.Execution, labels []model.Label) *ExecutionDetailResp { return &ExecutionDetailResp{ ExecutionResp: *NewExecutionResp(execution, labels), } } +// SubmitExecutionItem describes a single submitted execution task. type SubmitExecutionItem struct { Index int `json:"index"` TraceID string `json:"trace_id"` @@ -281,19 +301,59 @@ type SubmitExecutionItem struct { DatasetID *int `json:"dataset_id,omitempty"` } -// SubmitExecutionResp represents the response for submitting execution tasks +// SubmitExecutionResp represents the response for submitting execution tasks. type SubmitExecutionResp struct { GroupID string `json:"group_id"` Items []SubmitExecutionItem `json:"items"` } func validateExecutionStates(state *consts.ExecutionState) error { - if state != nil { - if *state < 0 { - return fmt.Errorf("state must be a non-negative integer") + if state == nil { + return nil + } + if *state < 0 { + return fmt.Errorf("state must be a non-negative integer") + } + if _, exists := consts.ValidExecutionStates[*state]; !exists { + return fmt.Errorf("invalid state: %d", *state) + } + return nil +} + +func validateExecutionStatus(statusPtr *consts.StatusType) error { + if statusPtr == nil { + return nil + } + status := *statusPtr + if _, exists := consts.ValidStatuses[status]; !exists { + return fmt.Errorf("invalid status value: %d", status) + } + return nil +} + +func validateExecutionLabels(labels []string) error { + for i, label := range labels { + parts := strings.SplitN(label, ":", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid label format at index %d: %q, expected key:value", i, label) } - if _, exists := consts.ValidExecutionStates[*state]; !exists { - return fmt.Errorf("invalid state: %d", *state) + if strings.TrimSpace(parts[0]) == "" { + return fmt.Errorf("empty label key at index %d", i) + } + if strings.TrimSpace(parts[1]) == "" { + return fmt.Errorf("empty label value at index %d", i) + } + } + return nil +} + +func validateExecutionLabelItems(items []dto.LabelItem) error { + for i, label := range items { + if strings.TrimSpace(label.Key) == "" { + return fmt.Errorf("empty label key at index %d", i) + } + if strings.TrimSpace(label.Value) == "" { + return fmt.Errorf("empty label value at index %d", i) } } return nil diff --git a/src/module/execution/handler.go b/src/module/execution/handler.go new file mode 100644 index 00000000..cb840f9b --- /dev/null +++ b/src/module/execution/handler.go @@ -0,0 +1,352 @@ +package execution + +import ( + "aegis/httpx" + "context" + "net/http" + "strconv" + + "aegis/consts" + "aegis/dto" + "aegis/middleware" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + +// ListProjectExecutions lists all algorithm executions for a project +// +// @Summary List project executions +// @Description Get paginated list of algorithm executions for a specific project +// @Tags Projects +// @ID list_project_executions +// @Produce json +// @Security BearerAuth +// @Param project_id path int true "Project ID" +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Success 200 {object} dto.GenericResponse[dto.ListResp[ExecutionResp]] "Executions retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid project ID or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Project not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/projects/{project_id}/executions [get] +// @x-api-type {"portal":"true"} +func (h *Handler) ListProjectExecutions(c *gin.Context) { + projectID, ok := parseProjectID(c) + if !ok { + return + } + + var req ListExecutionReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.ListProjectExecutions(c.Request.Context(), &req, projectID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.SuccessResponse(c, resp) +} + +// SubmitAlgorithmExecution submits batch algorithm execution for multiple datapacks or datasets +// +// @Summary Submit batch algorithm execution +// @Description Submit multiple algorithm execution tasks in batch. Supports mixing datapack (v1 compatible) and dataset (v2 feature) executions. +// @Tags Executions +// @ID run_algorithm +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param project_id path int true "Project ID" +// @Param request body SubmitExecutionReq true "Algorithm execution request" +// @Success 200 {object} dto.GenericResponse[SubmitExecutionResp] "Algorithm execution submitted successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Project, algorithm, datapack or dataset not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/projects/{project_id}/executions/execute [post] +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) SubmitAlgorithmExecution(c *gin.Context) { + groupID := c.GetString("groupID") + userID, exists := middleware.GetCurrentUserID(c) + if !exists { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + + spanCtx, span, ok := spanFromGin(c) + if !ok { + return + } + + var req SubmitExecutionReq + if err := c.ShouldBindJSON(&req); err != nil { + span.SetStatus(codes.Error, "validation error in SubmitAlgorithmExecution: "+err.Error()) + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + span.SetStatus(codes.Error, "validation error in SubmitAlgorithmExecution: "+err.Error()) + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.SubmitAlgorithmExecution(spanCtx, &req, groupID, userID) + if err != nil { + span.SetStatus(codes.Error, "service error in SubmitAlgorithmExecution: "+err.Error()) + logrus.Errorf("Failed to submit algorithm execution: %v", err) + httpx.HandleServiceError(c, err) + return + } + + span.SetStatus(codes.Ok, "Successfully submitted algorithm execution") + dto.SuccessResponse(c, resp) +} + +// GetExecution handles getting a single execution by ID +// +// @Summary Get execution by ID +// @Description Get detailed information about a specific execution +// @Tags Executions +// @ID get_execution_by_id +// @Produce json +// @Security BearerAuth +// @Param id path int true "Execution ID" +// @Success 200 {object} dto.GenericResponse[ExecutionDetailResp] "Execution retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid execution ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Execution not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/executions/{id} [get] +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) GetExecution(c *gin.Context) { + id, ok := httpx.ParsePositiveID(c, c.Param(consts.URLPathExecutionID), "execution ID") + if !ok { + return + } + resp, err := h.service.GetExecution(c.Request.Context(), id) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// ListExecutionLabels handles listing available execution labels +// +// @Summary List execution labels +// @Description List all available label keys for executions +// @Tags Executions +// @ID list_execution_labels +// @Security BearerAuth +// @Produce json +// @Success 200 {object} dto.GenericResponse[[]dto.LabelItem] "Available label keys" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/executions/labels [get] +// @x-api-type {"portal":"true"} +func (h *Handler) ListAvailableExecutionLabels(c *gin.Context) { + labels, err := h.service.ListAvailableLabels(c.Request.Context()) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, labels) +} + +// ManageExecutionCustomLabels manages execution custom labels (key-value pairs) +// +// @Summary Manage execution custom labels +// @Description Add or remove custom labels (key-value pairs) for an execution +// @Tags Executions +// @ID update_execution_labels +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Execution ID" +// @Param manage body ManageExecutionLabelReq true "Custom label management request" +// @Success 200 {object} dto.GenericResponse[ExecutionResp] "Custom labels managed successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid execution ID or request format/parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Execution not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/executions/{id}/labels [patch] +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) ManageExecutionCustomLabels(c *gin.Context) { + id, ok := httpx.ParsePositiveID(c, c.Param(consts.URLPathExecutionID), "execution ID") + if !ok { + return + } + var req ManageExecutionLabelReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + resp, err := h.service.ManageLabels(c.Request.Context(), &req, id) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// BatchDeleteExecutions handles batch deletion of executions +// +// @Summary Batch delete executions +// @Description Batch delete executions by IDs or labels with cascading deletion of related records +// @Tags Executions +// @ID batch_delete_executions +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body BatchDeleteExecutionReq true "Batch delete request" +// @Success 200 {object} dto.GenericResponse[any] "Executions deleted successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/executions/batch-delete [post] +// @x-api-type {"portal":"true"} +func (h *Handler) BatchDeleteExecutions(c *gin.Context) { + var req BatchDeleteExecutionReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + if httpx.HandleServiceError(c, h.service.BatchDelete(c.Request.Context(), &req)) { + return + } + dto.JSONResponse[any](c, http.StatusNoContent, "Executions deleted successfully", nil) +} + +// UploadDetectorResults uploads detector results +// +// @Summary Upload detector results +// @Description Upload detection results for detector algorithm via API instead of file collection +// @Tags Executions +// @ID upload_detection_results +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param execution_id path int true "Execution ID" +// @Param request body UploadDetectorResultReq true "Detector results" +// @Success 200 {object} dto.GenericResponse[UploadExecutionResultResp] "Results uploaded successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid executionID or invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Execution not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/executions/{execution_id}/detector_results [post] +// @x-api-type {"sdk":"true","runtime":"true"} +func (h *Handler) UploadDetectorResults(c *gin.Context) { + executionID, ok := httpx.ParsePositiveID(c, c.Param(consts.URLPathExecutionID), "execution ID") + if !ok { + return + } + var req UploadDetectorResultReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + resp, err := h.service.UploadDetectorResults(c.Request.Context(), &req, executionID) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// UploadGranularityResults uploads granularity results +// +// @Summary Upload granularity results +// @Description Upload granularity results for regular algorithms via API instead of file collection +// @Tags Executions +// @ID upload_localization_results +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param execution_id path int true "Execution ID" +// @Param request body UploadGranularityResultReq true "Granularity results" +// @Success 200 {object} dto.GenericResponse[UploadExecutionResultResp] "Results uploaded successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid exeuction ID or invalid request form or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Execution not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/executions/{execution_id}/granularity_results [post] +// @x-api-type {"sdk":"true","runtime":"true"} +func (h *Handler) UploadGranularityResults(c *gin.Context) { + executionID, ok := httpx.ParsePositiveID(c, c.Param(consts.URLPathExecutionID), "execution ID") + if !ok { + return + } + var req UploadGranularityResultReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + resp, err := h.service.UploadGranularityResults(c.Request.Context(), &req, executionID) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +func spanFromGin(c *gin.Context) (context.Context, trace.Span, bool) { + ctx, ok := c.Get(middleware.SpanContextKey) + if !ok { + logrus.Error("failed to get span context from gin.Context") + dto.ErrorResponse(c, http.StatusInternalServerError, "Internal server error") + return nil, nil, false + } + + spanCtx := ctx.(context.Context) + return spanCtx, trace.SpanFromContext(spanCtx), true +} + +func parseProjectID(c *gin.Context) (int, bool) { + projectIDStr := c.Param(consts.URLPathProjectID) + projectID, err := strconv.Atoi(projectIDStr) + if err != nil || projectID <= 0 { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") + return 0, false + } + return projectID, true +} diff --git a/src/module/execution/handler_service.go b/src/module/execution/handler_service.go new file mode 100644 index 00000000..282f7bf5 --- /dev/null +++ b/src/module/execution/handler_service.go @@ -0,0 +1,24 @@ +package execution + +import ( + "context" + + "aegis/dto" +) + +// HandlerService captures the execution operations consumed by the HTTP handler. +type HandlerService interface { + ListProjectExecutions(context.Context, *ListExecutionReq, int) (*dto.ListResp[ExecutionResp], error) + SubmitAlgorithmExecution(context.Context, *SubmitExecutionReq, string, int) (*SubmitExecutionResp, error) + ListExecutions(context.Context, *ListExecutionReq) (*dto.ListResp[ExecutionResp], error) + GetExecution(context.Context, int) (*ExecutionDetailResp, error) + ListAvailableLabels(context.Context) ([]dto.LabelItem, error) + ManageLabels(context.Context, *ManageExecutionLabelReq, int) (*ExecutionResp, error) + BatchDelete(context.Context, *BatchDeleteExecutionReq) error + UploadDetectorResults(context.Context, *UploadDetectorResultReq, int) (*UploadExecutionResultResp, error) + UploadGranularityResults(context.Context, *UploadGranularityResultReq, int) (*UploadExecutionResultResp, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/execution/module.go b/src/module/execution/module.go new file mode 100644 index 00000000..673255cc --- /dev/null +++ b/src/module/execution/module.go @@ -0,0 +1,10 @@ +package execution + +import "go.uber.org/fx" + +var Module = fx.Module("execution", + fx.Provide(NewRepository), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/module/execution/repository.go b/src/module/execution/repository.go new file mode 100644 index 00000000..36bc4314 --- /dev/null +++ b/src/module/execution/repository.go @@ -0,0 +1,482 @@ +package execution + +import ( + "aegis/config" + "aegis/consts" + "aegis/dto" + "aegis/model" + "errors" + "fmt" + "strings" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) getProjectByName(name string) (*model.Project, error) { + var project model.Project + if err := r.db.Where("name = ? AND status != ?", name, consts.CommonDeleted).First(&project).Error; err != nil { + return nil, fmt.Errorf("failed to find project with name %s: %w", name, err) + } + return &project, nil +} + +func (r *Repository) listProjectExecutionsView(projectID, limit, offset int) ([]model.Execution, int64, error) { + var ( + executions []model.Execution + total int64 + ) + + baseQuery := r.db.Model(&model.Execution{}). + Joins("JOIN tasks ON tasks.id = executions.task_id"). + Joins("JOIN traces on traces.id = tasks.trace_id"). + Where("traces.project_id = ? AND executions.status != ?", projectID, consts.CommonDeleted) + + if err := baseQuery.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count executions for project %d: %w", projectID, err) + } + if err := baseQuery. + Preload("AlgorithmVersion.Container"). + Preload("Datapack.Benchmark.Container"). + Preload("Datapack.Pedestal.Container"). + Preload("DatasetVersion"). + Limit(limit). + Offset(offset). + Order("executions.updated_at DESC"). + Find(&executions).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list executions for project %d: %w", projectID, err) + } + return r.attachExecutionLabels(executions, total) +} + +func (r *Repository) listExecutionsView(limit, offset int, req *ListExecutionReq) ([]model.Execution, int64, error) { + labelConditions := make([]map[string]string, 0, len(req.Labels)) + for _, item := range req.Labels { + parts := strings.SplitN(item, ":", 2) + condition := map[string]string{"key": parts[0], "value": ""} + if len(parts) > 1 { + condition["value"] = parts[1] + } + labelConditions = append(labelConditions, condition) + } + + var ( + executions []model.Execution + total int64 + ) + + query := r.db.Model(&model.Execution{}). + Preload("AlgorithmVersion.Container"). + Preload("Datapack.Benchmark.Container"). + Preload("Datapack.Pedestal.Container"). + Preload("DatasetVersion"). + Preload("Task.Trace.Project") + if req.State != nil { + query = query.Where("event = ?", *req.State) + } + if req.Status != nil { + query = query.Where("status = ?", *req.Status) + } + for _, condition := range labelConditions { + subQuery := r.db.Table("execution_injection_labels eil"). + Select("eil.execution_id"). + Joins("JOIN labels ON labels.id = eil.label_id"). + Where("labels.label_key = ? AND labels.label_value = ?", condition["key"], condition["value"]) + query = query.Where("executions.id IN (?)", subQuery) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count executions: %w", err) + } + if err := query.Limit(limit).Offset(offset).Order("updated_at DESC").Find(&executions).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list executions: %w", err) + } + return r.attachExecutionLabels(executions, total) +} + +func (r *Repository) getExecutionView(executionID int) (*model.Execution, []model.Label, error) { + var execution model.Execution + if err := r.db. + Preload("AlgorithmVersion.Container"). + Preload("Datapack.Benchmark.Container"). + Preload("Datapack.Pedestal.Container"). + Preload("DatasetVersion"). + Preload("Task.Trace.Project"). + Where("id = ? AND status != ?", executionID, consts.CommonDeleted). + First(&execution).Error; err != nil { + return nil, nil, fmt.Errorf("failed to find execution result with id %d: %w", executionID, err) + } + + var labels []model.Label + if err := r.db.Table("labels"). + Joins("JOIN execution_injection_labels eil ON labels.id = eil.label_id"). + Where("eil.execution_id = ?", execution.ID). + Find(&labels).Error; err != nil { + return nil, nil, fmt.Errorf("failed to get execution labels: %w", err) + } + return &execution, labels, nil +} + +func (r *Repository) listEvaluationExecutionsByDatapack(algorithmVersionID int, datapackName string, filterLabels []dto.LabelItem) ([]model.Execution, error) { + var executions []model.Execution + + query := r.db.Model(&model.Execution{}). + Preload("DetectorResults"). + Preload("GranularityResults"). + Preload("AlgorithmVersion.Container"). + Preload("Datapack.Groundtruths"). + Joins("JOIN fault_injections fi ON executions.datapack_id = fi.id"). + Where( + "executions.algorithm_version_id = ? AND fi.name = ? AND executions.status != ?", + algorithmVersionID, datapackName, consts.CommonDeleted, + ) + + if len(filterLabels) > 0 { + query = query. + Joins("JOIN execution_injection_labels eil ON eil.execution_id = executions.id"). + Joins("JOIN labels l ON l.id = eil.label_id") + + var whereConditions *gorm.DB + for _, label := range filterLabels { + if whereConditions == nil { + whereConditions = r.db.Where("l.label_key = ? AND l.label_value = ?", label.Key, label.Value) + } else { + whereConditions = whereConditions.Or("l.label_key = ? AND l.label_value = ?", label.Key, label.Value) + } + } + + if whereConditions != nil { + query = query.Where(whereConditions) + } + query = query.Group("executions.id").Having("COUNT(executions.id) = ?", len(filterLabels)) + } + + if err := query.Order("executions.updated_at DESC").Find(&executions).Error; err != nil { + return nil, fmt.Errorf("failed to list evaluation executions for algorithm %d and datapack %s: %w", algorithmVersionID, datapackName, err) + } + return executions, nil +} + +func (r *Repository) listEvaluationExecutionsByDataset(algorithmVersionID, datasetVersionID int, filterLabels []dto.LabelItem) ([]model.Execution, error) { + var executions []model.Execution + + query := r.db.Model(&model.Execution{}). + Preload("DetectorResults"). + Preload("GranularityResults"). + Preload("AlgorithmVersion.Container"). + Preload("Datapack.Groundtruths"). + Preload("DatasetVersion"). + Preload("DatasetVersion.Injections"). + Where( + "executions.algorithm_version_id = ? AND executions.dataset_version_id = ? AND executions.status != ?", + algorithmVersionID, datasetVersionID, consts.CommonDeleted, + ) + + if len(filterLabels) > 0 { + query = query. + Joins("JOIN execution_injection_labels eil ON eil.execution_id = executions.id"). + Joins("JOIN labels l ON l.id = eil.label_id") + + var whereConditions *gorm.DB + for _, label := range filterLabels { + if whereConditions == nil { + whereConditions = r.db.Where("l.label_key = ? AND l.label_value = ?", label.Key, label.Value) + } else { + whereConditions = whereConditions.Or("l.label_key = ? AND l.label_value = ?", label.Key, label.Value) + } + } + + if whereConditions != nil { + query = query.Where(whereConditions) + } + query = query.Group("executions.id").Having("COUNT(executions.id) = ?", len(filterLabels)) + } + + if err := query.Order("executions.updated_at DESC").Find(&executions).Error; err != nil { + return nil, fmt.Errorf("failed to list evaluation executions for algorithm %d and dataset version %d: %w", algorithmVersionID, datasetVersionID, err) + } + return executions, nil +} + +func (r *Repository) getExecutionResultView(executionID int) (*model.Execution, []model.Label, []model.DetectorResult, []model.GranularityResult, error) { + execution, labels, err := r.getExecutionView(executionID) + if err != nil { + return nil, nil, nil, nil, err + } + + if execution.AlgorithmVersion.Container.Name == config.GetDetectorName() { + var detectorResults []model.DetectorResult + if err := r.db.Where("execution_id = ?", execution.ID).Find(&detectorResults).Error; err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to get detector results: %w", err) + } + return execution, labels, detectorResults, nil, nil + } + + var granularityResults []model.GranularityResult + if err := r.db.Where("execution_id = ?", execution.ID).Find(&granularityResults).Error; err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to get granularity results: %w", err) + } + return execution, labels, nil, granularityResults, nil +} + +func (r *Repository) listAvailableExecutionLabels() ([]model.Label, error) { + var labels []model.Label + if err := r.db. + Where("status != ?", consts.CommonDeleted). + Order("usage_count DESC, created_at DESC"). + Find(&labels).Error; err != nil { + return nil, fmt.Errorf("failed to list labels: %w", err) + } + + executionLabels := make([]model.Label, 0) + for _, label := range labels { + if label.Category == consts.ExecutionCategory { + executionLabels = append(executionLabels, label) + } + } + return executionLabels, nil +} + +func (r *Repository) listExecutionLabelIDsByKeys(executionID int, keys []string) ([]int, error) { + var labelIDs []int + if err := r.db.Table("labels l"). + Select("l.id"). + Joins("JOIN execution_injection_labels eil ON eil.label_id = l.id"). + Where("eil.execution_id = ? AND l.label_key IN (?)", executionID, keys). + Pluck("l.id", &labelIDs).Error; err != nil { + return nil, fmt.Errorf("failed to find label IDs by key '%s': %w", keys, err) + } + return labelIDs, nil +} + +func (r *Repository) addExecutionLabels(executionID int, labelIDs []int) error { + if len(labelIDs) == 0 { + return nil + } + + executionLabels := make([]model.ExecutionInjectionLabel, 0, len(labelIDs)) + for _, labelID := range labelIDs { + executionLabels = append(executionLabels, model.ExecutionInjectionLabel{ + ExecutionID: executionID, + LabelID: labelID, + }) + } + if err := r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "execution_id"}, {Name: "label_id"}}, + DoNothing: true, + }).Create(&executionLabels).Error; err != nil { + return fmt.Errorf("failed to add execution-label associatons: %w", err) + } + return nil +} + +func (r *Repository) clearExecutionLabels(executionIDs []int, labelIDs []int) error { + if len(executionIDs) == 0 { + return nil + } + + query := r.db.Table("execution_injection_labels").Where("execution_id IN (?)", executionIDs) + if len(labelIDs) > 0 { + query = query.Where("label_id IN (?)", labelIDs) + } + if err := query.Delete(nil).Error; err != nil { + return fmt.Errorf("failed to clear execution labels: %w", err) + } + return nil +} + +func (r *Repository) batchDecreaseLabelUsages(labelIDs []int, decrement int) error { + if len(labelIDs) == 0 { + return nil + } + + expr := gorm.Expr("GREATEST(0, usage_count - ?)", decrement) + if err := r.db.Model(&model.Label{}). + Where("id IN (?)", labelIDs). + Clauses(clause.Returning{}). + UpdateColumn("usage_count", expr).Error; err != nil { + return fmt.Errorf("failed to batch decrease label usages: %w", err) + } + return nil +} + +func (r *Repository) listExecutionIDsByLabelItems(labelItems []dto.LabelItem) ([]int, error) { + labelConditions := make([]map[string]string, 0, len(labelItems)) + for _, item := range labelItems { + labelConditions = append(labelConditions, map[string]string{"key": item.Key, "value": item.Value}) + } + + var executionIDs []int + query := r.db.Model(&model.Execution{}). + Select("DISTINCT executions.id"). + Joins("JOIN execution_injection_labels eil ON eil.execution_id = executions.id"). + Joins("JOIN labels ON labels.id = eil.label_id"). + Where("executions.status != ?", consts.CommonDeleted) + + var whereClauses []string + var whereArgs []any + for _, condition := range labelConditions { + whereClauses = append(whereClauses, "(labels.label_key = ? AND labels.label_value = ?)") + whereArgs = append(whereArgs, condition["key"], condition["value"]) + } + if len(whereClauses) > 0 { + query = query.Where(strings.Join(whereClauses, " OR "), whereArgs...) + } + + if err := query.Pluck("executions.id", &executionIDs).Error; err != nil { + return nil, fmt.Errorf("failed to list execution IDs by labels: %w", err) + } + return executionIDs, nil +} + +func (r *Repository) batchDeleteExecutions(executionIDs []int) error { + if len(executionIDs) == 0 { + return nil + } + if err := r.db.Where("execution_id IN (?)", executionIDs). + Delete(&model.ExecutionInjectionLabel{}).Error; err != nil { + return fmt.Errorf("failed to delete execution labels: %w", err) + } + if err := r.db.Model(&model.Execution{}). + Where("id IN (?) AND status != ?", executionIDs, consts.CommonDeleted). + Update("status", consts.CommonDeleted).Error; err != nil { + return fmt.Errorf("failed to batch delete executions: %w", err) + } + return nil +} + +func (r *Repository) updateExecutionDuration(executionID int, duration float64) error { + var execution model.Execution + if err := r.db. + Preload("AlgorithmVersion.Container"). + Preload("Datapack.Benchmark.Container"). + Preload("Datapack.Pedestal.Container"). + Preload("DatasetVersion"). + Preload("Task.Trace.Project"). + Where("id = ? AND status != ?", executionID, consts.CommonDeleted). + First(&execution).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: execution %d not found", consts.ErrNotFound, executionID) + } + return fmt.Errorf("execution %d not found: %w", executionID, err) + } + + if execution.Status != consts.CommonEnabled { + return fmt.Errorf("must upload results for an active execution %d", executionID) + } + if execution.State == consts.ExecutionSuccess { + return fmt.Errorf("cannot upload results for a successful execution %d", executionID) + } + + result := r.db.Model(&model.Execution{}). + Where("id = ? AND status != ?", executionID, consts.CommonDeleted). + Updates(map[string]any{"duration": duration}) + if err := result.Error; err != nil { + return fmt.Errorf("failed to update execution %d duration: %w", executionID, err) + } + if result.RowsAffected == 0 { + return fmt.Errorf("execution not found or no changes made") + } + return nil +} + +func (r *Repository) loadExecution(executionID int) (*model.Execution, error) { + var execution model.Execution + if err := r.db.Where("id = ? AND status != ?", executionID, consts.CommonDeleted).First(&execution).Error; err != nil { + return nil, fmt.Errorf("failed to find execution %d: %w", executionID, err) + } + return &execution, nil +} + +func (r *Repository) createExecutionRecord(execution *model.Execution) error { + if err := r.db.Create(execution).Error; err != nil { + return fmt.Errorf("failed to create execution: %w", err) + } + return nil +} + +func (r *Repository) updateExecutionFields(executionID int, fields map[string]any) error { + result := r.db.Model(&model.Execution{}). + Where("id = ? AND status != ?", executionID, consts.CommonDeleted). + Updates(fields) + if err := result.Error; err != nil { + return fmt.Errorf("failed to update execution %d: %w", executionID, err) + } + if result.RowsAffected == 0 { + return fmt.Errorf("%w: execution %d not found", consts.ErrNotFound, executionID) + } + return nil +} + +func (r *Repository) saveDetectorResults(results []model.DetectorResult) error { + if len(results) == 0 { + return fmt.Errorf("no detector results to save") + } + if err := r.db.Create(&results).Error; err != nil { + return fmt.Errorf("failed to save detector results: %w", err) + } + return nil +} + +func (r *Repository) saveGranularityResults(results []model.GranularityResult) error { + if len(results) == 0 { + return fmt.Errorf("no granularity results to create") + } + for i := range results { + resultPtr := &results[i] + err := r.db.Omit("active_name").Create(resultPtr).Error + if err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: index %d", consts.ErrAlreadyExists, i) + } + return fmt.Errorf("failed to create record index %d: %w", i, err) + } + } + return nil +} + +func (r *Repository) attachExecutionLabels(executions []model.Execution, total int64) ([]model.Execution, int64, error) { + executionIDs := make([]int, 0, len(executions)) + for _, execution := range executions { + executionIDs = append(executionIDs, execution.ID) + } + + if len(executionIDs) == 0 { + return executions, total, nil + } + + type executionLabelResult struct { + model.Label + executionID int `gorm:"column:execution_id"` + } + + var flatResults []executionLabelResult + if err := r.db.Model(&model.Label{}). + Joins("JOIN execution_injection_labels eil ON eil.label_id = labels.id"). + Where("eil.execution_id IN (?)", executionIDs). + Select("labels.*, eil.execution_id"). + Find(&flatResults).Error; err != nil { + return nil, 0, fmt.Errorf("failed to batch query execution labels: %w", err) + } + + labelsMap := make(map[int][]model.Label, len(executionIDs)) + for _, id := range executionIDs { + labelsMap[id] = []model.Label{} + } + for _, res := range flatResults { + labelsMap[res.executionID] = append(labelsMap[res.executionID], res.Label) + } + + for i := range executions { + executions[i].Labels = labelsMap[executions[i].ID] + } + return executions, total, nil +} diff --git a/src/dto/algorithm_result.go b/src/module/execution/result_types.go similarity index 84% rename from src/dto/algorithm_result.go rename to src/module/execution/result_types.go index 63a9a095..1c7620f0 100644 --- a/src/dto/algorithm_result.go +++ b/src/module/execution/result_types.go @@ -1,12 +1,12 @@ -package dto +package execution import ( - "aegis/database" + "aegis/model" "fmt" "time" ) -// DetectorResultItem Single detector result item +// DetectorResultItem is a single detector result payload item. type DetectorResultItem struct { SpanName string `json:"span_name" binding:"required"` Issues string `json:"issues" binding:"required"` @@ -29,26 +29,23 @@ func (item *DetectorResultItem) Validate() error { if item.Issues == "" { return fmt.Errorf("issues cannot be empty") } - if item.AbnormalSuccRate != nil && (*item.AbnormalSuccRate < 0 || *item.AbnormalSuccRate > 1) { return fmt.Errorf("abnormal_succ_rate must be between 0-1") } if item.NormalSuccRate != nil && (*item.NormalSuccRate < 0 || *item.NormalSuccRate > 1) { return fmt.Errorf("normal_succ_rate must be between 0-1") } - if item.AbnormalAvgDuration != nil && *item.AbnormalAvgDuration < 0 { return fmt.Errorf("abnormal_avg_duration cannot be negative") } if item.NormalAvgDuration != nil && *item.NormalAvgDuration < 0 { return fmt.Errorf("normal_avg_duration cannot be negative") } - return nil } -func (item DetectorResultItem) ConvertToDetectorResult(executionID int) *database.DetectorResult { - return &database.DetectorResult{ +func (item DetectorResultItem) ConvertToDetectorResult(executionID int) *model.DetectorResult { + return &model.DetectorResult{ SpanName: item.SpanName, Issues: item.Issues, AbnormalAvgDuration: item.AbnormalAvgDuration, @@ -65,7 +62,7 @@ func (item DetectorResultItem) ConvertToDetectorResult(executionID int) *databas } } -func NewDetectorResultItem(result *database.DetectorResult) DetectorResultItem { +func NewDetectorResultItem(result *model.DetectorResult) DetectorResultItem { return DetectorResultItem{ SpanName: result.SpanName, Issues: result.Issues, @@ -82,7 +79,7 @@ func NewDetectorResultItem(result *database.DetectorResult) DetectorResultItem { } } -// GranularityResultItem Single granularity result item +// GranularityResultItem is a single localization result payload item. type GranularityResultItem struct { Level string `json:"level" binding:"required"` Result string `json:"result" binding:"required"` @@ -90,7 +87,7 @@ type GranularityResultItem struct { Confidence float64 `json:"confidence" binding:"omitempty"` } -func (item *GranularityResultItem) Valiate() error { +func (item *GranularityResultItem) Validate() error { if item.Level == "" { return fmt.Errorf("level cannot be empty") } @@ -106,8 +103,8 @@ func (item *GranularityResultItem) Valiate() error { return nil } -func (item *GranularityResultItem) ConvertToGranularityResult(executionID int) *database.GranularityResult { - return &database.GranularityResult{ +func (item *GranularityResultItem) ConvertToGranularityResult(executionID int) *model.GranularityResult { + return &model.GranularityResult{ Level: item.Level, Result: item.Result, Rank: item.Rank, @@ -116,7 +113,7 @@ func (item *GranularityResultItem) ConvertToGranularityResult(executionID int) * } } -func NewGranularityResultItem(result *database.GranularityResult) GranularityResultItem { +func NewGranularityResultItem(result *model.GranularityResult) GranularityResultItem { return GranularityResultItem{ Level: result.Level, Result: result.Result, @@ -125,9 +122,9 @@ func NewGranularityResultItem(result *database.GranularityResult) GranularityRes } } -// DetectorResultRequest Detector result upload request +// UploadDetectorResultReq is the detector result upload request body. type UploadDetectorResultReq struct { - Duration float64 `json:"duration" binding:"required"` // Execution duration in seconds + Duration float64 `json:"duration" binding:"required"` Results []DetectorResultItem `json:"results" binding:"required"` } @@ -135,17 +132,14 @@ func (req *UploadDetectorResultReq) Validate() error { if len(req.Results) == 0 { return fmt.Errorf("at least one detection result is required") } - for i, result := range req.Results { if err := result.Validate(); err != nil { return fmt.Errorf("validation failed for result %d: %w", i+1, err) } } - return nil } -// HasAnomalies checks if detector results contain anomalies func (req *UploadDetectorResultReq) HasAnomalies() bool { for _, result := range req.Results { if result.Issues != "{}" && result.Issues != "" { @@ -155,9 +149,9 @@ func (req *UploadDetectorResultReq) HasAnomalies() bool { return false } -// GranularityResultRequest Granularity result upload request +// UploadGranularityResultReq is the granularity result upload request body. type UploadGranularityResultReq struct { - Duration float64 `json:"duration" binding:"required"` // Execution duration in seconds + Duration float64 `json:"duration" binding:"required"` Results []GranularityResultItem `json:"results" binding:"required,dive,required"` } @@ -165,25 +159,22 @@ func (req *UploadGranularityResultReq) Validate() error { if len(req.Results) == 0 { return fmt.Errorf("at least one granularity result is required") } - rankMap := make(map[int]bool) for i, result := range req.Results { - if err := result.Valiate(); err != nil { + if err := result.Validate(); err != nil { return fmt.Errorf("validation failed for result %d: %w", i+1, err) } - if rankMap[result.Rank] { return fmt.Errorf("rank %d appeared repeatedly", result.Rank) } rankMap[result.Rank] = true } - return nil } -// UploadExecutionResultResp Execution result upload response +// UploadExecutionResultResp is the upload response body. type UploadExecutionResultResp struct { ResultCount int `json:"result_count"` UploadedAt time.Time `json:"uploaded_at"` - HasAnomalies bool `json:"has_anomalies,omitempty"` // Only included for detector results + HasAnomalies bool `json:"has_anomalies,omitempty"` } diff --git a/src/module/execution/runtime_types.go b/src/module/execution/runtime_types.go new file mode 100644 index 00000000..cd2392c6 --- /dev/null +++ b/src/module/execution/runtime_types.go @@ -0,0 +1,21 @@ +package execution + +import ( + "aegis/consts" + "aegis/dto" +) + +// RuntimeCreateExecutionReq captures execution writes initiated by runtime-worker-service. +type RuntimeCreateExecutionReq struct { + TaskID string `json:"task_id"` + AlgorithmVersionID int `json:"algorithm_version_id"` + DatapackID int `json:"datapack_id"` + DatasetVersionID *int `json:"dataset_version_id,omitempty"` + Labels []dto.LabelItem `json:"labels,omitempty"` +} + +// RuntimeUpdateExecutionStateReq captures execution state mutations initiated by runtime-worker-service. +type RuntimeUpdateExecutionStateReq struct { + ExecutionID int `json:"execution_id"` + State consts.ExecutionState `json:"state"` +} diff --git a/src/module/execution/service.go b/src/module/execution/service.go new file mode 100644 index 00000000..a0893512 --- /dev/null +++ b/src/module/execution/service.go @@ -0,0 +1,471 @@ +package execution + +import ( + "context" + "errors" + "fmt" + "time" + + "aegis/consts" + "aegis/dto" + redis "aegis/infra/redis" + "aegis/model" + container "aegis/module/container" + injection "aegis/module/injection" + label "aegis/module/label" + "aegis/service/common" + "aegis/utils" + + chaos "github.com/OperationsPAI/chaos-experiment/handler" + "gorm.io/gorm" +) + +type Service struct { + repo *Repository + redis *redis.Gateway +} + +func NewService(repo *Repository, redis *redis.Gateway) *Service { + return &Service{repo: repo, redis: redis} +} + +func (s *Service) ListProjectExecutions(_ context.Context, req *ListExecutionReq, projectID int) (*dto.ListResp[ExecutionResp], error) { + var project model.Project + if err := s.repo.db.Where("id = ?", projectID).First(&project).Error; err != nil { + if errors.Is(err, consts.ErrNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: project id %d not found", consts.ErrNotFound, projectID) + } + return nil, fmt.Errorf("failed to get project: %w", err) + } + + limit, offset := req.ToGormParams() + executions, total, err := s.repo.listProjectExecutionsView(projectID, limit, offset) + if err != nil { + return nil, fmt.Errorf("failed to list executions for project %d: %w", projectID, err) + } + + items := make([]ExecutionResp, 0, len(executions)) + for i := range executions { + items = append(items, *NewExecutionResp(&executions[i], executions[i].Labels)) + } + + return &dto.ListResp[ExecutionResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) SubmitAlgorithmExecution(ctx context.Context, req *SubmitExecutionReq, groupID string, userID int) (*SubmitExecutionResp, error) { + db := s.repo.db + + project, err := s.repo.getProjectByName(req.ProjectName) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: project %s not found", consts.ErrNotFound, req.ProjectName) + } + return nil, fmt.Errorf("failed to get project: %w", err) + } + + refs := make([]*dto.ContainerRef, 0, len(req.Specs)) + for i := range req.Specs { + refs = append(refs, &req.Specs[i].Algorithm.ContainerRef) + } + + algorithmVersionResults, err := container.NewRepository(db).ResolveContainerVersions(refs, consts.ContainerTypeAlgorithm, userID) + if err != nil { + return nil, fmt.Errorf("failed to map container refs to versions: %w", err) + } + if len(algorithmVersionResults) == 0 { + return nil, fmt.Errorf("no valid algorithm versions found for the provided specs") + } + + var allExecutionItems []SubmitExecutionItem + for idx, spec := range req.Specs { + datapacks, datasetID, err := injection.NewRepository(s.repo.db).ResolveDatapacks(spec.Datapack, spec.Dataset, userID, consts.TaskTypeRunAlgorithm) + if err != nil { + return nil, fmt.Errorf("failed to extract datapacks: %w", err) + } + + algorithmVersion, exists := algorithmVersionResults[refs[idx]] + if !exists { + return nil, fmt.Errorf("algorithm version not found for %v", spec.Algorithm) + } + + for _, datapack := range datapacks { + if datapack.StartTime == nil || datapack.EndTime == nil { + return nil, fmt.Errorf("datapack %s does not have valid start_time and end_time", datapack.Name) + } + + algorithmItem := dto.NewContainerVersionItem(&algorithmVersion) + envVars, err := container.NewRepository(db).ListContainerVersionEnvVars(spec.Algorithm.EnvVars, &algorithmVersion) + if err != nil { + return nil, fmt.Errorf("failed to list algorithm env vars: %w", err) + } + algorithmItem.EnvVars = envVars + + payload := map[string]any{ + consts.ExecuteAlgorithm: algorithmItem, + consts.ExecuteDatapack: dto.NewInjectionItem(&datapack), + consts.ExecuteDatasetVersionID: utils.GetIntValue(datasetID, consts.DefaultInvalidID), + consts.ExecuteLabels: req.Labels, + } + + task := &dto.UnifiedTask{ + Type: consts.TaskTypeRunAlgorithm, + Immediate: true, + Payload: payload, + GroupID: groupID, + ProjectID: project.ID, + UserID: userID, + State: consts.TaskPending, + } + task.SetGroupCtx(ctx) + + if err := common.SubmitTaskWithDB(ctx, db, s.redis, task); err != nil { + return nil, fmt.Errorf("failed to submit task: %w", err) + } + + allExecutionItems = append(allExecutionItems, SubmitExecutionItem{ + Index: idx, + TraceID: task.TraceID, + TaskID: task.TaskID, + AlgorithmID: algorithmVersion.ContainerID, + AlgorithmVersionID: algorithmVersion.ID, + DatapackID: &datapack.ID, + }) + } + } + + return &SubmitExecutionResp{ + GroupID: groupID, + Items: allExecutionItems, + }, nil +} + +func (s *Service) ListExecutions(_ context.Context, req *ListExecutionReq) (*dto.ListResp[ExecutionResp], error) { + limit, offset := req.ToGormParams() + executions, total, err := s.repo.listExecutionsView(limit, offset, req) + if err != nil { + return nil, fmt.Errorf("failed to list executions: %w", err) + } + + items := make([]ExecutionResp, 0, len(executions)) + for i := range executions { + items = append(items, *NewExecutionResp(&executions[i], executions[i].Labels)) + } + + return &dto.ListResp[ExecutionResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) GetExecution(_ context.Context, id int) (*ExecutionDetailResp, error) { + execution, labels, detectorResults, granularityResults, err := s.repo.getExecutionResultView(id) + if err != nil { + if errors.Is(err, consts.ErrNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: execution id: %d", consts.ErrNotFound, id) + } + return nil, fmt.Errorf("failed to get execution: %w", err) + } + + resp := NewExecutionDetailResp(execution, labels) + if len(detectorResults) > 0 { + items := make([]DetectorResultItem, 0, len(detectorResults)) + for _, result := range detectorResults { + items = append(items, NewDetectorResultItem(&result)) + } + resp.DetectorResults = items + } + if len(granularityResults) > 0 { + items := make([]GranularityResultItem, 0, len(granularityResults)) + for _, result := range granularityResults { + items = append(items, NewGranularityResultItem(&result)) + } + resp.GranularityResults = items + } + return resp, nil +} + +func (s *Service) ListEvaluationExecutionsByDatapack(_ context.Context, req *EvaluationExecutionsByDatapackReq) ([]EvaluationExecutionItem, error) { + if req == nil { + return nil, fmt.Errorf("evaluation datapack query is nil") + } + + executions, err := s.repo.listEvaluationExecutionsByDatapack(req.AlgorithmVersionID, req.DatapackName, req.FilterLabels) + if err != nil { + return nil, err + } + return buildEvaluationExecutionItems(executions), nil +} + +func (s *Service) ListEvaluationExecutionsByDataset(_ context.Context, req *EvaluationExecutionsByDatasetReq) ([]EvaluationExecutionItem, error) { + if req == nil { + return nil, fmt.Errorf("evaluation dataset query is nil") + } + + executions, err := s.repo.listEvaluationExecutionsByDataset(req.AlgorithmVersionID, req.DatasetVersionID, req.FilterLabels) + if err != nil { + return nil, err + } + return buildEvaluationExecutionItems(executions), nil +} + +func (s *Service) ListAvailableLabels(_ context.Context) ([]dto.LabelItem, error) { + labels, err := s.repo.listAvailableExecutionLabels() + if err != nil { + return nil, err + } + + items := make([]dto.LabelItem, 0, len(labels)) + for _, label := range labels { + items = append(items, dto.LabelItem{Key: label.Key, Value: label.Value}) + } + return items, nil +} + +func (s *Service) ManageLabels(_ context.Context, req *ManageExecutionLabelReq, executionID int) (*ExecutionResp, error) { + if req == nil { + return nil, fmt.Errorf("manage execution labels request is nil") + } + + var managedExecution *model.Execution + var managedLabels []model.Label + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + execution, _, err := repo.getExecutionView(executionID) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return fmt.Errorf("%w: execution id: %d", consts.ErrNotFound, executionID) + } + return fmt.Errorf("failed to get execution: %w", err) + } + + if len(req.AddLabels) > 0 { + labels, err := label.NewRepository(tx).CreateOrUpdateLabelsFromItems(tx, req.AddLabels, consts.ExecutionCategory) + if err != nil { + return fmt.Errorf("failed to create or update labels: %w", err) + } + + labelIDs := make([]int, 0, len(labels)) + for _, label := range labels { + labelIDs = append(labelIDs, label.ID) + } + if err := repo.addExecutionLabels(execution.ID, labelIDs); err != nil { + return fmt.Errorf("failed to add execution labels: %w", err) + } + } + + if len(req.RemoveLabels) > 0 { + labelIDs, err := repo.listExecutionLabelIDsByKeys(execution.ID, req.RemoveLabels) + if err != nil { + return fmt.Errorf("failed to find label ids by keys: %w", err) + } + + if len(labelIDs) > 0 { + if err := repo.clearExecutionLabels([]int{executionID}, labelIDs); err != nil { + return fmt.Errorf("failed to clear execution labels: %w", err) + } + if err := repo.batchDecreaseLabelUsages(labelIDs, 1); err != nil { + return fmt.Errorf("failed to decrease label usage counts: %w", err) + } + } + } + + reloadedExecution, labels, err := repo.getExecutionView(executionID) + if err != nil { + return fmt.Errorf("failed to reload execution labels: %w", err) + } + managedExecution = reloadedExecution + managedLabels = labels + return nil + }) + if err != nil { + return nil, err + } + + return NewExecutionResp(managedExecution, managedLabels), nil +} + +func (s *Service) BatchDelete(_ context.Context, req *BatchDeleteExecutionReq) error { + if len(req.IDs) > 0 { + return s.batchDeleteByIDs(req.IDs) + } + return s.batchDeleteByLabels(req.Labels) +} + +func buildEvaluationExecutionItems(executions []model.Execution) []EvaluationExecutionItem { + items := make([]EvaluationExecutionItem, 0, len(executions)) + for _, execution := range executions { + item := EvaluationExecutionItem{ + Datapack: execution.Datapack.Name, + Groundtruths: collectGroundtruths(execution.Datapack), + ExecutionRef: NewExecutionGranularityRef(&execution), + } + items = append(items, item) + } + return items +} + +func collectGroundtruths(datapack *model.FaultInjection) []chaos.Groundtruth { + if datapack == nil || len(datapack.Groundtruths) == 0 { + return nil + } + + items := make([]chaos.Groundtruth, 0, len(datapack.Groundtruths)) + for _, gt := range datapack.Groundtruths { + items = append(items, *gt.ConvertToChaosGroundtruth()) + } + return items +} + +func (s *Service) UploadDetectorResults(_ context.Context, req *UploadDetectorResultReq, executionID int) (*UploadExecutionResultResp, error) { + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if err := repo.updateExecutionDuration(executionID, req.Duration); err != nil { + return err + } + + results := make([]model.DetectorResult, 0, len(req.Results)) + for _, item := range req.Results { + results = append(results, *item.ConvertToDetectorResult(executionID)) + } + if err := repo.saveDetectorResults(results); err != nil { + return fmt.Errorf("failed to save detector results for execution %d: %w", executionID, err) + } + return nil + }) + if err != nil { + return nil, err + } + + return &UploadExecutionResultResp{ + ResultCount: len(req.Results), + UploadedAt: time.Now(), + HasAnomalies: req.HasAnomalies(), + }, nil +} + +func (s *Service) UploadGranularityResults(_ context.Context, req *UploadGranularityResultReq, executionID int) (*UploadExecutionResultResp, error) { + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if err := repo.updateExecutionDuration(executionID, req.Duration); err != nil { + return err + } + + results := make([]model.GranularityResult, 0, len(req.Results)) + for _, item := range req.Results { + results = append(results, *item.ConvertToGranularityResult(executionID)) + } + if err := repo.saveGranularityResults(results); err != nil { + return fmt.Errorf("failed to save detector results for execution %d: %w", executionID, err) + } + return nil + }) + if err != nil { + return nil, err + } + + return &UploadExecutionResultResp{ + ResultCount: len(req.Results), + UploadedAt: time.Now(), + }, nil +} + +func (s *Service) CreateExecutionRecord(_ context.Context, req *RuntimeCreateExecutionReq) (int, error) { + if req == nil { + return 0, fmt.Errorf("runtime create execution request is nil") + } + if req.TaskID == "" { + return 0, fmt.Errorf("%w: task_id is required", consts.ErrBadRequest) + } + if req.AlgorithmVersionID <= 0 || req.DatapackID <= 0 { + return 0, fmt.Errorf("%w: algorithm_version_id and datapack_id are required", consts.ErrBadRequest) + } + + var createdExecutionID int + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + execution := &model.Execution{ + TaskID: &req.TaskID, + AlgorithmVersionID: req.AlgorithmVersionID, + DatapackID: req.DatapackID, + DatasetVersionID: req.DatasetVersionID, + State: consts.ExecutionInitial, + Status: consts.CommonEnabled, + } + + if err := repo.createExecutionRecord(execution); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: execution already exists for task %s", consts.ErrAlreadyExists, req.TaskID) + } + return err + } + + if len(req.Labels) > 0 { + labels, err := label.NewRepository(tx).CreateOrUpdateLabelsFromItems(tx, req.Labels, consts.ExecutionCategory) + if err != nil { + return fmt.Errorf("failed to create or update labels: %w", err) + } + + labelIDs := make([]int, 0, len(labels)) + for _, label := range labels { + labelIDs = append(labelIDs, label.ID) + } + if err := repo.addExecutionLabels(execution.ID, labelIDs); err != nil { + return fmt.Errorf("failed to add execution labels: %w", err) + } + } + + createdExecutionID = execution.ID + return nil + }) + if err != nil { + return 0, err + } + return createdExecutionID, nil +} + +func (s *Service) UpdateExecutionState(_ context.Context, req *RuntimeUpdateExecutionStateReq) error { + if req == nil { + return fmt.Errorf("runtime update execution state request is nil") + } + if req.ExecutionID <= 0 { + return fmt.Errorf("%w: execution_id is required", consts.ErrBadRequest) + } + + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + execution, err := repo.loadExecution(req.ExecutionID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: execution %d not found", consts.ErrNotFound, req.ExecutionID) + } + return err + } + if execution.State != consts.ExecutionInitial { + return fmt.Errorf("cannot change state of execution %d from %s to %s", req.ExecutionID, consts.GetExecutionStateName(execution.State), consts.GetExecutionStateName(req.State)) + } + return repo.updateExecutionFields(req.ExecutionID, map[string]any{"state": req.State}) + }) +} + +func (s *Service) batchDeleteByIDs(executionIDs []int) error { + if len(executionIDs) == 0 { + return nil + } + return s.repo.db.Transaction(func(tx *gorm.DB) error { + return NewRepository(tx).batchDeleteExecutions(executionIDs) + }) +} + +func (s *Service) batchDeleteByLabels(labelItems []dto.LabelItem) error { + if len(labelItems) == 0 { + return nil + } + executionIDs, err := s.repo.listExecutionIDsByLabelItems(labelItems) + if err != nil { + return fmt.Errorf("failed to list execution ids by labels: %w", err) + } + return s.batchDeleteByIDs(executionIDs) +} diff --git a/src/module/execution/service_test.go b/src/module/execution/service_test.go new file mode 100644 index 00000000..14abf2d0 --- /dev/null +++ b/src/module/execution/service_test.go @@ -0,0 +1,255 @@ +package execution + +import ( + "regexp" + "testing" + "time" + + "aegis/consts" + "aegis/dto" + redis "aegis/infra/redis" + "aegis/testutil" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func newExecutionService(t *testing.T) (*Service, sqlmock.Sqlmock, func()) { + t.Helper() + + addr, cleanupRedis := testutil.StartRedisStub(t) + viper.Set("redis.host", addr) + + sqlDB, mock, err := sqlmock.New() + require.NoError(t, err) + + db, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + require.NoError(t, err) + + return NewService(NewRepository(db), redis.NewGateway(nil)), mock, func() { + cleanupRedis() + _ = sqlDB.Close() + } +} + +func TestServiceListAvailableLabelsSuccess(t *testing.T) { + service, mock, cleanup := newExecutionService(t) + defer cleanup() + + now := time.Now() + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `labels` WHERE status != ? ORDER BY usage_count DESC, created_at DESC")). + WithArgs(consts.CommonDeleted). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "label_key", "label_value", "category", "description", "color", "usage_count", "is_system", "status", "created_at", "updated_at", + }).AddRow(1, "source", "manual", consts.ExecutionCategory, "manual source", "#1890ff", 2, false, consts.CommonEnabled, now, now)) + + labels, err := service.ListAvailableLabels(t.Context()) + + require.NoError(t, err) + require.Len(t, labels, 1) + require.Equal(t, "source", labels[0].Key) + require.Equal(t, "manual", labels[0].Value) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestServiceBatchDeleteEmptyRequestSucceeds(t *testing.T) { + service := NewService(nil, nil) + + err := service.BatchDelete(t.Context(), &BatchDeleteExecutionReq{}) + + require.NoError(t, err) +} + +func TestServiceListExecutionsSuccessWithLabelFilter(t *testing.T) { + service, mock, cleanup := newExecutionService(t) + defer cleanup() + + status := consts.CommonEnabled + req := &ListExecutionReq{ + Status: &status, + Labels: []string{"source:manual"}, + } + + mock.ExpectQuery("SELECT count\\(\\*\\) FROM `executions` WHERE status = \\? AND executions\\.id IN \\(SELECT eil\\.execution_id FROM execution_injection_labels eil JOIN labels ON labels\\.id = eil\\.label_id WHERE labels\\.label_key = \\? AND labels\\.label_value = \\?\\)"). + WithArgs(status, "source", "manual"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + mock.ExpectQuery("SELECT \\* FROM `executions` WHERE status = \\? AND executions\\.id IN \\(SELECT eil\\.execution_id FROM execution_injection_labels eil JOIN labels ON labels\\.id = eil\\.label_id WHERE labels\\.label_key = \\? AND labels\\.label_value = \\?\\) ORDER BY updated_at DESC LIMIT \\?"). + WithArgs(status, "source", "manual", 20). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "duration", "task_id", "algorithm_version_id", "datapack_id", "dataset_version_id", "state", "status", "created_at", "updated_at", + })) + + resp, err := service.ListExecutions(t.Context(), req) + + require.NoError(t, err) + require.Empty(t, resp.Items) + require.Equal(t, int64(0), resp.Pagination.Total) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestServiceUploadDetectorResultsSuccess(t *testing.T) { + service, mock, cleanup := newExecutionService(t) + defer cleanup() + + now := time.Now() + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `executions` WHERE id = ? AND status != ? ORDER BY `executions`.`id` LIMIT ?")). + WithArgs(12, consts.CommonDeleted, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "duration", "task_id", "algorithm_version_id", "datapack_id", "dataset_version_id", "state", "status", "created_at", "updated_at", + }).AddRow(12, 0, nil, 5, 7, nil, consts.ExecutionInitial, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `container_versions` WHERE `container_versions`.`id` = ?")). + WithArgs(5). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "container_id", "registry", "namespace", "repository", "tag", "status", "created_at", "updated_at", + }).AddRow(5, "1.0.0", 8, "docker.io", "", "algo", "latest", consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `containers` WHERE `containers`.`id` = ?")). + WithArgs(8). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "type", "readme", "is_public", "status", "created_at", "updated_at", + }).AddRow(8, "algo", consts.ContainerTypeAlgorithm, "", true, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `fault_injections` WHERE `fault_injections`.`id` = ?")). + WithArgs(7). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "source", "fault_type", "category", "description", "engine_config", "groundtruth_source", "pre_duration", "benchmark_id", "pedestal_id", "task_id", "state", "status", "created_at", "updated_at", + }).AddRow(7, "dp-1", consts.DatapackSourceInjection, 0, "train-ticket", "", "{}", "auto", 0, nil, nil, nil, consts.DatapackInitial, consts.CommonEnabled, now, now)) + mock.ExpectExec(regexp.QuoteMeta("UPDATE `executions` SET `duration`=?,`updated_at`=? WHERE id = ? AND status != ?")). + WithArgs(12.5, sqlmock.AnyArg(), 12, consts.CommonDeleted). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `detector_results`")). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + duration := 12.5 + resp, err := service.UploadDetectorResults(t.Context(), &UploadDetectorResultReq{ + Duration: duration, + Results: []DetectorResultItem{ + {SpanName: "checkout", Issues: `{"latency":true}`}, + }, + }, 12) + + require.NoError(t, err) + require.Equal(t, 1, resp.ResultCount) + require.True(t, resp.HasAnomalies) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestServiceUploadGranularityResultsSuccess(t *testing.T) { + service, mock, cleanup := newExecutionService(t) + defer cleanup() + + now := time.Now() + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `executions` WHERE id = ? AND status != ? ORDER BY `executions`.`id` LIMIT ?")). + WithArgs(15, consts.CommonDeleted, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "duration", "task_id", "algorithm_version_id", "datapack_id", "dataset_version_id", "state", "status", "created_at", "updated_at", + }).AddRow(15, 0, nil, 6, 9, nil, consts.ExecutionInitial, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `container_versions` WHERE `container_versions`.`id` = ?")). + WithArgs(6). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "container_id", "registry", "namespace", "repository", "tag", "status", "created_at", "updated_at", + }).AddRow(6, "1.0.0", 10, "docker.io", "", "algo", "latest", consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `containers` WHERE `containers`.`id` = ?")). + WithArgs(10). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "type", "readme", "is_public", "status", "created_at", "updated_at", + }).AddRow(10, "locator", consts.ContainerTypeAlgorithm, "", true, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `fault_injections` WHERE `fault_injections`.`id` = ?")). + WithArgs(9). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "source", "fault_type", "category", "description", "engine_config", "groundtruth_source", "pre_duration", "benchmark_id", "pedestal_id", "task_id", "state", "status", "created_at", "updated_at", + }).AddRow(9, "dp-2", consts.DatapackSourceInjection, 0, "train-ticket", "", "{}", "auto", 0, nil, nil, nil, consts.DatapackInitial, consts.CommonEnabled, now, now)) + mock.ExpectExec(regexp.QuoteMeta("UPDATE `executions` SET `duration`=?,`updated_at`=? WHERE id = ? AND status != ?")). + WithArgs(8.8, sqlmock.AnyArg(), 15, consts.CommonDeleted). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `granularity_results`")). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + resp, err := service.UploadGranularityResults(t.Context(), &UploadGranularityResultReq{ + Duration: 8.8, + Results: []GranularityResultItem{ + {Level: "service", Result: "checkout", Rank: 1, Confidence: 0.91}, + }, + }, 15) + + require.NoError(t, err) + require.Equal(t, 1, resp.ResultCount) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestServiceSubmitAlgorithmExecutionSuccess(t *testing.T) { + addr, cleanupRedis := testutil.StartRedisStub(t) + defer cleanupRedis() + viper.Set("redis.host", addr) + + service, mock, cleanup := newExecutionService(t) + defer cleanup() + + mock.MatchExpectationsInOrder(false) + + now := time.Now() + start := now.Add(-5 * time.Minute) + end := now.Add(-1 * time.Minute) + datapackName := "dp-1" + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `projects` WHERE name = ? AND status != ? ORDER BY `projects`.`id` LIMIT ?")). + WithArgs("demo-project", consts.CommonDeleted, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "description", "team_id", "is_public", "status", "created_at", "updated_at", + }).AddRow(3, "demo-project", "demo", nil, true, consts.CommonEnabled, now, now)) + mock.ExpectQuery("SELECT .* FROM container_versions cv .*"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "name_major", "name_minor", "name_patch", "github_link", "registry", "namespace", "repository", "tag", "command", "usage_count", "container_id", "user_id", "status", "created_at", "updated_at", + }).AddRow(5, "1.0.0", 1, 0, 0, "", "docker.io", "", "algo", "latest", "", 0, 8, 1, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `containers` WHERE `containers`.`id` = ?")). + WithArgs(8). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "type", "readme", "is_public", "status", "created_at", "updated_at", + }).AddRow(8, "algo", consts.ContainerTypeAlgorithm, "", true, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `fault_injections` WHERE name = ? AND status != ? ORDER BY `fault_injections`.`id` LIMIT ?")). + WithArgs(datapackName, consts.CommonDeleted, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "source", "fault_type", "category", "description", "display_config", "engine_config", "groundtruths", "groundtruth_source", "pre_duration", "start_time", "end_time", "benchmark_id", "pedestal_id", "task_id", "state", "status", "created_at", "updated_at", + }).AddRow(7, datapackName, consts.DatapackSourceInjection, 0, "ts", "", nil, "{}", "[]", "auto", 5, start, end, nil, nil, nil, consts.DatapackDetectorSuccess, consts.CommonEnabled, now, now)) + mock.ExpectQuery("SELECT .* FROM `fault_injection_labels` .*"). + WillReturnRows(sqlmock.NewRows([]string{"fault_injection_id", "label_id"})) + mock.ExpectQuery("SELECT .* FROM `parameter_configs` JOIN container_version_env_vars .*"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "config_key", "type", "category", "value_type", "description", "default_value", "template_string", "required", "overridable", + })) + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `traces`")). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `tasks`")). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + resp, err := service.SubmitAlgorithmExecution(t.Context(), &SubmitExecutionReq{ + ProjectName: "demo-project", + Specs: []ExecutionSpec{ + { + Algorithm: dto.ContainerSpec{ + ContainerRef: dto.ContainerRef{Name: "algo", Version: "1.0.0"}, + }, + Datapack: &datapackName, + }, + }, + }, "group-1", 1) + + require.NoError(t, err) + require.Equal(t, "group-1", resp.GroupID) + require.Len(t, resp.Items, 1) + require.Equal(t, 5, resp.Items[0].AlgorithmVersionID) + require.Equal(t, 7, *resp.Items[0].DatapackID) + require.NotEmpty(t, resp.Items[0].TaskID) + require.NotEmpty(t, resp.Items[0].TraceID) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/src/module/group/api_types.go b/src/module/group/api_types.go new file mode 100644 index 00000000..d04515e6 --- /dev/null +++ b/src/module/group/api_types.go @@ -0,0 +1,119 @@ +package group + +import ( + "fmt" + "strings" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/model" + "aegis/utils" +) + +// GroupStreamEvent represents a lightweight event pushed to group-level Redis stream. +type GroupStreamEvent struct { + TraceID string `json:"trace_id"` + State consts.TraceState `json:"state"` + LastEvent consts.EventType `json:"last_event"` +} + +func (e *GroupStreamEvent) ToRedisStream() map[string]any { + return map[string]any{ + consts.RdbEventTraceID: e.TraceID, + consts.RdbEventTraceState: e.State, + consts.RdbEventTraceLastEvent: e.LastEvent, + } +} + +type GetGroupStreamReq struct { + LastID string `form:"last_id" binding:"omitempty"` +} + +func (req *GetGroupStreamReq) Validate() error { + if req.LastID == "" { + req.LastID = "0" + } + if req.LastID == "0" { + return nil + } + if strings.Count(req.LastID, "-") != 1 { + return fmt.Errorf("invalid last_id format: must be '0' or a valid stream ID (e.g., 1678886400000-0)") + } + return nil +} + +type GetGroupStatsReq struct { + GroupID string `form:"group_id" binding:"required"` +} + +func (req *GetGroupStatsReq) Validate() error { + if !utils.IsValidUUID(req.GroupID) { + return fmt.Errorf("invalid group_id: must be a valid UUID") + } + return nil +} + +type TraceStatsItem struct { + TraceID string `json:"trace_id"` + Type string `json:"type"` + State string `json:"state"` + StartTime time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time,omitempty"` + CurrentEvent string `json:"current_event"` + CurrentTask string `json:"current_task"` + TaskTypeDurations map[string]float64 `json:"task_type_durations,omitempty" swaggertype:"object"` +} + +func NewTraceStats(trace *model.Trace) *TraceStatsItem { + detail := &TraceStatsItem{ + TraceID: trace.ID, + Type: consts.GetTraceTypeName(trace.Type), + State: consts.GetTraceStateName(trace.State), + StartTime: trace.StartTime, + EndTime: trace.EndTime, + CurrentEvent: trace.LastEvent.String(), + } + + if len(trace.Tasks) > 0 { + detail.CurrentTask = trace.Tasks[0].ID + + taskTypeMap := make(map[string][]model.Task) + for _, task := range trace.Tasks { + if task.State == consts.TaskCompleted || task.State == consts.TaskError { + taskTypeName := consts.GetTaskTypeName(task.Type) + taskTypeMap[taskTypeName] = append(taskTypeMap[taskTypeName], task) + } + } + + detail.TaskTypeDurations = make(map[string]float64) + for taskTypeName, tasks := range taskTypeMap { + totalDuration := 0.0 + for _, task := range tasks { + totalDuration += task.UpdatedAt.Sub(task.CreatedAt).Seconds() + } + detail.TaskTypeDurations[taskTypeName] = totalDuration / float64(len(tasks)) + } + } + + return detail +} + +type GroupStats struct { + TotalTraces int `json:"total_traces"` + AvgDuration float64 `json:"avg_duration"` + MinDuration float64 `json:"min_duration"` + MaxDuration float64 `json:"max_duration"` + TraceStateMap map[string][]TraceStatsItem `json:"trace_state_map"` +} + +func NewDefaultGroupStats() *GroupStats { + return &GroupStats{ + TotalTraces: 0, + AvgDuration: 0.0, + MinDuration: 0.0, + MaxDuration: 0.0, + } +} + +type GroupTraceListResp = dto.ListResp[TraceStatsItem] diff --git a/src/handlers/v2/groups.go b/src/module/group/handler.go similarity index 70% rename from src/handlers/v2/groups.go rename to src/module/group/handler.go index 0506fc80..e263888c 100644 --- a/src/handlers/v2/groups.go +++ b/src/module/group/handler.go @@ -1,23 +1,31 @@ -package v2 +package group import ( - "aegis/consts" - "aegis/dto" - "aegis/handlers" - producer "aegis/service/producer" - "aegis/utils" + "aegis/httpx" "context" "errors" "fmt" "net/http" "time" + "aegis/consts" + "aegis/dto" + "aegis/utils" + "github.com/gin-contrib/sse" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" "github.com/sirupsen/logrus" ) +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + // GetGroupStats handles retrieval of group trace statistics // // @Summary Get statistics for a group of traces @@ -26,28 +34,29 @@ import ( // @ID get_group_stats // @Produce json // @Security BearerAuth -// @Param group_id path string true "Group ID (UUID)" -// @Success 200 {object} dto.GenericResponse[dto.GroupStats] "Group trace statistics" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format/parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param group_id path string true "Group ID (UUID)" +// @Success 200 {object} dto.GenericResponse[GroupStats] "Group trace statistics" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format/parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/groups/{group_id}/stats [get] -// @x-api-type {"sdk":"true"} -func GetGroupStats(c *gin.Context) { - var req dto.GetGroupStatsReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) +// @x-api-type {"portal":"true"} +func (h *Handler) GetGroupStats(c *gin.Context) { + groupID := c.Param(consts.URLPathGroupID) + if !utils.IsValidUUID(groupID) { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid group ID") return } + req := GetGroupStatsReq{GroupID: groupID} if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - stats, err := producer.GetGroupStats(&req) - if handlers.HandleServiceError(c, err) { + stats, err := h.service.GetGroupStats(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { return } @@ -71,16 +80,16 @@ func GetGroupStats(c *gin.Context) { // @Failure 401 {object} dto.GenericResponse[any] "Authentication required" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/groups/{group_id}/stream [get] +// @x-api-type {"portal":"true"} // @x-request-type {"stream":"true"} -// @x-api-type {"sdk":"true"} -func GetGroupStream(c *gin.Context) { +func (h *Handler) GetGroupStream(c *gin.Context) { groupID := c.Param(consts.URLPathGroupID) if !utils.IsValidUUID(groupID) { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid group ID") return } - var req dto.GetGroupStreamReq + var req GetGroupStreamReq if err := c.ShouldBindQuery(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -104,16 +113,14 @@ func GetGroupStream(c *gin.Context) { "stream_key": streamKey, }) - processor, err := producer.NewGroupStreamProcessor(groupID) + processor, err := h.service.NewGroupStreamProcessor(ctx, groupID) if err != nil { logEntry.Errorf("Failed to initialize group stream processor: %v", err) dto.ErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to initialize group stream: %v", err)) return } - // Read historical events (traces that already completed before SSE connection) - logEntry.Info("Reading historical group stream events") - historical, err := producer.ReadGroupStreamMessages(ctx, streamKey, req.LastID, 100, 0) + historical, err := h.service.ReadGroupStreamMessages(ctx, streamKey, req.LastID, 100, 0) if err != nil { logEntry.Errorf("Failed to read historical group stream events: %v", err) dto.ErrorResponse(c, http.StatusInternalServerError, "Failed to read group event history") @@ -129,26 +136,20 @@ func GetGroupStream(c *gin.Context) { } if completed { - logEntry.Info("Group completed during historical events, closing stream") return } req.LastID = lastID } - // Switch to real-time monitoring - logEntry.Infof("Switching to real-time group event monitoring from ID: %s", req.LastID) for { select { case <-ctx.Done(): - logEntry.Info("Request context done") return - default: - newMessages, err := producer.ReadGroupStreamMessages(ctx, streamKey, req.LastID, 10, time.Second) + newMessages, err := h.service.ReadGroupStreamMessages(ctx, streamKey, req.LastID, 10, time.Second) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - logEntry.Infof("Context done while reading group stream: %v", err) return } @@ -169,16 +170,14 @@ func GetGroupStream(c *gin.Context) { req.LastID = lastID if completed { - logEntry.Info("All traces in group completed, closing stream") - time.Sleep(1 * time.Second) + time.Sleep(time.Second) return } } } } -// sendGroupSSEEvents processes group stream messages and sends them as SSE events -func sendGroupSSEEvents(c *gin.Context, processor *producer.GroupStreamProcessor, streams []redis.XStream) (string, bool, error) { +func sendGroupSSEEvents(c *gin.Context, processor *GroupStreamProcessor, streams []redis.XStream) (string, bool, error) { if len(streams) == 0 || len(streams[0].Messages) == 0 { return "", false, fmt.Errorf("no messages to process") } diff --git a/src/module/group/handler_service.go b/src/module/group/handler_service.go new file mode 100644 index 00000000..021555dc --- /dev/null +++ b/src/module/group/handler_service.go @@ -0,0 +1,19 @@ +package group + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" +) + +// HandlerService captures group operations consumed by HTTP handlers and gateway adapters. +type HandlerService interface { + GetGroupStats(context.Context, *GetGroupStatsReq) (*GroupStats, error) + NewGroupStreamProcessor(context.Context, string) (*GroupStreamProcessor, error) + ReadGroupStreamMessages(context.Context, string, string, int64, time.Duration) ([]redis.XStream, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/group/module.go b/src/module/group/module.go new file mode 100644 index 00000000..bf806ee3 --- /dev/null +++ b/src/module/group/module.go @@ -0,0 +1,10 @@ +package group + +import "go.uber.org/fx" + +var Module = fx.Module("group", + fx.Provide(NewRepository), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/module/group/repository.go b/src/module/group/repository.go new file mode 100644 index 00000000..a2e9ebc6 --- /dev/null +++ b/src/module/group/repository.go @@ -0,0 +1,38 @@ +package group + +import ( + "aegis/consts" + "aegis/model" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) GetTracesByGroupID(groupID string) ([]model.Trace, error) { + var traces []model.Trace + if err := r.db.Model(&model.Trace{}). + Preload("Tasks"). + Where("group_id = ? AND status != ?", groupID, consts.CommonDeleted). + Order("start_time DESC"). + Find(&traces).Error; err != nil { + return nil, err + } + return traces, nil +} + +func (r *Repository) CountTracesByGroupID(groupID string) (int64, error) { + var count int64 + if err := r.db.Model(&model.Trace{}). + Where("group_id = ? AND status != ?", groupID, consts.CommonDeleted). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} diff --git a/src/module/group/service.go b/src/module/group/service.go new file mode 100644 index 00000000..ae9a9f7a --- /dev/null +++ b/src/module/group/service.go @@ -0,0 +1,136 @@ +package group + +import ( + "context" + "fmt" + "slices" + "strconv" + "time" + + "aegis/consts" + redisinfra "aegis/infra/redis" + + goredis "github.com/redis/go-redis/v9" +) + +type Service struct { + repo *Repository + redis *redisinfra.Gateway +} + +func NewService(repo *Repository, redis *redisinfra.Gateway) *Service { + return &Service{repo: repo, redis: redis} +} + +func (s *Service) GetGroupStats(_ context.Context, req *GetGroupStatsReq) (*GroupStats, error) { + if req == nil { + return nil, fmt.Errorf("request cannot be nil") + } + + traces, err := s.repo.GetTracesByGroupID(req.GroupID) + if err != nil { + return nil, fmt.Errorf("failed to query traces for group %s: %w", req.GroupID, err) + } + if len(traces) == 0 { + return NewDefaultGroupStats(), nil + } + + durations := make([]float64, 0, len(traces)) + totalDuration := 0.0 + for _, trace := range traces { + if trace.EndTime != nil { + duration := trace.EndTime.Sub(trace.StartTime).Seconds() + durations = append(durations, duration) + totalDuration += duration + } + } + + traceStateMap := make(map[string][]TraceStatsItem, 4) + for _, trace := range traces { + stateName := consts.GetTraceStateName(trace.State) + traceStateMap[stateName] = append(traceStateMap[stateName], *NewTraceStats(&trace)) + } + + return &GroupStats{ + TotalTraces: len(traces), + AvgDuration: totalDuration / float64(len(durations)), + MinDuration: slices.Min(durations), + MaxDuration: slices.Max(durations), + TraceStateMap: traceStateMap, + }, nil +} + +func (s *Service) NewGroupStreamProcessor(_ context.Context, groupID string) (*GroupStreamProcessor, error) { + total, err := s.GetGroupTraceCount(groupID) + if err != nil { + return nil, err + } + return NewGroupStreamProcessor(int(total)), nil +} + +func (s *Service) GetGroupTraceCount(groupID string) (int64, error) { + total, err := s.repo.CountTracesByGroupID(groupID) + if err != nil { + return 0, fmt.Errorf("failed to count traces for group %s: %w", groupID, err) + } + if total == 0 { + return 0, fmt.Errorf("the group %s does not exist", groupID) + } + return total, nil +} + +func (s *Service) ReadGroupStreamMessages(ctx context.Context, streamKey, lastID string, count int64, block time.Duration) ([]goredis.XStream, error) { + if lastID == "" { + lastID = "0" + } + + messages, err := s.redis.XRead(ctx, []string{streamKey, lastID}, count, block) + if err != nil { + return nil, fmt.Errorf("failed to read group stream messages: %w", err) + } + return messages, nil +} + +type GroupStreamProcessor struct { + totalTraces int + finishedCount int +} + +func NewGroupStreamProcessor(totalTraces int) *GroupStreamProcessor { + return &GroupStreamProcessor{ + totalTraces: totalTraces, + finishedCount: 0, + } +} + +func (p *GroupStreamProcessor) ProcessGroupMessage(msg goredis.XMessage) (*GroupStreamEvent, error) { + traceID, ok := msg.Values[consts.RdbEventTraceID].(string) + if !ok || traceID == "" { + return nil, fmt.Errorf("missing or invalid %s in group stream message", consts.RdbEventTraceID) + } + + stateStr, ok := msg.Values[consts.RdbEventTraceState].(string) + if !ok { + return nil, fmt.Errorf("missing or invalid %s in group stream message", consts.RdbEventTraceState) + } + stateInt, err := strconv.Atoi(stateStr) + if err != nil { + return nil, fmt.Errorf("invalid trace state value %s in group stream message: %w", stateStr, err) + } + + lastEventStr, ok := msg.Values[consts.RdbEventTraceLastEvent].(string) + if !ok { + return nil, fmt.Errorf("missing or invalid %s in group stream message", consts.RdbEventTraceLastEvent) + } + + p.finishedCount++ + return &GroupStreamEvent{ + TraceID: traceID, + State: consts.TraceState(stateInt), + LastEvent: consts.EventType(lastEventStr), + }, nil +} + +func (p *GroupStreamProcessor) IsCompleted() bool { + return p.totalTraces > 0 && p.finishedCount >= p.totalTraces +} diff --git a/src/module/injection/api_types.go b/src/module/injection/api_types.go new file mode 100644 index 00000000..5d9a8a7d --- /dev/null +++ b/src/module/injection/api_types.go @@ -0,0 +1,1046 @@ +package injection + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "aegis/config" + "aegis/consts" + "aegis/dto" + "aegis/model" + "aegis/utils" + + chaos "github.com/OperationsPAI/chaos-experiment/handler" + "github.com/OperationsPAI/chaos-experiment/pkg/guidedcli" +) + +// BatchDeleteInjectionReq represents the request to batch delete injections +type BatchDeleteInjectionReq struct { + IDs []int `json:"ids,omitempty"` // List of injection IDs for deletion + Labels []dto.LabelItem `json:"labels,omitempty"` // List of label keys to match for deletion +} + +func (req *BatchDeleteInjectionReq) Validate() error { + hasIDs := len(req.IDs) > 0 + hasLabels := len(req.Labels) > 0 + + criteriaCount := 0 + if hasIDs { + criteriaCount++ + } + if hasLabels { + criteriaCount++ + } + + if criteriaCount == 0 { + return fmt.Errorf("must provide one of: ids, labels, or tags") + } + if criteriaCount > 1 { + return fmt.Errorf("can only specify one deletion criteria (ids, labels, or tags)") + } + + if hasIDs { + for i, id := range req.IDs { + if id <= 0 { + return fmt.Errorf("invalid id at index %d: %d", i, id) + } + } + } + + if hasLabels { + for i, label := range req.Labels { + if strings.TrimSpace(label.Key) == "" { + return fmt.Errorf("empty label key at index %d", i) + } + if strings.TrimSpace(label.Value) == "" { + return fmt.Errorf("empty label value at index %d", i) + } + } + } + + return nil +} + +// CloneInjectionReq represents the request to clone an injection +type CloneInjectionReq struct { + Name string `json:"name" binding:"required"` // New name for cloned injection + Labels []dto.LabelItem `json:"labels" binding:"omitempty"` // Optional labels for cloned injection +} + +// InjectionLogsResp represents the response for injection logs +type InjectionLogsResp struct { + InjectionID int `json:"injection_id"` + TaskID string `json:"task_id,omitempty"` + Logs []string `json:"logs"` +} + +// TriggerDatasetBuildItemResponse represents the response for a single injection in batch trigger +type TriggerDatasetBuildItemResponse struct { + TaskID string `json:"task_id"` + TraceID string `json:"trace_id"` + InjectionName string `json:"injection_name"` + Benchmark string `json:"benchmark"` + Namespace string `json:"namespace"` + Message string `json:"message"` +} + +// TriggerDatasetBuildError represents an error during dataset build trigger +type TriggerDatasetBuildError struct { + InjectionName string `json:"injection_name"` + Error string `json:"error"` +} + +// TriggerFailedDatapackRebuildRequest represents the request for triggering rebuild of failed datapacks +type TriggerFailedDatapackRebuildRequest struct { + Namespace string `json:"namespace,omitempty"` // Optional namespace, defaults to "ts" + Days *int `json:"days,omitempty"` // Number of days to look back, defaults to 3 +} + +// TriggerFailedDatapackRebuildResponse represents the response for triggering rebuild of failed datapacks +type TriggerFailedDatapackRebuildResponse struct { + SuccessCount int `json:"success_count"` + SuccessItems []TriggerDatasetBuildItemResponse `json:"success_items"` + FailedCount int `json:"failed_count"` + FailedItems []TriggerDatasetBuildError `json:"failed_items,omitempty"` + TotalFound int `json:"total_found"` // Total number of failed datapacks found + DaysSearched int `json:"days_searched"` // Number of days searched + SearchCutoff string `json:"search_cutoff"` // ISO timestamp of search cutoff + Message string `json:"message"` +} + +// TriggerFailedDatapackRebuildProgressEvent represents a single progress event for SSE +type TriggerFailedDatapackRebuildProgressEvent struct { + Type string `json:"type"` // "start", "progress", "item_success", "item_error", "complete", "error" + Message string `json:"message"` // Human readable message + TotalFound int `json:"total_found"` // Total number of failed datapacks found + CurrentIndex int `json:"current_index"` // Current processing index (0-based) + Progress float64 `json:"progress"` // Progress percentage (0-100) + SuccessCount int `json:"success_count"` // Number of successful triggers so far + FailedCount int `json:"failed_count"` // Number of failed triggers so far + CurrentItem *TriggerDatasetBuildItemResponse `json:"current_item,omitempty"` // Current successful item + CurrentError *TriggerDatasetBuildError `json:"current_error,omitempty"` // Current error item + EstimatedTime *time.Duration `json:"estimated_time,omitempty"` // Estimated remaining time + FinalResponse *TriggerFailedDatapackRebuildResponse `json:"final_response,omitempty"` // Final response (only for "complete" type) +} + +type InjectionFieldMappingResp struct { + StatusMap map[int]string `json:"status" swaggertype:"object"` + FaultTypeMap map[chaos.ChaosType]string `json:"fault_type" swaggertype:"object"` + FaultResourceMap map[string]chaos.ChaosResourceMapping `json:"fault_resource" swaggertype:"object"` +} + +type ListInjectionFilters struct { + FaultType *chaos.ChaosType + Category *chaos.SystemType + Benchmark string + State *consts.DatapackState + Status *consts.StatusType + LabelConditions []map[string]string +} + +// ListInjectionReq represents the request to list injections with various filters +type ListInjectionReq struct { + dto.PaginationReq + Type *chaos.ChaosType `form:"fault_type" binding:"omitempty"` + Category *chaos.SystemType `form:"category" binding:"omitempty"` + Benchmark string `form:"benchmark" binding:"omitempty"` + State *consts.DatapackState `form:"state" binding:"omitempty"` + Status *consts.StatusType `form:"status" binding:"omitempty"` + Labels []string `form:"labels" binding:"omitempty"` +} + +func (req *ListInjectionReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + if err := validateChaosType(req.Type); err != nil { + return err + } + // Only validate category if it's provided (not nil) + if req.Category != nil && !req.Category.IsValid() { + return fmt.Errorf("invalid category: %s", *req.Category) + } + if err := validateDatapackState(req.State); err != nil { + return err + } + if err := validateInjectionStatus(req.Status, false); err != nil { + return err + } + if err := validateInjectionLabels(req.Labels); err != nil { + return err + } + + return nil +} + +func (req *ListInjectionReq) ToFilterOptions() *ListInjectionFilters { + labelConditions := make([]map[string]string, 0, len(req.Labels)) + for _, item := range req.Labels { + parts := strings.SplitN(item, ":", 2) + labelConditions = append(labelConditions, map[string]string{ + "key": parts[0], + "value": parts[1], + }) + } + + return &ListInjectionFilters{ + FaultType: req.Type, + Benchmark: req.Benchmark, + State: req.State, + Status: req.Status, + LabelConditions: labelConditions, + } +} + +// SearchInjectionReq represents the request to search fault injections with advanced filters +type SearchInjectionReq struct { + dto.AdvancedSearchReq[consts.InjectionField] + TaskIDs []string `json:"task_ids" binding:"omitempty"` + Names []string `json:"names" binding:"omitempty"` + NamePattern string `json:"name_pattern" binding:"omitempty"` + FaultTypes []chaos.ChaosType `json:"fault_types" binding:"omitempty"` + Categories []chaos.SystemType `json:"categories" binding:"omitempty"` + States []consts.DatapackState `json:"states" binding:"omitempty"` + Benchmarks []string `json:"benchmarks" binding:"omitempty"` + Labels []dto.LabelItem `json:"labels" binding:"omitempty"` // Custom labels to filter by + StartTime *dto.DateRange `json:"start_time" binding:"omitempty"` + EndTime *dto.DateRange `json:"end_time" binding:"omitempty"` + IncludeLabels bool `json:"include_labels" binding:"omitempty"` // Whether to include labels in the response + IncludeTask bool `json:"include_task" binding:"omitempty"` // Whether to include task details in the response +} + +func (req *SearchInjectionReq) Validate() error { + if err := req.AdvancedSearchReq.Validate(); err != nil { + return err + } + + for i, id := range req.TaskIDs { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("empty task ID at index %d", i) + } + if !utils.IsValidUUID(id) { + return fmt.Errorf("invalid task ID format at index %d: %s", i, id) + } + } + + if len(req.Names) > 0 && req.NamePattern != "" { + return fmt.Errorf("can only specify one of names or name_pattern for filtering") + } + + for i, name := range req.Names { + if strings.TrimSpace(name) == "" { + return fmt.Errorf("empty injection name at index %d", i) + } + } + + if err := validateInjectionLabelItems(req.Labels); err != nil { + return err + } + + if req.StartTime != nil { + if err := req.StartTime.Validate(); err != nil { + return fmt.Errorf("invalid start_time: %w", err) + } + } + if req.EndTime != nil { + if err := req.EndTime.Validate(); err != nil { + return fmt.Errorf("invalid end_time: %w", err) + } + } + + for i, sortField := range req.Sort { + if _, valid := consts.InjectionAllowedFields[sortField.Field]; !valid { + return fmt.Errorf("invalid sort_by field at index %d: %s", i, sortField.Field) + } + } + + for i, field := range req.GroupBy { + if _, valid := consts.InjectionAllowedFields[field]; !valid { + return fmt.Errorf("invalid group_by field at index %d: %s", i, field) + } + } + + return nil +} + +func (req *SearchInjectionReq) ConvertToSearchReq() *dto.SearchReq[consts.InjectionField] { + sr := req.ConvertAdvancedToSearch() + + if len(req.TaskIDs) > 0 { + sr.AddFilter("task_id", dto.OpIn, req.TaskIDs) + } + if len(req.Names) > 0 { + sr.AddFilter("name", dto.OpIn, req.Names) + } + if req.NamePattern != "" { + sr.AddFilter("name", dto.OpLike, req.NamePattern) + } + if len(req.Benchmarks) > 0 { + sr.AddFilter("benchmark", dto.OpIn, req.Benchmarks) + } + + if len(req.FaultTypes) > 0 { + faultTypeValues := make([]string, len(req.FaultTypes)) + for i, ft := range req.FaultTypes { + faultTypeValues[i] = fmt.Sprintf("%d", ft) + } + sr.AddFilter("fault_type", dto.OpIn, faultTypeValues) + } + if len(req.Categories) > 0 { + categoryValues := make([]string, len(req.Categories)) + for i, ct := range req.Categories { + categoryValues[i] = ct.String() + } + sr.AddFilter("category", dto.OpIn, categoryValues) + } + + if len(req.States) > 0 { + stateValues := make([]string, len(req.States)) + for i, st := range req.States { + stateValues[i] = fmt.Sprintf("%d", st) + } + sr.AddFilter("state", dto.OpIn, stateValues) + } + + if req.StartTime != nil { + if req.StartTime.From != nil && req.StartTime.To != nil { + sr.AddFilter("created_at", dto.OpDateBetween, []any{req.StartTime.From, req.StartTime.To}) + } else if req.StartTime.From != nil { + sr.AddFilter("created_at", dto.OpDateAfter, req.StartTime.From) + } else if req.StartTime.To != nil { + sr.AddFilter("created_at", dto.OpDateBefore, req.StartTime.To) + } + } + if req.EndTime != nil { + if req.EndTime.From != nil && req.EndTime.To != nil { + sr.AddFilter("created_at", dto.OpDateBetween, []any{req.EndTime.From, req.EndTime.To}) + } else if req.EndTime.From != nil { + sr.AddFilter("created_at", dto.OpDateAfter, req.EndTime.From) + } else if req.EndTime.To != nil { + sr.AddFilter("created_at", dto.OpDateBefore, req.EndTime.To) + } + } + + if req.IncludeLabels { + sr.AddInclude("Labels") + } + if req.IncludeTask { + sr.AddInclude("Task") + } + + return sr +} + +// FriendlyFaultSpec is a human-readable fault specification format used by CLI tools. +// It is automatically converted to chaos.Node DSL on the server side via FriendlySpecToNode. +type FriendlyFaultSpec struct { + Type string `json:"type"` // Fault type name (e.g., "CPUStress", "MemoryStress") + Namespace string `json:"namespace"` // Namespace prefix (e.g., "exp") + Target string `json:"target"` // Target container/app name or numeric index + Duration string `json:"duration"` // Duration as Go duration string (e.g., "60s", "5m") or integer minutes + Params map[string]any `json:"params,omitempty"` // Additional spec-specific parameters (e.g., cpu_load, cpu_worker) +} + +// SubmitInjectionReq represents a request to submit fault injection tasks with parallel fault support. +// Each element in Specs represents a batch of faults to be injected in parallel within a single experiment. +// Specs accepts BOTH chaos.Node DSL (numeric tree) and FriendlyFaultSpec (human-readable YAML) formats. +// Mixed formats within a single request are supported — each element is auto-detected. +type SubmitInjectionReq struct { + ProjectName string `json:"project_name" binding:"omitempty"` // Project name + Pedestal *dto.ContainerSpec `json:"pedestal" binding:"required"` // Pedestal (workload) configuration + Benchmark *dto.ContainerSpec `json:"benchmark" binding:"required"` // Benchmark (detector) configuration + Interval int `json:"interval" binding:"required,min=1"` // Total experiment interval in minutes + PreDuration int `json:"pre_duration" binding:"required,min=1"` // Normal data collection duration before fault injection + Specs [][]json.RawMessage `json:"specs" binding:"required"` // Fault injection specs - accepts both chaos.Node DSL and FriendlyFaultSpec + Algorithms []dto.ContainerSpec `json:"algorithms" binding:"omitempty"` // RCA algorithms to execute (optional) + Labels []dto.LabelItem `json:"labels" binding:"omitempty"` // Labels to attach to the injection + + // ResolvedSpecs holds the converted [][]chaos.Node after calling ResolveSpecs. + // Not serialized — populated server-side only. + // Mutually exclusive with ResolvedGuidedConfigs; legacy Node/Friendly path only. + ResolvedSpecs [][]chaos.Node `json:"-"` + + // ResolvedGuidedConfigs holds the parsed GuidedConfig specs when the request + // carries chaos-experiment guided configs (detected by top-level chaos_type). + // Mutually exclusive with ResolvedSpecs. + ResolvedGuidedConfigs [][]guidedcli.GuidedConfig `json:"-"` +} + +// ResolveSpecs auto-detects the format of each spec element and routes it: +// 1. Top-level "chaos_type" string → guidedcli.GuidedConfig +// 2. Otherwise "type" string → FriendlyFaultSpec via converter +// 3. Otherwise chaos.Node DSL +// +// A request must be homogeneous in shape: guided configs cannot be mixed with +// legacy Node/Friendly specs. +func (req *SubmitInjectionReq) ResolveSpecs(converter func(*FriendlyFaultSpec) (chaos.Node, error)) error { + // First pass: detect guided vs legacy. + guidedCount := 0 + legacyCount := 0 + for i, batch := range req.Specs { + for j, raw := range batch { + var probe map[string]json.RawMessage + if err := json.Unmarshal(raw, &probe); err != nil { + return fmt.Errorf("specs[%d][%d]: invalid JSON: %w", i, j, err) + } + if _, hasChaosType := probe["chaos_type"]; hasChaosType { + guidedCount++ + } else { + legacyCount++ + } + } + } + if guidedCount > 0 && legacyCount > 0 { + return fmt.Errorf("specs mix guided (chaos_type) and legacy (type/value) entries; please submit them in separate requests") + } + + if guidedCount > 0 { + result := make([][]guidedcli.GuidedConfig, len(req.Specs)) + for i, batch := range req.Specs { + cfgs := make([]guidedcli.GuidedConfig, len(batch)) + for j, raw := range batch { + var cfg guidedcli.GuidedConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + return fmt.Errorf("specs[%d][%d]: failed to parse guided config: %w", i, j, err) + } + cfgs[j] = cfg + } + result[i] = cfgs + } + req.ResolvedGuidedConfigs = result + req.ResolvedSpecs = nil + return nil + } + + // Legacy path. + result := make([][]chaos.Node, len(req.Specs)) + for i, batch := range req.Specs { + nodes := make([]chaos.Node, len(batch)) + for j, raw := range batch { + var probe map[string]json.RawMessage + if err := json.Unmarshal(raw, &probe); err != nil { + return fmt.Errorf("specs[%d][%d]: invalid JSON: %w", i, j, err) + } + + if typeRaw, hasType := probe["type"]; hasType { + var typeStr string + if err := json.Unmarshal(typeRaw, &typeStr); err == nil { + var friendly FriendlyFaultSpec + if err := json.Unmarshal(raw, &friendly); err != nil { + return fmt.Errorf("specs[%d][%d]: failed to parse friendly spec: %w", i, j, err) + } + node, err := converter(&friendly) + if err != nil { + return fmt.Errorf("specs[%d][%d]: failed to convert friendly spec: %w", i, j, err) + } + nodes[j] = node + continue + } + } + + var node chaos.Node + if err := json.Unmarshal(raw, &node); err != nil { + return fmt.Errorf("specs[%d][%d]: failed to parse node spec: %w", i, j, err) + } + nodes[j] = node + } + result[i] = nodes + } + req.ResolvedSpecs = result + req.ResolvedGuidedConfigs = nil + return nil +} + +func (req *SubmitInjectionReq) Validate() error { + if req.Pedestal == nil { + return fmt.Errorf("pedestal must not be nil") + } else { + if err := req.Pedestal.Validate(); err != nil { + return fmt.Errorf("invalid pedestal: %w", err) + } + } + + if req.Benchmark == nil { + return fmt.Errorf("benchmark must not be nil") + } + if req.Interval <= req.PreDuration { + return fmt.Errorf("interval must be greater than pre_duration") + } + if len(req.Specs) == 0 { + return fmt.Errorf("specs must not be empty") + } + + if req.Algorithms != nil { + for idx, algorithm := range req.Algorithms { + if err := algorithm.Validate(); err != nil { + return fmt.Errorf("invalid algorithm at index %d: %w", idx, err) + } + if algorithm.Name == config.GetDetectorName() { + return fmt.Errorf("algorithm name %s is reserved and cannot be used", config.GetDetectorName()) + } + } + } + + if req.Labels == nil { + req.Labels = make([]dto.LabelItem, 0) + } + + return nil +} + +type UpdateGroundtruthReq struct { + Groundtruths []model.Groundtruth `json:"ground_truths" binding:"required"` +} + +func (req *UpdateGroundtruthReq) Validate() error { + if len(req.Groundtruths) == 0 { + return fmt.Errorf("at least one ground truth entry is required") + } + return nil +} + +type InjectionResp struct { + ID int `json:"id"` + Name string `json:"name"` + Source string `json:"source"` + FaultType string `json:"fault_type"` + Category string `json:"category"` + DisplayConfig map[string]any `json:"display_config,omitempty" swaggertype:"object"` + PreDuration int `json:"pre_duration"` + StartTime *time.Time `json:"start_time,omitempty"` + EndTime *time.Time `json:"end_time,omitempty"` + State consts.DatapackState `json:"state" swaggertype:"string"` + Status string `json:"status"` + GroundtruthSource string `json:"groundtruth_source"` + BenchmarkID *int `json:"benchmark_id"` + BenchmarkName string `json:"benchmark_name"` + PedestalID *int `json:"pedestal_id"` + PedestalName string `json:"pedestal_name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Labels []dto.LabelItem `json:"labels,omitempty"` +} + +func NewInjectionResp(injection *model.FaultInjection) *InjectionResp { + resp := &InjectionResp{ + ID: injection.ID, + Name: injection.Name, + Source: string(injection.Source), + Category: injection.Category.String(), + PreDuration: injection.PreDuration, + StartTime: injection.StartTime, + EndTime: injection.EndTime, + State: injection.State, + Status: consts.GetStatusTypeName(injection.Status), + GroundtruthSource: injection.GroundtruthSource, + BenchmarkID: injection.BenchmarkID, + PedestalID: injection.PedestalID, + CreatedAt: injection.CreatedAt, + UpdatedAt: injection.UpdatedAt, + } + + if injection.FaultType == consts.Hybrid { + resp.FaultType = "hybrid" + } else { + resp.FaultType = chaos.ChaosTypeMap[injection.FaultType] + } + + if injection.DisplayConfig != nil { + var displayConfigData map[string]any + _ = json.Unmarshal([]byte(*injection.DisplayConfig), &displayConfigData) + resp.DisplayConfig = displayConfigData + } + + if injection.Benchmark != nil { + if injection.Benchmark.Container != nil { + resp.BenchmarkName = injection.Benchmark.Container.Name + } + } + if injection.Pedestal != nil { + if injection.Pedestal.Container != nil { + resp.PedestalName = injection.Pedestal.Container.Name + } + } + + // Get labels from associated Task instead of directly from injection + if len(injection.Labels) > 0 { + resp.Labels = make([]dto.LabelItem, 0, len(injection.Labels)) + for _, l := range injection.Labels { + resp.Labels = append(resp.Labels, dto.LabelItem{ + Key: l.Key, + Value: l.Value, + IsSystem: l.IsSystem, + }) + } + } + return resp +} + +type InjectionDetailResp struct { + InjectionResp + + TaskID string `json:"task_id"` + TraceID string `json:"trace_id"` + Source string `json:"source"` + + Description string `json:"description,omitempty"` + EngineConfig []map[string]any `json:"engine_config" swaggertype:"array,object"` + Groundtruths []chaos.Groundtruth `json:"ground_truth,omitempty"` + GroundtruthSource string `json:"groundtruth_source"` +} + +func NewInjectionDetailResp(injection *model.FaultInjection) *InjectionDetailResp { + injectionResp := NewInjectionResp(injection) + resp := &InjectionDetailResp{ + InjectionResp: *injectionResp, + Source: string(injection.Source), + Description: injection.Description, + GroundtruthSource: injection.GroundtruthSource, + } + + if injection.Task != nil { + resp.TaskID = injection.Task.ID + if injection.Task.Trace != nil { + resp.TraceID = injection.Task.Trace.ID + } + } + + if injection.EngineConfig != "" { + var engineConfigData []map[string]any + _ = json.Unmarshal([]byte(injection.EngineConfig), &engineConfigData) + resp.EngineConfig = engineConfigData + } + + resp.Groundtruths = make([]chaos.Groundtruth, 0, len(injection.Groundtruths)) + if len(injection.Groundtruths) > 0 { + for _, gt := range injection.Groundtruths { + resp.Groundtruths = append(resp.Groundtruths, *gt.ConvertToChaosGroundtruth()) + } + } + + return resp +} + +// InjectionMetadataResp represents the metadata response for injections +type InjectionMetadataResp struct { + Config *chaos.Node `json:"config"` + FaultTypeMap map[chaos.ChaosType]string `json:"fault_type_map"` + FaultResourceMap map[string]chaos.ChaosResourceMapping `json:"fault_resource_map"` + SystemResource chaos.SystemResource `json:"ns_resources"` + SystemMap map[string]int `json:"system_map"` + FaultTypeReverseMap map[string]int `json:"fault_type_reverse_map"` + FaultFieldDescriptions map[string][]utils.FieldDescription `json:"fault_field_descriptions"` +} + +// SystemDetail represents a named system with its index. +type SystemDetail struct { + Name string `json:"name"` + Index int `json:"index"` +} + +// SystemMappingResp is the response for the system mapping endpoint. +type SystemMappingResp struct { + Systems map[string]int `json:"systems"` + SystemDetails []SystemDetail `json:"system_details"` +} + +// FaultSpecInput represents a human-readable fault specification for translation. +type FaultSpecInput struct { + Type string `json:"type"` + Namespace string `json:"namespace"` + Target string `json:"target"` + Duration string `json:"duration"` + Extra map[string]any `json:"extra,omitempty"` +} + +// TranslateFaultSpecsReq is the request body for the translate endpoint. +type TranslateFaultSpecsReq struct { + Specs [][]FaultSpecInput `json:"specs" binding:"required"` +} + +// TranslateFaultSpecsResp is the response for the translate endpoint. +type TranslateFaultSpecsResp struct { + Nodes [][]chaos.Node `json:"nodes"` + Warnings []string `json:"warnings"` +} + +type SubmitInjectionItem struct { + Index int `json:"index"` // Index of the batch this injection belongs to + TraceID string `json:"trace_id"` + TaskID string `json:"task_id"` +} + +// Structured warnings about duplications and conflicts +type InjectionWarnings struct { + DuplicateServicesInBatch []string `json:"duplicate_services_in_batch,omitempty"` // Warnings about duplicate service injections within the same batch + DuplicateBatchesInRequest []int `json:"duplicate_batches_in_request,omitempty"` // Batch indices that have duplicate configurations within this request + BatchesExistInDatabase []int `json:"batches_exist_in_database,omitempty"` // Batch indices that already exist in database +} + +type SubmitInjectionResp struct { + GroupID string `json:"group_id"` + Items []SubmitInjectionItem `json:"items"` + OriginalCount int `json:"original_count"` + Warnings *InjectionWarnings `json:"warnings,omitempty"` +} + +type SubmitDatapackBuildingReq struct { + ProjectName string `json:"project_name" binding:"omitempty"` + Specs []BuildingSpec `json:"specs" binding:"required"` + Labels []dto.LabelItem `json:"labels" binding:"omitempty"` +} + +func (req *SubmitDatapackBuildingReq) Validate() error { + if len(req.Specs) == 0 { + return fmt.Errorf("at least one datapack spec is required") + } + + for _, spec := range req.Specs { + if err := spec.Validate(); err != nil { + return fmt.Errorf("invalid datapack spec: %w", err) + } + } + + return validateInjectionLabelItems(req.Labels) +} + +// ManageInjectionLabelReq Represents the request to manage labels for an injection +type ManageInjectionLabelReq struct { + AddLabels []dto.LabelItem `json:"add_labels"` // List of labels to add + RemoveLabels []string `json:"remove_labels"` // List of label keys to remove +} + +func (req *ManageInjectionLabelReq) Validate() error { + if len(req.AddLabels) == 0 && len(req.RemoveLabels) == 0 { + return fmt.Errorf("at least one of add_labels or remove_labels must be provided") + } + + if err := validateInjectionLabelItems(req.AddLabels); err != nil { + return err + } + + for i, key := range req.RemoveLabels { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("empty label key at index %d in remove_labels", i) + } + } + + return nil +} + +// InjectionLabelOperation represents label operations for a single injection +type InjectionLabelOperation struct { + InjectionID int `json:"injection_id" binding:"required"` // Injection ID to manage + AddLabels []dto.LabelItem `json:"add_labels,omitempty"` // Labels to add to this injection + RemoveLabels []dto.LabelItem `json:"remove_labels,omitempty"` // Labels to remove from this injection +} + +// BatchManageInjectionLabelReq represents the request to batch manage injection labels +// Each injection can have its own set of label operations +type BatchManageInjectionLabelReq struct { + Items []InjectionLabelOperation `json:"items" binding:"required,min=1,dive"` // List of label operations per injection +} + +func (req *BatchManageInjectionLabelReq) Validate() error { + if len(req.Items) == 0 { + return fmt.Errorf("items list cannot be empty") + } + + seenIDs := make(map[int]struct{}, len(req.Items)) + for i, item := range req.Items { + if _, exists := seenIDs[item.InjectionID]; exists { + return fmt.Errorf("duplicate injection_id at index %d: %d", i, item.InjectionID) + } + seenIDs[item.InjectionID] = struct{}{} + + if item.InjectionID <= 0 { + return fmt.Errorf("invalid injection_id at index %d: %d", i, item.InjectionID) + } + + if len(item.AddLabels) == 0 && len(item.RemoveLabels) == 0 { + return fmt.Errorf("at least one of add_labels or remove_labels must be provided for injection_id %d at index %d", item.InjectionID, i) + } + + if err := validateInjectionLabelItems(item.AddLabels); err != nil { + return fmt.Errorf("invalid add_labels for injection_id %d at index %d: %w", item.InjectionID, i, err) + } + if err := validateInjectionLabelItems(item.RemoveLabels); err != nil { + return fmt.Errorf("invalid remove_labels for injection_id %d at index %d: %w", item.InjectionID, i, err) + } + } + + return nil +} + +// BatchManageInjectionLabelResp represents the response for batch injection label management +type BatchManageInjectionLabelResp struct { + FailedCount int `json:"failed_count"` + FailedItems []string `json:"failed_items"` + SuccessCount int `json:"success_count"` + SuccessItems []InjectionResp `json:"success_items"` +} + +// analysis +type ListInjectionNoIssuesReq struct { + Labels []string `form:"labels" binding:"omitempty"` + TimeRangeQuery +} + +func (req *ListInjectionNoIssuesReq) Validate() error { + if err := validateInjectionLabels(req.Labels); err != nil { + return err + } + return req.TimeRangeQuery.Validate() +} + +type ListInjectionWithIssuesReq struct { + Labels []string `form:"labels" binding:"omitempty"` + TimeRangeQuery +} + +func (req *ListInjectionWithIssuesReq) Validate() error { + if err := validateInjectionLabels(req.Labels); err != nil { + return err + } + return req.TimeRangeQuery.Validate() +} + +type InjectionNoIssuesResp struct { + ID int `json:"datapack_id"` + Name string `json:"datapack_name"` + FaultType string `json:"fault_type"` + Category string `json:"category"` + EngineConfig *chaos.Node `json:"engine_config"` +} + +func NewInjectionNoIssuesResp(entity model.FaultInjectionNoIssues) (*InjectionNoIssuesResp, error) { + var engineConfig *chaos.Node + err := json.Unmarshal([]byte(entity.EngineConfig), engineConfig) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal engine config: %w", err) + } + + return &InjectionNoIssuesResp{ + ID: entity.ID, + Name: entity.Name, + FaultType: chaos.ChaosTypeMap[entity.FaultType], + Category: entity.Category.String(), + EngineConfig: engineConfig, + }, nil +} + +// InjectionWithIssuesResp represents the response for fault injections with issues +type InjectionWithIssuesResp struct { + ID int `json:"datapack_id"` + Name string `json:"datapack_name"` + FaultType string `json:"fault_type"` + Category string `json:"category"` + EngineConfig chaos.Node `json:"engine_config"` + Issues string `json:"issues"` + AbnormalAvgDuration float64 `json:"abnormal_avg_duration"` + NormalAvgDuration float64 `json:"normal_avg_duration"` + AbnormalSuccRate float64 `json:"abnormal_succ_rate"` + NormalSuccRate float64 `json:"normal_succ_rate"` + AbnormalP99 float64 `json:"abnormal_p99"` + NormalP99 float64 `json:"normal_p99"` +} + +func NewInjectionWithIssuesResp(entity model.FaultInjectionWithIssues) (*InjectionWithIssuesResp, error) { + var engineConfig chaos.Node + err := json.Unmarshal([]byte(entity.EngineConfig), &engineConfig) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal engine config: %w", err) + } + return &InjectionWithIssuesResp{ + ID: entity.ID, + Name: entity.Name, + FaultType: chaos.ChaosTypeMap[entity.FaultType], + Category: entity.Category.String(), + EngineConfig: engineConfig, + Issues: entity.Issues, + AbnormalAvgDuration: entity.AbnormalAvgDuration, + NormalAvgDuration: entity.NormalAvgDuration, + AbnormalSuccRate: entity.AbnormalSuccRate, + NormalSuccRate: entity.NormalSuccRate, + AbnormalP99: entity.AbnormalP99, + NormalP99: entity.NormalP99, + }, nil +} + +// datapack +type BuildingSpec struct { + Benchmark dto.ContainerSpec `json:"benchmark" binding:"required"` + Datapack *string `json:"datapack" binding:"omitempty"` + Dataset *dto.DatasetRef `json:"dataset" binding:"omitempty"` + PreDuration *int `json:"pre_duration" binding:"omitempty"` +} + +func (spec *BuildingSpec) Validate() error { + hasDatapack := spec.Datapack != nil + hasDataset := spec.Dataset != nil + + if !hasDatapack && !hasDataset { + return fmt.Errorf("either datapack or dataset must be specified") + } + if hasDatapack && hasDataset { + return fmt.Errorf("cannot specify both datapack and dataset") + } + + if hasDatapack { + if *spec.Datapack == "" { + return fmt.Errorf("datapack name cannot be empty") + } + } + + if hasDataset { + if err := spec.Dataset.Validate(); err != nil { + return fmt.Errorf("invalid dataset: %w", err) + } + } + + if spec.PreDuration != nil && *spec.PreDuration <= 0 { + return fmt.Errorf("pre_duration must be greater than 0") + } + + return nil +} + +type SubmitBuildingItem struct { + Index int `json:"index"` + TraceID string `json:"trace_id"` + TaskID string `json:"task_id"` +} + +// SubmitDatapackResp represents the response for submitting datapack building tasks +type SubmitDatapackBuildingResp struct { + GroupID string `json:"group_id"` + Items []SubmitBuildingItem `json:"items"` +} + +// DatapackFileItem represents a file or directory in the datapack +type DatapackFileItem struct { + Name string `json:"name"` // File or directory name + Path string `json:"path"` // Relative path from datapack root + Size string `json:"size"` // File size in KB/MB format or directory info + ModTime *time.Time `json:"modified_at,omitempty"` // Last modification time (only for files) + Children []DatapackFileItem `json:"children,omitempty"` // Child items (only for directories) +} + +// DatapackFilesResp represents the response for listing datapack files +type DatapackFilesResp struct { + Files []DatapackFileItem `json:"files"` + FileCount int `json:"file_count"` // Number of files (excluding directories) + DirCount int `json:"dir_count"` // Number of directories +} + +// validateChaosType checks if the provided chaos type is valid +func validateChaosType(faultType *chaos.ChaosType) error { + if faultType != nil { + if _, exists := chaos.ChaosTypeMap[*faultType]; !exists { + return fmt.Errorf("invalid fault type: %d", faultType) + } + } + return nil +} + +// validateDatapackState checks if the provided datapack state is valid +func validateDatapackState(state *consts.DatapackState) error { + if state != nil { + if *state < 0 { + return fmt.Errorf("state must be a non-negative integer") + } + if _, exists := consts.ValidDatapackStates[consts.DatapackState(*state)]; !exists { + return fmt.Errorf("invalid state: %d", *state) + } + } + return nil +} + +func validateInjectionStatus(statusPtr *consts.StatusType, isMutation bool) error { + if statusPtr == nil { + return nil + } + status := *statusPtr + if _, exists := consts.ValidStatuses[status]; !exists { + return fmt.Errorf("invalid status value: %d", status) + } + if isMutation && status == consts.CommonDeleted { + return fmt.Errorf("status value cannot be set to deleted (%d) directly through this update/create operation", consts.CommonDeleted) + } + return nil +} + +func validateInjectionLabels(labels []string) error { + for i, label := range labels { + parts := strings.SplitN(label, ":", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid label format at index %d: %q, expected key:value", i, label) + } + if strings.TrimSpace(parts[0]) == "" { + return fmt.Errorf("empty label key at index %d", i) + } + if strings.TrimSpace(parts[1]) == "" { + return fmt.Errorf("empty label value at index %d", i) + } + } + return nil +} + +func validateInjectionLabelItems(items []dto.LabelItem) error { + for i, label := range items { + if strings.TrimSpace(label.Key) == "" { + return fmt.Errorf("empty label key at index %d", i) + } + if strings.TrimSpace(label.Value) == "" { + return fmt.Errorf("empty label value at index %d", i) + } + } + return nil +} + +// UploadDatapackReq represents the request to upload a manual datapack +type UploadDatapackReq struct { + Name string `form:"name" binding:"required"` + Description string `form:"description"` + Category string `form:"category"` + Labels string `form:"labels"` // JSON-encoded []dto.LabelItem + Groundtruths string `form:"ground_truths"` // JSON-encoded []Groundtruth +} + +func (req *UploadDatapackReq) Validate() error { + if strings.TrimSpace(req.Name) == "" { + return fmt.Errorf("name is required") + } + return nil +} + +func (req *UploadDatapackReq) ParseLabels() ([]dto.LabelItem, error) { + if req.Labels == "" { + return nil, nil + } + var labels []dto.LabelItem + if err := json.Unmarshal([]byte(req.Labels), &labels); err != nil { + return nil, fmt.Errorf("invalid labels JSON: %w", err) + } + return labels, nil +} + +func (req *UploadDatapackReq) ParseGroundtruths() ([]model.Groundtruth, error) { + if req.Groundtruths == "" { + return nil, nil + } + var gts []model.Groundtruth + if err := json.Unmarshal([]byte(req.Groundtruths), >s); err != nil { + return nil, fmt.Errorf("invalid ground_truths JSON: %w", err) + } + return gts, nil +} + +// UploadDatapackResp represents the response for uploading a manual datapack +type UploadDatapackResp struct { + ID int `json:"id"` + Name string `json:"name"` +} diff --git a/src/module/injection/archive.go b/src/module/injection/archive.go new file mode 100644 index 00000000..de7c7362 --- /dev/null +++ b/src/module/injection/archive.go @@ -0,0 +1,41 @@ +package injection + +import ( + "archive/zip" + "fmt" + "io/fs" + "path/filepath" + + "aegis/consts" + "aegis/utils" +) + +func packageDatapackDirectoryToZip(zipWriter *zip.Writer, workDir string, excludeRules []utils.ExculdeRule) error { + err := filepath.WalkDir(workDir, func(path string, dir fs.DirEntry, err error) error { + if err != nil || dir.IsDir() { + return err + } + + relPath, _ := filepath.Rel(workDir, path) + fullRelPath := filepath.Join(consts.DownloadFilename, filepath.Base(workDir), relPath) + fileName := filepath.Base(path) + + for _, rule := range excludeRules { + if utils.MatchFile(fileName, rule) { + return nil + } + } + + fileInfo, err := dir.Info() + if err != nil { + return err + } + + return utils.AddToZip(zipWriter, fileInfo, path, filepath.ToSlash(fullRelPath)) + }) + if err != nil { + return fmt.Errorf("failed to package datapack directory %s: %w", filepath.Base(workDir), err) + } + + return nil +} diff --git a/src/module/injection/datapack_store.go b/src/module/injection/datapack_store.go new file mode 100644 index 00000000..a9e35b8d --- /dev/null +++ b/src/module/injection/datapack_store.go @@ -0,0 +1,353 @@ +package injection + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "aegis/config" + "aegis/consts" + "aegis/model" + "aegis/utils" + + "github.com/sirupsen/logrus" +) + +type DatapackStore struct { + basePath string +} + +func NewDatapackStore() *DatapackStore { + return &DatapackStore{basePath: config.GetString("jfs.dataset_path")} +} + +func (s *DatapackStore) RootDir(datapackName string) string { + return filepath.Join(s.basePath, datapackName) +} + +func (s *DatapackStore) Package(zipWriter *zip.Writer, datapackName string, excludeRules []utils.ExculdeRule) error { + workDir := s.RootDir(datapackName) + if !utils.IsAllowedPath(workDir) { + return fmt.Errorf("invalid path access to %s", workDir) + } + return packageDatapackDirectoryToZip(zipWriter, workDir, excludeRules) +} + +func (s *DatapackStore) BuildFileTree(datapackName, baseURL string, datapackID int) (*DatapackFilesResp, error) { + workDir := s.RootDir(datapackName) + if !utils.IsAllowedPath(workDir) { + return nil, fmt.Errorf("invalid path access to %s", workDir) + } + if _, err := os.Stat(workDir); os.IsNotExist(err) { + return nil, fmt.Errorf("datapack directory not found for datapack id %d", datapackID) + } + + resp := &DatapackFilesResp{ + Files: []DatapackFileItem{}, + FileCount: 0, + DirCount: 0, + } + + rootItems, err := buildFileTree(workDir, "", baseURL, datapackID, resp) + if err != nil { + return nil, err + } + resp.Files = rootItems + return resp, nil +} + +func (s *DatapackStore) OpenFile(datapackName, filePath string) (string, string, int64, io.ReadSeekCloser, error) { + fullPath, err := s.resolveFilePath(datapackName, filePath) + if err != nil { + return "", "", 0, nil, err + } + + file, err := os.Open(fullPath) + if err != nil { + return "", "", 0, nil, fmt.Errorf("failed to open file: %w", err) + } + + stat, err := file.Stat() + if err != nil { + _ = file.Close() + return "", "", 0, nil, fmt.Errorf("failed to stat file: %w", err) + } + + fileName := filepath.Base(fullPath) + contentType := "application/octet-stream" + switch filepath.Ext(fileName) { + case ".json": + contentType = "application/json" + case ".yaml", ".yml": + contentType = "application/x-yaml" + case ".txt", ".log": + contentType = "text/plain" + case ".csv": + contentType = "text/csv" + case ".xml": + contentType = "application/xml" + case ".html", ".htm": + contentType = "text/html" + case ".pdf": + contentType = "application/pdf" + case ".zip": + contentType = "application/zip" + case ".tar", ".gz", ".tgz": + contentType = "application/x-tar" + } + + return fileName, contentType, stat.Size(), file, nil +} + +func (s *DatapackStore) ResolveFilePath(datapackName, filePath string) (string, error) { + return s.resolveFilePath(datapackName, filePath) +} + +func (s *DatapackStore) resolveFilePath(datapackName, filePath string) (string, error) { + workDir := s.RootDir(datapackName) + if !utils.IsAllowedPath(workDir) { + return "", fmt.Errorf("invalid path access to %s", workDir) + } + + cleanPath := filepath.Clean(filePath) + fullPath := filepath.Join(workDir, cleanPath) + if !strings.HasPrefix(fullPath, workDir) { + return "", fmt.Errorf("invalid file path: path traversal detected") + } + if !utils.IsAllowedPath(fullPath) { + return "", fmt.Errorf("invalid file path access") + } + + fileInfo, err := os.Stat(fullPath) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("%w: file not found: %s", consts.ErrNotFound, cleanPath) + } + return "", fmt.Errorf("failed to stat file: %w", err) + } + if fileInfo.IsDir() { + return "", fmt.Errorf("path is a directory, not a file: %s", cleanPath) + } + + return fullPath, nil +} + +func buildFileTree(workDir, relPath string, baseURL string, datapackID int, resp *DatapackFilesResp) ([]DatapackFileItem, error) { + _ = baseURL + _ = datapackID + currentPath := filepath.Join(workDir, relPath) + entries, err := os.ReadDir(currentPath) + if err != nil { + return nil, err + } + + var items []DatapackFileItem + for _, entry := range entries { + itemRelPath := filepath.Join(relPath, entry.Name()) + fileInfo, err := entry.Info() + if err != nil { + return nil, err + } + + item := DatapackFileItem{ + Name: entry.Name(), + Path: filepath.ToSlash(itemRelPath), + } + + if entry.IsDir() { + children, err := buildFileTree(workDir, itemRelPath, baseURL, datapackID, resp) + if err != nil { + return nil, err + } + item.Children = children + + subFolderCount := 0 + fileCount := 0 + for _, child := range children { + if len(child.Children) > 0 { + subFolderCount++ + } else { + fileCount++ + } + } + item.Size = fmt.Sprintf("%d subfolders, %d files", subFolderCount, fileCount) + resp.DirCount++ + } else { + fileSize := fileInfo.Size() + item.Size = formatFileSize(fileSize) + modTime := fileInfo.ModTime() + item.ModTime = &modTime + resp.FileCount++ + } + + items = append(items, item) + } + + return items, nil +} + +func formatFileSize(bytes int64) string { + const ( + kb = 1024 + mb = 1024 * 1024 + ) + + if bytes < mb { + return fmt.Sprintf("%.1fKB", float64(bytes)/float64(kb)) + } + return fmt.Sprintf("%.1fMB", float64(bytes)/float64(mb)) +} + +func (s *DatapackStore) CreateUploadTempFile() (*os.File, error) { + return os.CreateTemp("", "datapack-upload-*.zip") +} + +func (s *DatapackStore) ValidateArchive(zipPath string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return fmt.Errorf("failed to open zip archive: %w", err) + } + defer func() { _ = r.Close() }() + + for _, f := range r.File { + name := filepath.Base(f.Name) + if validParquetFiles[name] { + return nil + } + } + + return fmt.Errorf("archive must contain at least one parquet file from: abnormal_traces.parquet, abnormal_metrics.parquet, abnormal_logs.parquet, normal_traces.parquet, normal_metrics.parquet, normal_logs.parquet") +} + +func (s *DatapackStore) EnsureDatapackDirAvailable(datapackName string) (string, error) { + if s.basePath == "" { + return "", fmt.Errorf("dataset path not configured") + } + targetDir := s.RootDir(datapackName) + if _, err := os.Stat(targetDir); err == nil { + return "", fmt.Errorf("%w: directory %s already exists", consts.ErrAlreadyExists, datapackName) + } + return targetDir, nil +} + +func (s *DatapackStore) ExtractArchive(zipPath, targetDir string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return fmt.Errorf("failed to open zip archive: %w", err) + } + defer func() { _ = r.Close() }() + + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return fmt.Errorf("failed to create target directory: %w", err) + } + + for _, f := range r.File { + destPath := filepath.Join(targetDir, f.Name) + if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(targetDir)+string(os.PathSeparator)) && + filepath.Clean(destPath) != filepath.Clean(targetDir) { + return fmt.Errorf("illegal file path in archive: %s", f.Name) + } + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(destPath, 0o755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", f.Name, err) + } + continue + } + + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return fmt.Errorf("failed to create parent directory for %s: %w", f.Name, err) + } + + outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", f.Name, err) + } + + rc, err := f.Open() + if err != nil { + _ = outFile.Close() + return fmt.Errorf("failed to open file in archive %s: %w", f.Name, err) + } + + _, err = io.Copy(outFile, rc) + _ = rc.Close() + _ = outFile.Close() + if err != nil { + return fmt.Errorf("failed to extract file %s: %w", f.Name, err) + } + } + + return nil +} + +func (s *DatapackStore) RemoveAll(path string) error { + return os.RemoveAll(path) +} + +func (s *DatapackStore) Remove(path string) error { + return os.Remove(path) +} + +func (s *DatapackStore) ExtractGroundtruths(dir string) []model.Groundtruth { + jsonPath := filepath.Join(dir, "injection.json") + data, err := os.ReadFile(jsonPath) + if err != nil { + logrus.Debugf("No injection.json found in %s: %v", dir, err) + return nil + } + + var parsed injectionJSONFile + if err := json.Unmarshal(data, &parsed); err != nil { + logrus.Warnf("Failed to parse injection.json in %s: %v", dir, err) + return nil + } + + rawGTs := parsed.Groundtruths + if len(rawGTs) == 0 { + rawGTs = parsed.GroundTruth + } + if len(rawGTs) == 0 { + return nil + } + + result := make([]model.Groundtruth, 0, len(rawGTs)) + for _, gt := range rawGTs { + result = append(result, model.Groundtruth{ + Service: gt.Service, + Pod: gt.Pod, + Container: gt.Container, + Metric: gt.Metric, + Function: gt.Function, + Span: gt.Span, + }) + } + return result +} + +type injectionJSONGroundtruth struct { + Service []string `json:"service,omitempty"` + Pod []string `json:"pod,omitempty"` + Container []string `json:"container,omitempty"` + Metric []string `json:"metric,omitempty"` + Function []string `json:"function,omitempty"` + Span []string `json:"span,omitempty"` +} + +type injectionJSONFile struct { + Groundtruths []injectionJSONGroundtruth `json:"ground_truths"` + GroundTruth []injectionJSONGroundtruth `json:"ground_truth"` +} + +var validParquetFiles = map[string]bool{ + "abnormal_traces.parquet": true, + "abnormal_metrics.parquet": true, + "abnormal_logs.parquet": true, + "normal_traces.parquet": true, + "normal_metrics.parquet": true, + "normal_logs.parquet": true, +} diff --git a/src/module/injection/datapack_store_test.go b/src/module/injection/datapack_store_test.go new file mode 100644 index 00000000..8d98f277 --- /dev/null +++ b/src/module/injection/datapack_store_test.go @@ -0,0 +1,83 @@ +package injection + +import ( + "archive/zip" + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "aegis/utils" + + "github.com/spf13/viper" +) + +func TestDatapackStoreBuildTreeAndOpenFile(t *testing.T) { + tmpDir := t.TempDir() + viper.Set("jfs.dataset_path", tmpDir) + store := &DatapackStore{basePath: tmpDir} + + root := filepath.Join(tmpDir, "dp-one") + if err := os.MkdirAll(filepath.Join(root, "nested"), 0o755); err != nil { + t.Fatalf("mkdir root: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "nested", "data.txt"), []byte("hello"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + resp, err := store.BuildFileTree("dp-one", "", 12) + if err != nil { + t.Fatalf("BuildFileTree failed: %v", err) + } + if resp.FileCount != 1 || resp.DirCount != 1 { + t.Fatalf("unexpected counts: files=%d dirs=%d", resp.FileCount, resp.DirCount) + } + + name, contentType, size, reader, err := store.OpenFile("dp-one", "nested/data.txt") + if err != nil { + t.Fatalf("OpenFile failed: %v", err) + } + defer func() { _ = reader.Close() }() + content, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read file: %v", err) + } + if name != "data.txt" || contentType != "text/plain" || size != int64(len("hello")) || string(content) != "hello" { + t.Fatalf("unexpected file result: %s %s %d %q", name, contentType, size, string(content)) + } +} + +func TestDatapackStorePackageUsesExcludeRules(t *testing.T) { + tmpDir := t.TempDir() + viper.Set("jfs.dataset_path", tmpDir) + store := &DatapackStore{basePath: tmpDir} + + root := filepath.Join(tmpDir, "dp-two") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatalf("mkdir root: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "keep.txt"), []byte("keep"), 0o644); err != nil { + t.Fatalf("write keep: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "drop.log"), []byte("drop"), 0o644); err != nil { + t.Fatalf("write drop: %v", err) + } + + buf := &bytes.Buffer{} + zw := zip.NewWriter(buf) + if err := store.Package(zw, "dp-two", []utils.ExculdeRule{{Pattern: "*.log", IsGlob: true}}); err != nil { + t.Fatalf("Package failed: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("close zip: %v", err) + } + + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil { + t.Fatalf("open zip: %v", err) + } + if len(zr.File) != 1 || filepath.Base(zr.File[0].Name) != "keep.txt" { + t.Fatalf("unexpected zip entries: %+v", zr.File) + } +} diff --git a/src/module/injection/handler.go b/src/module/injection/handler.go new file mode 100644 index 00000000..767664f2 --- /dev/null +++ b/src/module/injection/handler.go @@ -0,0 +1,961 @@ +package injection + +import ( + "aegis/httpx" + "archive/zip" + "context" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + + "aegis/consts" + "aegis/dto" + "aegis/middleware" + "aegis/utils" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + chaos "github.com/OperationsPAI/chaos-experiment/handler" +) + +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + +// ListProjectInjections lists all fault injections for a project +// +// @Summary List project fault injections +// @Description Get paginated list of fault injections for a specific project +// @Tags Projects +// @ID list_project_injections +// @Produce json +// @Security BearerAuth +// @Param project_id path int true "Project ID" +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Success 200 {object} dto.GenericResponse[dto.ListResp[InjectionResp]] "Fault injections retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid project ID or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Project not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/projects/{project_id}/injections [get] +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) ListProjectInjections(c *gin.Context) { + projectID, ok := parseProjectID(c) + if !ok { + return + } + + var req ListInjectionReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.ListProjectInjections(c.Request.Context(), &req, projectID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.SuccessResponse(c, resp) +} + +// SearchProjectInjections searches fault injections within a specific project +// +// @Summary Search project fault injections +// @Description Advanced search for injections within a project with complex filtering +// @Tags Projects +// @ID search_project_injections +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param project_id path int true "Project ID" +// @Param search body SearchInjectionReq true "Search criteria" +// @Success 200 {object} dto.GenericResponse[dto.SearchResp[InjectionDetailResp]] "Search results" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid project ID or request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Project not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/projects/{project_id}/injections/search [post] +// @x-api-type {"portal":"true"} +func (h *Handler) SearchProjectInjections(c *gin.Context) { + projectID, ok := parseProjectID(c) + if !ok { + return + } + + var req SearchInjectionReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.Search(c.Request.Context(), &req, &projectID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.SuccessResponse(c, resp) +} + +// ListProjectFaultInjectionNoIssues lists fault injections without issues for a project +// +// @Summary List project fault injections without issues +// @Description Query fault injection records without issues within a project based on time range +// @Tags Projects +// @ID list_project_injections_no_issues +// @Produce json +// @Security BearerAuth +// @Param project_id path int true "Project ID" +// @Param labels query []string false "Filter by labels" +// @Param lookback query string false "Time range query" +// @Param custom_start_time query string false "Custom start time" +// @Param custom_end_time query string false "Custom end time" +// @Success 200 {object} dto.GenericResponse[[]InjectionNoIssuesResp] "Injections retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Project not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/projects/{project_id}/injections/analysis/no-issues [get] +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) ListProjectFaultInjectionNoIssues(c *gin.Context) { + projectID, ok := parseProjectID(c) + if !ok { + return + } + + h.listFaultInjectionNoIssues(c, &projectID) +} + +// ListProjectFaultInjectionWithIssues lists fault injections with issues for a project +// +// @Summary List project fault injections with issues +// @Description Query fault injection records with issues within a project based on time range +// @Tags Projects +// @ID list_project_injections_with_issues +// @Produce json +// @Security BearerAuth +// @Param project_id path int true "Project ID" +// @Param labels query []string false "Filter by labels" +// @Param lookback query string false "Time range query" +// @Param custom_start_time query string false "Custom start time" +// @Param custom_end_time query string false "Custom end time" +// @Success 200 {object} dto.GenericResponse[[]InjectionWithIssuesResp] "Injections retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Project not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/projects/{project_id}/injections/analysis/with-issues [get] +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) ListProjectFaultInjectionWithIssues(c *gin.Context) { + projectID, ok := parseProjectID(c) + if !ok { + return + } + + h.listFaultInjectionWithIssues(c, &projectID) +} + +// SubmitProjectFaultInjection submits fault injections for a specific project +// +// @Summary Submit project fault injections +// @Description Submit multiple fault injection tasks for a specific project +// @Tags Projects +// @ID submit_project_fault_injection +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param project_id path int true "Project ID" +// @Param body body SubmitInjectionReq true "Fault injection request" +// @Success 200 {object} dto.GenericResponse[SubmitInjectionResp] "Injections submitted successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Project not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/projects/{project_id}/injections/inject [post] +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) SubmitProjectFaultInjection(c *gin.Context) { + projectID, ok := parseProjectID(c) + if !ok { + return + } + + h.submitFaultInjection(c, &projectID) +} + +// SubmitProjectDatapackBuilding submits datapack building tasks for a specific project +// +// @Summary Submit project datapack buildings +// @Description Submit multiple datapack building tasks for a specific project +// @Tags Projects +// @ID submit_project_datapack_building +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param project_id path int true "Project ID" +// @Param body body SubmitDatapackBuildingReq true "Datapack building request" +// @Success 202 {object} dto.GenericResponse[SubmitDatapackBuildingResp] "Datapack buildings submitted successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Project not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/projects/{project_id}/injections/build [post] +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) SubmitProjectDatapackBuilding(c *gin.Context) { + projectID, ok := parseProjectID(c) + if !ok { + return + } + + h.submitDatapackBuilding(c, &projectID) +} + +// GetInjection handles getting a single injection by ID +// +// @Summary Get injection by ID +// @Description Get detailed information about a specific injection +// @Tags Injections +// @ID get_injection_by_id +// @Produce json +// @Security BearerAuth +// @Param id path int true "Injection ID" +// @Success 200 {object} dto.GenericResponse[InjectionDetailResp] "Injection retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid injection ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Injection not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/injections/{id} [get] +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) GetInjection(c *gin.Context) { + id, ok := parsePositiveID(c, consts.URLPathID, "injection ID") + if !ok { + return + } + resp, err := h.service.GetInjection(c.Request.Context(), id) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// GetInjectionMetadata +// +// @Summary Get Injection Metadata +// @Description Get injection-related metadata including configuration, field mappings, and system resources +// @Tags Injections +// @ID get_injection_metadata +// @Produce json +// @Security BearerAuth +// @Param system query chaos.SystemType true "System for config and resources metadata" +// @Success 200 {object} dto.GenericResponse[InjectionMetadataResp] "Successfully returned metadata" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid system" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Resource not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/injections/metadata [get] +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) GetInjectionMetadata(c *gin.Context) { + c.JSON(http.StatusGone, gin.H{"error": "endpoint removed; migrate to /inject with GuidedConfig"}) +} + +// GetSystemMapping returns a mapping of system type names to integer indices. +// +// @Summary Get system type mapping +// @Description Returns all registered system types with their integer indices, sorted alphabetically +// @Tags Injections +// @ID get_system_mapping +// @Produce json +// @Security BearerAuth +// @Success 200 {object} dto.GenericResponse[SystemMappingResp] "System mapping retrieved successfully" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/injections/systems [get] +func (h *Handler) GetSystemMapping(c *gin.Context) { + allSystems := chaos.GetAllSystemTypes() + systemMap := utils.BuildSystemIndexMap(allSystems) + + details := make([]SystemDetail, 0, len(systemMap)) + for name, idx := range systemMap { + details = append(details, SystemDetail{Name: name, Index: idx}) + } + sort.Slice(details, func(i, j int) bool { + return details[i].Index < details[j].Index + }) + + dto.SuccessResponse(c, &SystemMappingResp{ + Systems: systemMap, + SystemDetails: details, + }) +} + +// TranslateFaultSpecs translates human-readable fault specs into chaos.Node trees. +// +// @Summary Translate fault specs to Nodes +// @Description Converts human-readable fault specifications (type names, durations, etc.) into the integer-indexed Node AST used by the injection engine +// @Tags Injections +// @ID translate_fault_specs +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body TranslateFaultSpecsReq true "Fault specs to translate" +// @Success 200 {object} dto.GenericResponse[TranslateFaultSpecsResp] "Translation successful" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/injections/translate [post] +func (h *Handler) TranslateFaultSpecs(c *gin.Context) { + c.JSON(http.StatusGone, gin.H{"error": "endpoint removed; migrate to /inject with GuidedConfig"}) +} + +// ManageInjectionCustomLabels manages injection custom labels (key-value pairs) +// +// @Summary Manage injection custom labels +// @Description Add or remove custom labels (key-value pairs) for an injection +// @Tags Injections +// @ID manage_injection_labels +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Injection ID" +// @Param manage body ManageInjectionLabelReq true "Custom label management request" +// @Success 200 {object} dto.GenericResponse[InjectionResp] "Custom labels managed successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid injection ID or request format/parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Injection not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/injections/{id}/labels [patch] +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) ManageInjectionCustomLabels(c *gin.Context) { + id, ok := parsePositiveID(c, consts.URLPathID, "injection ID") + if !ok { + return + } + var req ManageInjectionLabelReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + resp, err := h.service.ManageLabels(c.Request.Context(), &req, id) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// BatchManageInjectionLabels +// +// @Summary Batch manage injection labels +// @Description Add or remove labels from multiple injections by IDs with success/failure tracking +// @Tags Injections +// @ID batch_manage_injection_labels +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param batch_manage body BatchManageInjectionLabelReq true "Batch manage label request" +// @Success 200 {object} dto.GenericResponse[BatchManageInjectionLabelResp] "Injection labels managed successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/injections/labels/batch [patch] +// @x-api-type {"portal":"true"} +func (h *Handler) BatchManageInjectionLabels(c *gin.Context) { + var req BatchManageInjectionLabelReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + resp, err := h.service.BatchManageLabels(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// BatchDeleteInjections +// +// @Summary Batch delete injections +// @Description Batch delete injections by IDs or labels or tags with cascading deletion of related records +// @Tags Injections +// @ID batch_delete_injections +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param batch_delete body BatchDeleteInjectionReq true "Batch delete request" +// @Success 200 {object} dto.GenericResponse[any] "Injections deleted successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/injections/batch-delete [post] +// @x-api-type {"portal":"true"} +func (h *Handler) BatchDeleteInjections(c *gin.Context) { + var req BatchDeleteInjectionReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + if httpx.HandleServiceError(c, h.service.BatchDelete(c.Request.Context(), &req)) { + return + } + dto.JSONResponse[any](c, http.StatusNoContent, "Injections deleted successfully", nil) +} + +// CloneInjection handles cloning an injection configuration +// +// @Summary Clone injection +// @Description Clone an existing injection configuration for reuse +// @Tags Injections +// @ID clone_injection +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Injection ID" +// @Param body body CloneInjectionReq true "Clone request" +// @Success 201 {object} dto.GenericResponse[InjectionDetailResp] "Injection cloned successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 404 {object} dto.GenericResponse[any] "Injection not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/injections/{id}/clone [post] +// @x-api-type {"portal":"true"} +func (h *Handler) CloneInjection(c *gin.Context) { + id, ok := parsePositiveID(c, consts.URLPathID, "injection ID") + if !ok { + return + } + var req CloneInjectionReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + resp, err := h.service.Clone(c.Request.Context(), id, &req) + if httpx.HandleServiceError(c, err) { + return + } + dto.JSONResponse(c, http.StatusCreated, "Injection cloned successfully", resp) +} + +// DownloadDatapack handles datapack file download +// +// @Summary Download datapack +// @Description Download datapack file by injection ID +// @Tags Injections +// @ID download_datapack +// @Produce application/zip +// @Security BearerAuth +// @Param id path int true "Injection ID" +// @Success 200 {file} binary "Datapack zip file" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid injection ID" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Injection not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/injections/{id}/download [get] +// @x-api-type {"portal":"true"} +func (h *Handler) DownloadDatapack(c *gin.Context) { + id, ok := parsePositiveID(c, consts.URLPathID, "injection ID") + if !ok { + return + } + filename, err := h.service.GetDatapackFilename(c.Request.Context(), id) + if httpx.HandleServiceError(c, err) { + return + } + c.Header("Content-Type", "application/zip") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip", filename)) + zipWriter := zip.NewWriter(c.Writer) + defer func() { _ = zipWriter.Close() }() + if err := h.service.DownloadDatapack(c.Request.Context(), zipWriter, []utils.ExculdeRule{}, id); err != nil { + delete(c.Writer.Header(), "Content-Disposition") + c.Header("Content-Type", "application/json; charset=utf-8") + httpx.HandleServiceError(c, err) + } +} + +// ListDatapackFiles handles getting the file structure of an injection datapack +// +// @Summary List datapack files +// @Description Get the file structure of an injection datapack +// @Tags Injections +// @ID list_datapack_files +// @Produce json +// @Security BearerAuth +// @Param id path int true "Injection ID" +// @Success 200 {object} dto.GenericResponse[DatapackFilesResp] "Files retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid injection ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 404 {object} dto.GenericResponse[any] "Datapack not found or not ready" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/injections/{id}/files [get] +// @x-api-type {"portal":"true"} +func (h *Handler) ListDatapackFiles(c *gin.Context) { + id, ok := parsePositiveID(c, consts.URLPathID, "datapack ID") + if !ok { + return + } + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + baseURL := fmt.Sprintf("%s://%s", scheme, c.Request.Host) + resp, err := h.service.GetDatapackFiles(c.Request.Context(), id, baseURL) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// DownloadDatapackFile handles downloading a specific file from a datapack. +// Supports HTTP Range requests for resumable downloads. +// +// @Summary Download datapack file +// @Description Download a specific file from a datapack. Supports Range requests for resumable download. +// @Tags Injections +// @ID download_datapack_file +// @Produce application/octet-stream +// @Security BearerAuth +// @Param id path int true "Injection ID" +// @Param path query string true "Relative path to the file" +// @Success 200 {file} binary "Complete file content" +// @Success 206 {file} binary "Partial file content (Range request)" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid injection ID or file path" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Datapack or file not found" +// @Failure 416 {object} dto.GenericResponse[any] "Range not satisfiable" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/injections/{id}/files/download [get] +// @x-api-type {"portal":"true"} +func (h *Handler) DownloadDatapackFile(c *gin.Context) { + id, ok := parsePositiveID(c, consts.URLPathID, "datapack ID") + if !ok { + return + } + filePath := c.Query("path") + if filePath == "" { + dto.ErrorResponse(c, http.StatusBadRequest, "File path is required") + return + } + fileName, contentType, fileSize, fileReader, err := h.service.DownloadDatapackFile(c.Request.Context(), id, filePath) + if httpx.HandleServiceError(c, err) { + return + } + defer func() { _ = fileReader.Close() }() + c.Header("Content-Type", contentType) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) + c.Header("Cache-Control", "no-cache, no-store, must-revalidate") + c.Header("Accept-Ranges", "bytes") + rangeHeader := c.GetHeader("Range") + if rangeHeader != "" { + serveRangeRequest(c, fileReader, fileSize, rangeHeader) + return + } + c.Header("Content-Length", strconv.FormatInt(fileSize, 10)) + c.Status(http.StatusOK) + if _, err := io.Copy(c.Writer, fileReader); err != nil { + logrus.WithError(err).Error("failed to stream file content") + } +} + +// QueryDatapackFile handles querying the content of a specific file in the datapack. +// Returns the complete file with Content-Length for download progress tracking. +// +// NOTE: Arrow IPC is a structured stream that must be read sequentially from the +// beginning — Range requests are intentionally NOT supported here. Use +// DownloadDatapackFile for resumable downloads of raw files. +// +// @Summary Query datapack file content +// @Description Query the content of a parquet file in the datapack, returned as a complete stream. Content-Length header is provided for progress tracking. +// @Tags Injections +// @ID query_datapack_file +// @Produce application/vnd.apache.arrow.stream +// @Security BearerAuth +// @Param id path int true "Injection ID" +// @Param path query string true "Relative path to the file" +// @Success 200 {file} binary "Complete Arrow IPC stream" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid injection ID or file path" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Datapack or file not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/injections/{id}/files/query [get] +// @x-api-type {"portal":"true"} +func (h *Handler) QueryDatapackFile(c *gin.Context) { + id, ok := parsePositiveID(c, consts.URLPathID, "datapack ID") + if !ok { + return + } + filePath := c.Query("path") + if filePath == "" { + dto.ErrorResponse(c, http.StatusBadRequest, "File path is required") + return + } + fileName, totalRows, reader, err := h.service.QueryDatapackFile(c.Request.Context(), id, filePath) + if err != nil && httpx.HandleServiceError(c, err) { + return + } + defer func() { _ = reader.Close() }() + c.Header("Content-Type", "application/vnd.apache.arrow.stream") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s.arrow", fileName)) + c.Header("Cache-Control", "no-cache, no-store, must-revalidate") + c.Header("X-Total-Rows", strconv.FormatInt(totalRows, 10)) + c.Header("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + if _, err := io.Copy(c.Writer, reader); err != nil { + logrus.Errorf("failed to stream file content: %v", err) + } +} + +// UpdateGroundtruth handles updating ground truth for a datapack +// +// @Summary Update datapack ground truth +// @Description Update or set ground truth labels for a datapack (fault injection) +// @Tags Injections +// @ID update_groundtruth +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Injection ID" +// @Param request body UpdateGroundtruthReq true "Ground truth data" +// @Success 200 {object} dto.GenericResponse[any] "Ground truth updated" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 404 {object} dto.GenericResponse[any] "Injection not found" +// @Router /api/v2/injections/{id}/groundtruth [put] +// @x-api-type {"portal":"true"} +func (h *Handler) UpdateGroundtruth(c *gin.Context) { + id, ok := parsePositiveID(c, consts.URLPathID, "injection ID") + if !ok { + return + } + var req UpdateGroundtruthReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + if httpx.HandleServiceError(c, h.service.UpdateGroundtruth(c.Request.Context(), id, &req)) { + return + } + dto.JSONResponse[any](c, http.StatusOK, "Groundtruth updated successfully", nil) +} + +// UploadDatapack handles manual datapack upload +// +// @Summary Upload a manual datapack +// @Description Upload a zip archive as a manual datapack data source +// @Tags Injections +// @ID upload_datapack +// @Accept multipart/form-data +// @Produce json +// @Security BearerAuth +// @Param name formData string true "Datapack name" +// @Param description formData string false "Description" +// @Param category formData string false "Category" +// @Param labels formData string false "JSON-encoded labels" +// @Param ground_truths formData string false "JSON-encoded ground truths" +// @Param file formData file true "Zip archive file" +// @Success 201 {object} dto.GenericResponse[UploadDatapackResp] "Datapack uploaded successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/injections/upload [post] +// @x-api-type {"portal":"true"} +func (h *Handler) UploadDatapack(c *gin.Context) { + fileHeader, err := c.FormFile("file") + if err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "file is required: "+err.Error()) + return + } + file, err := fileHeader.Open() + if err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "failed to open uploaded file: "+err.Error()) + return + } + defer func() { _ = file.Close() }() + + req := &UploadDatapackReq{ + Name: c.PostForm("name"), + Description: c.PostForm("description"), + Category: c.PostForm("category"), + Labels: c.PostForm("labels"), + Groundtruths: c.PostForm("groundtruths"), + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + resp, err := h.service.UploadDatapack(c.Request.Context(), req, file, fileHeader.Size) + if httpx.HandleServiceError(c, err) { + return + } + dto.JSONResponse(c, http.StatusCreated, "Datapack uploaded successfully", resp) +} + +func (h *Handler) listFaultInjectionNoIssues(c *gin.Context, projectID *int) { + var req ListInjectionNoIssuesReq + if err := c.BindQuery(&req); err != nil { + logrus.Errorf("failed to bind query parameters: %v", err) + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid query parameters") + return + } + + if err := req.Validate(); err != nil { + logrus.Errorf("invalid query parameters: %v", err) + dto.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + items, err := h.service.ListNoIssues(c.Request.Context(), &req, projectID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.SuccessResponse(c, items) +} + +func (h *Handler) listFaultInjectionWithIssues(c *gin.Context, projectID *int) { + var req ListInjectionWithIssuesReq + if err := c.BindQuery(&req); err != nil { + logrus.Errorf("failed to bind query parameters: %v", err) + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid query parameters") + return + } + + if err := req.Validate(); err != nil { + logrus.Errorf("invalid query parameters: %v", err) + dto.ErrorResponse(c, http.StatusBadRequest, err.Error()) + return + } + + items, err := h.service.ListWithIssues(c.Request.Context(), &req, projectID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.SuccessResponse(c, items) +} + +func (h *Handler) submitFaultInjection(c *gin.Context, projectID *int) { + groupID := c.GetString("groupID") + userID, exists := middleware.GetCurrentUserID(c) + if !exists { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + + spanCtx, span, ok := spanFromGin(c, "SubmitFaultInjection") + if !ok { + return + } + + var req SubmitInjectionReq + if err := c.BindJSON(&req); err != nil { + span.SetStatus(codes.Error, "validation error in SubmitFaultInjection: "+err.Error()) + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + span.SetStatus(codes.Error, "validation error in SubmitFaultInjection: "+err.Error()) + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + // Resolve specs: auto-detect friendly YAML vs chaos.Node DSL and convert all to chaos.Node + if err := req.ResolveSpecs(FriendlySpecToNode); err != nil { + span.SetStatus(codes.Error, "spec conversion error in SubmitFaultInjection: "+err.Error()) + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid fault spec: "+err.Error()) + return + } + + if req.ProjectName == "" && projectID == nil { + span.SetStatus(codes.Error, "validation error in SubmitFaultInjection: project name is required") + dto.ErrorResponse(c, http.StatusBadRequest, "Project name or ID is required") + return + } + + resp, err := h.service.SubmitFaultInjection(spanCtx, &req, groupID, userID, projectID) + if err != nil { + span.SetStatus(codes.Error, "service error in SubmitFaultInjection: "+err.Error()) + logrus.Errorf("Failed to submit fault injection: %v", err) + httpx.HandleServiceError(c, err) + return + } + + span.SetStatus(codes.Ok, fmt.Sprintf("Successfully submitted %d fault injections with groupID: %s", len(resp.Items), groupID)) + dto.SuccessResponse(c, resp) +} + +func (h *Handler) submitDatapackBuilding(c *gin.Context, projectID *int) { + groupID := c.GetString("groupID") + userID, exists := middleware.GetCurrentUserID(c) + if !exists { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + + spanCtx, span, ok := spanFromGin(c, "SubmitDatapackBuilding") + if !ok { + return + } + + var req SubmitDatapackBuildingReq + if err := c.BindJSON(&req); err != nil { + span.SetStatus(codes.Error, "validation error in SubmitDatapackBuilding: "+err.Error()) + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + span.SetStatus(codes.Error, "validation error in SubmitDatapackBuilding: "+err.Error()) + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + if req.ProjectName == "" && projectID == nil { + span.SetStatus(codes.Error, "validation error in SubmitFaultInjection: project name is required") + dto.ErrorResponse(c, http.StatusBadRequest, "Project name or ID is required") + return + } + + resp, err := h.service.SubmitDatapackBuilding(spanCtx, &req, groupID, userID, projectID) + if err != nil { + span.SetStatus(codes.Error, "service error in SubmitDatapackBuilding: "+err.Error()) + logrus.Errorf("Failed to submit datapack building: %v", err) + httpx.HandleServiceError(c, err) + return + } + + span.SetStatus(codes.Ok, fmt.Sprintf("Successfully submitted %d datapack buildings with groupID: %s", len(resp.Items), groupID)) + dto.SuccessResponse(c, resp) +} + +func spanFromGin(c *gin.Context, operation string) (context.Context, trace.Span, bool) { + ctx, ok := c.Get(middleware.SpanContextKey) + if !ok { + logrus.Errorf("Failed to get span context from gin.Context in %s", operation) + dto.ErrorResponse(c, http.StatusInternalServerError, "Internal server error") + return nil, nil, false + } + + spanCtx := ctx.(context.Context) + return spanCtx, trace.SpanFromContext(spanCtx), true +} + +func parsePositiveID(c *gin.Context, key, label string) (int, bool) { + id, ok := httpx.ParsePositiveID(c, c.Param(key), label) + return id, ok +} + +func serveRangeRequest(c *gin.Context, reader io.ReadSeeker, fileSize int64, rangeHeader string) { + const prefix = "bytes=" + if !strings.HasPrefix(rangeHeader, prefix) { + dto.ErrorResponse(c, http.StatusRequestedRangeNotSatisfiable, "Invalid range format") + return + } + rangeSpec := strings.TrimPrefix(rangeHeader, prefix) + if strings.Contains(rangeSpec, ",") { + dto.ErrorResponse(c, http.StatusRequestedRangeNotSatisfiable, "Multi-range not supported") + return + } + parts := strings.SplitN(rangeSpec, "-", 2) + if len(parts) != 2 { + dto.ErrorResponse(c, http.StatusRequestedRangeNotSatisfiable, "Invalid range format") + return + } + var start, end int64 + var err error + if parts[0] == "" { + suffix, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || suffix <= 0 || suffix > fileSize { + c.Header("Content-Range", fmt.Sprintf("bytes */%d", fileSize)) + dto.ErrorResponse(c, http.StatusRequestedRangeNotSatisfiable, "Invalid range") + return + } + start = fileSize - suffix + end = fileSize - 1 + } else { + start, err = strconv.ParseInt(parts[0], 10, 64) + if err != nil || start < 0 || start >= fileSize { + c.Header("Content-Range", fmt.Sprintf("bytes */%d", fileSize)) + dto.ErrorResponse(c, http.StatusRequestedRangeNotSatisfiable, "Invalid range start") + return + } + if parts[1] == "" { + end = fileSize - 1 + } else { + end, err = strconv.ParseInt(parts[1], 10, 64) + if err != nil || end < start || end >= fileSize { + c.Header("Content-Range", fmt.Sprintf("bytes */%d", fileSize)) + dto.ErrorResponse(c, http.StatusRequestedRangeNotSatisfiable, "Invalid range end") + return + } + } + } + contentLength := end - start + 1 + if _, err := reader.Seek(start, io.SeekStart); err != nil { + logrus.Errorf("failed to seek to range start: %v", err) + dto.ErrorResponse(c, http.StatusInternalServerError, "Failed to seek to range start") + return + } + c.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, fileSize)) + c.Header("Content-Length", strconv.FormatInt(contentLength, 10)) + c.Status(http.StatusPartialContent) + if _, err := io.CopyN(c.Writer, reader, contentLength); err != nil { + logrus.Errorf("failed to stream partial content: %v", err) + } +} + +func parseProjectID(c *gin.Context) (int, bool) { + projectIDStr := c.Param(consts.URLPathProjectID) + projectID, err := strconv.Atoi(projectIDStr) + if err != nil || projectID <= 0 { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") + return 0, false + } + return projectID, true +} diff --git a/src/module/injection/handler_service.go b/src/module/injection/handler_service.go new file mode 100644 index 00000000..20dc227c --- /dev/null +++ b/src/module/injection/handler_service.go @@ -0,0 +1,38 @@ +package injection + +import ( + "archive/zip" + "context" + "io" + + "aegis/dto" + "aegis/utils" +) + +// HandlerService captures the injection operations consumed by the HTTP handler. +type HandlerService interface { + ListProjectInjections(context.Context, *ListInjectionReq, int) (*dto.ListResp[InjectionResp], error) + Search(context.Context, *SearchInjectionReq, *int) (*dto.SearchResp[InjectionDetailResp], error) + ListNoIssues(context.Context, *ListInjectionNoIssuesReq, *int) ([]InjectionNoIssuesResp, error) + ListWithIssues(context.Context, *ListInjectionWithIssuesReq, *int) ([]InjectionWithIssuesResp, error) + SubmitFaultInjection(context.Context, *SubmitInjectionReq, string, int, *int) (*SubmitInjectionResp, error) + SubmitDatapackBuilding(context.Context, *SubmitDatapackBuildingReq, string, int, *int) (*SubmitDatapackBuildingResp, error) + ListInjections(context.Context, *ListInjectionReq) (*dto.ListResp[InjectionResp], error) + GetInjection(context.Context, int) (*InjectionDetailResp, error) + ManageLabels(context.Context, *ManageInjectionLabelReq, int) (*InjectionResp, error) + BatchManageLabels(context.Context, *BatchManageInjectionLabelReq) (*BatchManageInjectionLabelResp, error) + BatchDelete(context.Context, *BatchDeleteInjectionReq) error + Clone(context.Context, int, *CloneInjectionReq) (*InjectionDetailResp, error) + GetLogs(context.Context, int) (*InjectionLogsResp, error) + GetDatapackFilename(context.Context, int) (string, error) + DownloadDatapack(context.Context, *zip.Writer, []utils.ExculdeRule, int) error + GetDatapackFiles(context.Context, int, string) (*DatapackFilesResp, error) + DownloadDatapackFile(context.Context, int, string) (string, string, int64, io.ReadSeekCloser, error) + QueryDatapackFile(context.Context, int, string) (string, int64, io.ReadCloser, error) + UpdateGroundtruth(context.Context, int, *UpdateGroundtruthReq) error + UploadDatapack(context.Context, *UploadDatapackReq, io.Reader, int64) (*UploadDatapackResp, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/injection/module.go b/src/module/injection/module.go new file mode 100644 index 00000000..48141cf6 --- /dev/null +++ b/src/module/injection/module.go @@ -0,0 +1,11 @@ +package injection + +import "go.uber.org/fx" + +var Module = fx.Module("injection", + fx.Provide(NewRepository), + fx.Provide(NewDatapackStore), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/service/producer/query_datapack_arrow.go b/src/module/injection/query_datapack_arrow.go similarity index 80% rename from src/service/producer/query_datapack_arrow.go rename to src/module/injection/query_datapack_arrow.go index f77b074e..b69aad4b 100644 --- a/src/service/producer/query_datapack_arrow.go +++ b/src/module/injection/query_datapack_arrow.go @@ -1,6 +1,6 @@ //go:build duckdb_arrow -package producer +package injection import ( "context" @@ -15,9 +15,13 @@ import ( "github.com/sirupsen/logrus" ) -// QueryDatapackFileContent reads a parquet file and streams it as an Arrow IPC stream. -func QueryDatapackFileContent(ctx context.Context, datapackID int, filePath string) (string, int64, io.ReadCloser, error) { - fullPath, err := getFileFullPath(datapackID, filePath) +func (s *Service) queryDatapackFileContent(ctx context.Context, id int, filePath string) (string, int64, io.ReadCloser, error) { + injection, err := s.getReadyDatapack(id) + if err != nil { + return "", 0, nil, err + } + + fullPath, err := s.store.ResolveFilePath(injection.Name, filePath) if err != nil { return "", 0, nil, fmt.Errorf("invalid file path: %w", err) } @@ -45,7 +49,6 @@ func QueryDatapackFileContent(ctx context.Context, datapackID int, filePath stri return "", 0, nil, err } - // Inspect schema and build a SELECT that casts unsupported unsigned integer types. safeSQL, err := buildSafeParquetSQL(ctx, db, fullPath) if err != nil { return "", 0, nil, fmt.Errorf("failed to build safe parquet SQL: %w", err) @@ -96,9 +99,6 @@ func QueryDatapackFileContent(ctx context.Context, datapackID int, filePath stri return filepath.Base(fullPath), totalRows, pr, nil } -// buildSafeParquetSQL inspects the parquet file schema via DuckDB DESCRIBE and builds -// a SELECT that casts UINT64/UHUGEINT columns to signed BIGINT so Arrow IPC consumers -// that do not support unsigned 64-bit integers can still parse the stream. func buildSafeParquetSQL(ctx context.Context, db *sql.DB, filePath string) (string, error) { fallbackSQL := fmt.Sprintf("SELECT * FROM read_parquet('%s')", filePath) describeQuery := fmt.Sprintf("DESCRIBE SELECT * FROM read_parquet('%s')", filePath) @@ -134,7 +134,5 @@ func buildSafeParquetSQL(ctx context.Context, db *sql.DB, filePath string) (stri return fallbackSQL, nil } - safeSQL := fmt.Sprintf("SELECT %s FROM read_parquet('%s')", strings.Join(columns, ", "), filePath) - logrus.Infof("parquet query uses type casting: %s", safeSQL) - return safeSQL, nil + return fmt.Sprintf("SELECT %s FROM read_parquet('%s')", strings.Join(columns, ", "), filePath), nil } diff --git a/src/module/injection/query_datapack_noarrow.go b/src/module/injection/query_datapack_noarrow.go new file mode 100644 index 00000000..c75f131a --- /dev/null +++ b/src/module/injection/query_datapack_noarrow.go @@ -0,0 +1,16 @@ +//go:build !duckdb_arrow + +package injection + +import ( + "context" + "fmt" + "io" +) + +func (s *Service) queryDatapackFileContent(ctx context.Context, id int, filePath string) (string, int64, io.ReadCloser, error) { + _ = ctx + _ = id + _ = filePath + return "", 0, nil, fmt.Errorf("QueryDatapackFileContent requires building with -tags duckdb_arrow") +} diff --git a/src/module/injection/repository.go b/src/module/injection/repository.go new file mode 100644 index 00000000..1ab848f4 --- /dev/null +++ b/src/module/injection/repository.go @@ -0,0 +1,657 @@ +package injection + +import ( + "aegis/consts" + "aegis/dto" + "aegis/model" + "aegis/searchx" + "encoding/json" + "fmt" + "strings" + "time" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) loadInjection(id int) (*model.FaultInjection, error) { + var injection model.FaultInjection + if err := r.db. + Preload("Task"). + Preload("Task.Trace"). + Preload("Benchmark.Container"). + Preload("Pedestal.Container"). + Where("id = ?", id). + First(&injection).Error; err != nil { + return nil, fmt.Errorf("failed to find injection with id %d: %w", id, err) + } + return &injection, nil +} + +func (r *Repository) findInjectionByName(name string, preload bool) (*model.FaultInjection, error) { + query := r.db + if preload { + query = query.Preload("Labels") + } + + var injection model.FaultInjection + if err := query.Where("name = ? AND status != ?", name, consts.CommonDeleted). + First(&injection).Error; err != nil { + return nil, fmt.Errorf("failed to find injection with name %s: %w", name, err) + } + return &injection, nil +} + +func (r *Repository) createInjectionRecord(injection *model.FaultInjection) error { + if err := r.db.Create(injection).Error; err != nil { + return fmt.Errorf("failed to create injection: %w", err) + } + return nil +} + +func (r *Repository) updateGroundtruth(id int, groundtruths []model.Groundtruth, source string) error { + groundtruthJSON, err := json.Marshal(groundtruths) + if err != nil { + return fmt.Errorf("failed to marshal groundtruths: %w", err) + } + + result := r.db.Model(&model.FaultInjection{}). + Where("id = ? AND status != ?", id, consts.CommonDeleted). + Updates(map[string]any{ + "groundtruths": string(groundtruthJSON), + "groundtruth_source": source, + }) + if result.Error != nil { + return fmt.Errorf("failed to update groundtruth for injection %d: %w", id, result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("injection with id %d: %w", id, consts.ErrNotFound) + } + return nil +} + +func (r *Repository) updateInjectionFields(id int, fields map[string]any) error { + result := r.db.Model(&model.FaultInjection{}). + Where("id = ? AND status != ?", id, consts.CommonDeleted). + Updates(fields) + if result.Error != nil { + return fmt.Errorf("failed to update injection %d: %w", id, result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("%w: injection %d not found", consts.ErrNotFound, id) + } + return nil +} + +func (r *Repository) addInjectionLabels(injectionID int, labelIDs []int) error { + if len(labelIDs) == 0 { + return nil + } + + links := make([]model.FaultInjectionLabel, 0, len(labelIDs)) + for _, labelID := range labelIDs { + links = append(links, model.FaultInjectionLabel{ + FaultInjectionID: injectionID, + LabelID: labelID, + }) + } + if err := r.db.Create(&links).Error; err != nil { + return fmt.Errorf("failed to add injection labels: %w", err) + } + return nil +} + +func (r *Repository) resolveProject(name string) (*model.Project, error) { + var project model.Project + if err := r.db.Where("name = ? AND status != ?", name, consts.CommonDeleted).First(&project).Error; err != nil { + return nil, fmt.Errorf("failed to find project with name %s: %w", name, err) + } + return &project, nil +} + +func (r *Repository) loadTask(taskID string) (*model.Task, error) { + var task model.Task + if err := r.db. + Preload("FaultInjection.Benchmark.Container"). + Preload("FaultInjection.Pedestal.Container"). + Preload("Execution.AlgorithmVersion.Container"). + Preload("Execution.Datapack"). + Preload("Execution.DatasetVersion"). + Where("id = ? AND status != ?", taskID, consts.CommonDeleted). + First(&task).Error; err != nil { + return nil, fmt.Errorf("failed to find task with id %s: %w", taskID, err) + } + return &task, nil +} + +func (r *Repository) loadPedestalHelmConfig(versionID int) (*model.HelmConfig, error) { + var helmConfig model.HelmConfig + if err := r.db.Preload("ContainerVersion"). + Where("container_version_id = ?", versionID). + First(&helmConfig).Error; err != nil { + return nil, fmt.Errorf("failed to find helm config for version id %d: %w", versionID, err) + } + return &helmConfig, nil +} + +func (r *Repository) listExistingEngineConfigs(configs []string) ([]string, error) { + if len(configs) == 0 { + return []string{}, nil + } + + invalidLabelSubQuery := r.db.Table("fault_injection_labels fil"). + Select("fil.fault_injection_id"). + Joins("JOIN labels ON labels.id = fil.label_id"). + Where("labels.label_key = ? AND labels.label_value = ?", consts.LabelKeyTag, "invalid") + + var existing []string + if err := r.db.Model(&model.FaultInjection{}). + Select("engine_config"). + Where("engine_config IN (?) AND state >= ? AND status = ?", configs, consts.DatapackInjectSuccess, consts.CommonEnabled). + Where("fault_injections.id NOT IN (?)", invalidLabelSubQuery). + Pluck("engine_config", &existing).Error; err != nil { + return nil, err + } + return existing, nil +} + +func (r *Repository) clearInjectionLabels(injectionIDs []int, labelIDs []int) error { + if len(injectionIDs) == 0 { + return nil + } + + query := r.db.Table("fault_injection_labels").Where("fault_injection_id IN (?)", injectionIDs) + if len(labelIDs) > 0 { + query = query.Where("label_id IN (?)", labelIDs) + } + if err := query.Delete(nil).Error; err != nil { + return fmt.Errorf("failed to clear injection labels: %w", err) + } + return nil +} + +func (r *Repository) batchDecreaseLabelUsages(labelIDs []int, decrement int) error { + if len(labelIDs) == 0 { + return nil + } + + expr := gorm.Expr("GREATEST(0, usage_count - ?)", decrement) + if err := r.db.Model(&model.Label{}). + Where("id IN (?)", labelIDs). + UpdateColumn("usage_count", expr).Error; err != nil { + return fmt.Errorf("failed to batch decrease label usages: %w", err) + } + return nil +} + +func (r *Repository) listExecutionsByDatapackIDs(datapackIDs []int) ([]model.Execution, error) { + if len(datapackIDs) == 0 { + return []model.Execution{}, nil + } + + var executions []model.Execution + if err := r.db. + Preload("AlgorithmVersion.Container"). + Preload("Datapack.Benchmark.Container"). + Preload("Datapack.Pedestal.Container"). + Preload("DatasetVersion"). + Preload("Task.Trace.Project"). + Where("datapack_id IN (?) AND status != ?", datapackIDs, consts.CommonDeleted). + Find(&executions).Error; err != nil { + return nil, fmt.Errorf("failed to list executions by datapack IDs: %w", err) + } + return executions, nil +} + +func (r *Repository) removeLabelsFromExecutions(executionIDs []int) error { + if len(executionIDs) == 0 { + return nil + } + if err := r.db.Where("execution_id IN (?)", executionIDs). + Delete(&model.ExecutionInjectionLabel{}).Error; err != nil { + return fmt.Errorf("failed to remove all labels from executions %v: %w", executionIDs, err) + } + return nil +} + +func (r *Repository) batchDeleteExecutions(executionIDs []int) error { + if len(executionIDs) == 0 { + return nil + } + if err := r.db.Model(&model.Execution{}). + Where("id IN (?) AND status != ?", executionIDs, consts.CommonDeleted). + Update("status", consts.CommonDeleted).Error; err != nil { + return fmt.Errorf("failed to batch delete executions: %w", err) + } + return nil +} + +func (r *Repository) batchDeleteInjections(injectionIDs []int) error { + if len(injectionIDs) == 0 { + return nil + } + if err := r.db.Model(&model.FaultInjection{}). + Where("id IN (?) AND status != ?", injectionIDs, consts.CommonDeleted). + Update("status", consts.CommonDeleted).Error; err != nil { + return fmt.Errorf("failed to batch delete injections: %w", err) + } + return nil +} + +func (r *Repository) deleteInjectionsCascade(injectionIDs []int) error { + executions, err := r.listExecutionsByDatapackIDs(injectionIDs) + if err != nil { + return fmt.Errorf("failed to list executions by datapack ids: %w", err) + } + + executionIDs := make([]int, 0, len(executions)) + for _, execution := range executions { + executionIDs = append(executionIDs, execution.ID) + } + + if len(executionIDs) > 0 { + if err := r.removeLabelsFromExecutions(executionIDs); err != nil { + return fmt.Errorf("failed to remove execution labels: %w", err) + } + if err := r.batchDeleteExecutions(executionIDs); err != nil { + return fmt.Errorf("failed to delete executions: %w", err) + } + } + + if err := r.clearInjectionLabels(injectionIDs, nil); err != nil { + return fmt.Errorf("failed to clear injection labels: %w", err) + } + if err := r.batchDeleteInjections(injectionIDs); err != nil { + return fmt.Errorf("failed to delete injections: %w", err) + } + return nil +} + +func (r *Repository) getInjectionWithLabels(injectionID int) (*model.FaultInjection, error) { + injection, err := r.loadInjection(injectionID) + if err != nil { + return nil, err + } + + var labels []model.Label + if err := r.db.Table("labels"). + Joins("JOIN fault_injection_labels fil ON labels.id = fil.label_id"). + Where("fil.fault_injection_id = ?", injection.ID). + Find(&labels).Error; err != nil { + return nil, fmt.Errorf("failed to get injection labels: %w", err) + } + injection.Labels = labels + return injection, nil +} + +func (r *Repository) loadInjectionLabelIDsByItems(conditions []map[string]string, category consts.LabelCategory) (map[string]int, error) { + if len(conditions) == 0 { + return map[string]int{}, nil + } + + query := r.db.Model(&model.Label{}). + Where("status != ? AND category = ?", consts.CommonDeleted, category) + + orBuilder := r.db.Where("1 = 0") + for _, condition := range conditions { + andBuilder := r.db.Where("1 = 1") + if key, ok := condition["key"]; ok { + andBuilder = andBuilder.Where("label_key = ?", key) + } + if value, ok := condition["value"]; ok { + andBuilder = andBuilder.Where("label_value = ?", value) + } + orBuilder = orBuilder.Or(andBuilder) + } + + var labels []model.Label + if err := query.Where(orBuilder).Find(&labels).Error; err != nil { + return nil, fmt.Errorf("failed to list label IDs by conditions: %w", err) + } + + result := make(map[string]int, len(labels)) + for _, label := range labels { + result[label.Key+":"+label.Value] = label.ID + } + return result, nil +} + +func (r *Repository) loadExistingInjectionsByID(injectionIDs []int) (map[int]*model.FaultInjection, error) { + injections, err := r.listFaultInjectionsByIDWithLabels(injectionIDs) + if err != nil { + return nil, err + } + + result := make(map[int]*model.FaultInjection, len(injections)) + for i := range injections { + injection := injections[i] + result[injection.ID] = &injection + } + return result, nil +} + +func (r *Repository) listInjectionsView(limit, offset int, filterOptions *ListInjectionFilters) ([]model.FaultInjection, int64, error) { + query := r.db.Model(&model.FaultInjection{}). + Preload("Benchmark.Container"). + Preload("Pedestal.Container"). + Preload("Task.Trace.Project"). + Preload("Labels") + if filterOptions.FaultType != nil { + query = query.Where("fault_type = ?", *filterOptions.FaultType) + } + if filterOptions.Category != nil { + query = query.Where("category = ?", *filterOptions.Category) + } + if filterOptions.Benchmark != "" { + query = query.Where("benchmark = ?", filterOptions.Benchmark) + } + if filterOptions.State != nil { + query = query.Where("state = ?", *filterOptions.State) + } + if filterOptions.Status != nil { + query = query.Where("status = ?", *filterOptions.Status) + } + if len(filterOptions.LabelConditions) > 0 { + for _, condition := range filterOptions.LabelConditions { + subQuery := r.db.Table("fault_injection_labels fil"). + Select("fil.fault_injection_id"). + Joins("JOIN labels ON labels.id = fil.label_id"). + Where("labels.label_key = ? AND labels.label_value = ?", condition["key"], condition["value"]) + query = query.Where("fault_injections.id IN (?)", subQuery) + } + } + + var ( + injections []model.FaultInjection + total int64 + ) + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count injections: %w", err) + } + if err := query.Limit(limit).Offset(offset).Order("updated_at DESC").Find(&injections).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list injections: %w", err) + } + + injectionIDs := make([]int, 0, len(injections)) + for _, injection := range injections { + injectionIDs = append(injectionIDs, injection.ID) + } + + labelsMap, err := r.listInjectionLabels(injectionIDs) + if err != nil { + return nil, 0, fmt.Errorf("failed to list injection labels: %w", err) + } + + for i := range injections { + if labels, exists := labelsMap[injections[i].ID]; exists { + injections[i].Labels = labels + } + } + + return injections, total, nil +} + +func (r *Repository) listProjectInjectionsView(projectID, limit, offset int) ([]model.FaultInjection, int64, error) { + baseQuery := r.db.Model(&model.FaultInjection{}). + Joins("JOIN tasks ON tasks.id = fault_injections.task_id"). + Joins("JOIN traces on traces.id = tasks.trace_id"). + Where("traces.project_id = ? AND fault_injections.status != ?", projectID, consts.CommonDeleted) + + var ( + injections []model.FaultInjection + total int64 + ) + if err := baseQuery.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count injections for project %d: %w", projectID, err) + } + + if err := baseQuery. + Preload("Benchmark.Container"). + Preload("Pedestal.Container"). + Limit(limit). + Offset(offset). + Order("fault_injections.updated_at DESC"). + Find(&injections).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list injections for project %d: %w", projectID, err) + } + + injectionIDs := make([]int, 0, len(injections)) + for _, injection := range injections { + injectionIDs = append(injectionIDs, injection.ID) + } + + labelsMap, err := r.listInjectionLabels(injectionIDs) + if err != nil { + return nil, 0, fmt.Errorf("failed to list injection labels: %w", err) + } + + for i := range injections { + injections[i].Labels = labelsMap[injections[i].ID] + } + return injections, total, nil +} + +func (r *Repository) searchInjections(req *SearchInjectionReq, projectID *int) ([]model.FaultInjection, int64, error) { + searchReq := req.ConvertToSearchReq() + if projectID != nil { + searchReq.AddFilter("project_id", dto.OpEqual, *projectID) + } + + qb := searchx.NewQueryBuilder(r.db, consts.InjectionAllowedFields) + qb.ApplySearchReq(searchReq.Filters, searchReq.Keyword, searchReq.Sort, searchReq.GroupBy, model.FaultInjection{}) + qb.ApplyIncludes(searchReq.Includes) + qb.ApplyIncludeFields(searchReq.IncludeFields) + qb.ApplyExcludeFields(searchReq.ExcludeFields, model.FaultInjection{}) + + total, err := qb.GetCount() + if err != nil { + return nil, 0, fmt.Errorf("failed to count searched injections: %w", err) + } + + query := qb.Query() + if searchReq.Size != 0 && searchReq.Page != 0 { + query = query.Offset(searchReq.GetOffset()).Limit(int(searchReq.Size)) + } + + var injections []model.FaultInjection + if err := query.Find(&injections).Error; err != nil { + return nil, 0, fmt.Errorf("failed to execute injection search: %w", err) + } + + if len(req.Labels) == 0 { + return injections, total, nil + } + + labelConditions := make([]map[string]string, 0, len(req.Labels)) + for _, item := range req.Labels { + labelConditions = append(labelConditions, map[string]string{"key": item.Key, "value": item.Value}) + } + + injectionIDs, err := r.listInjectionIDsByLabels(labelConditions) + if err != nil { + return nil, 0, fmt.Errorf("failed to list injection ids by labels: %w", err) + } + + injectionIDMap := make(map[int]struct{}, len(injectionIDs)) + for _, id := range injectionIDs { + injectionIDMap[id] = struct{}{} + } + + filtered := make([]model.FaultInjection, 0, len(injections)) + for _, injection := range injections { + if _, exists := injectionIDMap[injection.ID]; exists { + filtered = append(filtered, injection) + } + } + + return filtered, total, nil +} + +func (r *Repository) listIssuesFreeInjections(labelConditions []map[string]string, startTime, endTime *time.Time, projectID *int) ([]model.FaultInjectionNoIssues, error) { + var injections []model.FaultInjectionNoIssues + query := r.db.Model(&model.FaultInjectionNoIssues{}). + Joins("JOIN fault_injections fi ON fi.id = fault_injection_no_issues.datapack_id"). + Joins("JOIN tasks t ON t.id = fi.task_id"). + Joins("JOIN traces tr ON tr.id = t.trace_id"). + Where("fi.status != ?", consts.CommonDeleted) + if projectID != nil { + query = query.Where("tr.project_id = ?", *projectID) + } + if startTime != nil { + query = query.Where("fi.created_at >= ?", *startTime) + } + if endTime != nil { + query = query.Where("fi.created_at <= ?", *endTime) + } + for _, condition := range labelConditions { + subQuery := r.db.Table("fault_injection_labels fil"). + Select("fil.fault_injection_id"). + Joins("JOIN labels l ON l.id = fil.label_id"). + Where("l.label_key = ? AND l.label_value = ?", condition["key"], condition["value"]) + query = query.Where("fi.id IN (?)", subQuery) + } + anomalySubQuery := r.db.Table("executions e"). + Select("DISTINCT fi2.id"). + Joins("JOIN fault_injections fi2 ON fi2.id = e.datapack_id"). + Where("e.status != ? AND e.has_anomaly = ?", consts.CommonDeleted, true) + query = query.Where("fi.id NOT IN (?)", anomalySubQuery) + if err := query.Scan(&injections).Error; err != nil { + return nil, fmt.Errorf("failed to list injections without issues: %w", err) + } + return injections, nil +} + +func (r *Repository) listIssueInjections(labelConditions []map[string]string, startTime, endTime *time.Time, projectID *int) ([]model.FaultInjectionWithIssues, error) { + var injections []model.FaultInjectionWithIssues + query := r.db.Model(&model.FaultInjectionWithIssues{}). + Joins("JOIN fault_injections fi ON fi.id = fault_injection_with_issues.datapack_id"). + Joins("JOIN tasks t ON t.id = fi.task_id"). + Joins("JOIN traces tr ON tr.id = t.trace_id"). + Where("fi.status != ?", consts.CommonDeleted) + if projectID != nil { + query = query.Where("tr.project_id = ?", *projectID) + } + if startTime != nil { + query = query.Where("fi.created_at >= ?", *startTime) + } + if endTime != nil { + query = query.Where("fi.created_at <= ?", *endTime) + } + for _, condition := range labelConditions { + subQuery := r.db.Table("fault_injection_labels fil"). + Select("fil.fault_injection_id"). + Joins("JOIN labels l ON l.id = fil.label_id"). + Where("l.label_key = ? AND l.label_value = ?", condition["key"], condition["value"]) + query = query.Where("fi.id IN (?)", subQuery) + } + if err := query.Scan(&injections).Error; err != nil { + return nil, fmt.Errorf("failed to list injections with issues: %w", err) + } + return injections, nil +} + +func (r *Repository) listInjectionLabelIDsByKeys(injectionID int, keys []string) ([]int, error) { + var labelIDs []int + if err := r.db.Table("labels l"). + Select("l.id"). + Joins("JOIN fault_injection_labels fil ON fil.label_id = l.id"). + Where("fil.fault_injection_id = ? AND l.label_key IN (?)", injectionID, keys). + Pluck("l.id", &labelIDs).Error; err != nil { + return nil, fmt.Errorf("failed to find label IDs by key '%v': %w", keys, err) + } + return labelIDs, nil +} + +func (r *Repository) listFaultInjectionsByIDWithLabels(injectionIDs []int) ([]model.FaultInjection, error) { + if len(injectionIDs) == 0 { + return []model.FaultInjection{}, nil + } + + var injections []model.FaultInjection + if err := r.db. + Preload("Benchmark.Container"). + Preload("Pedestal.Container"). + Preload("Task.Trace.Project"). + Preload("Labels"). + Where("id IN (?) AND status != ?", injectionIDs, consts.CommonDeleted). + Find(&injections).Error; err != nil { + return nil, fmt.Errorf("failed to query fault injections: %w", err) + } + + labelsMap, err := r.listInjectionLabels(injectionIDs) + if err != nil { + return nil, fmt.Errorf("failed to list injection labels: %w", err) + } + + for i := range injections { + if labels, exists := labelsMap[injections[i].ID]; exists { + injections[i].Labels = labels + } + } + + return injections, nil +} + +func (r *Repository) listInjectionIDsByLabelConditions(labelConditions []map[string]string) ([]int, error) { + return r.listInjectionIDsByLabels(labelConditions) +} + +func (r *Repository) listInjectionIDsByLabels(labelConditions []map[string]string) ([]int, error) { + var injectionIDs []int + query := r.db.Model(&model.FaultInjection{}). + Select("DISTINCT fault_injections.id"). + Joins("JOIN fault_injection_labels fil ON fil.fault_injection_id = fault_injections.id"). + Joins("JOIN labels ON labels.id = fil.label_id"). + Where("fault_injections.status != ?", consts.CommonDeleted) + + var whereClauses []string + var whereArgs []any + for _, condition := range labelConditions { + whereClauses = append(whereClauses, "(labels.label_key = ? AND labels.label_value = ?)") + whereArgs = append(whereArgs, condition["key"], condition["value"]) + } + if len(whereClauses) > 0 { + query = query.Where(strings.Join(whereClauses, " OR "), whereArgs...) + } + + if err := query.Pluck("fault_injections.id", &injectionIDs).Error; err != nil { + return nil, fmt.Errorf("failed to list injection IDs by labels: %v", err) + } + return injectionIDs, nil +} + +func (r *Repository) listInjectionLabels(injectionIDs []int) (map[int][]model.Label, error) { + labelsMap := make(map[int][]model.Label, len(injectionIDs)) + for _, id := range injectionIDs { + labelsMap[id] = []model.Label{} + } + if len(injectionIDs) == 0 { + return labelsMap, nil + } + + type injectionLabelResult struct { + model.Label + InjectionID int `gorm:"column:injection_id"` + } + + var flatResults []injectionLabelResult + if err := r.db.Model(&model.Label{}). + Joins("JOIN fault_injection_labels fil ON fil.label_id = labels.id"). + Where("fil.fault_injection_id IN (?)", injectionIDs). + Select("labels.*, fil.fault_injection_id as injection_id"). + Find(&flatResults).Error; err != nil { + return nil, fmt.Errorf("failed to batch query fault injection labels: %w", err) + } + + for _, result := range flatResults { + labelsMap[result.InjectionID] = append(labelsMap[result.InjectionID], result.Label) + } + return labelsMap, nil +} diff --git a/src/service/producer/common.go b/src/module/injection/resolve.go similarity index 52% rename from src/service/producer/common.go rename to src/module/injection/resolve.go index e9fafb7f..1f83e415 100644 --- a/src/service/producer/common.go +++ b/src/module/injection/resolve.go @@ -1,14 +1,11 @@ -package producer +package injection import ( "aegis/consts" - "aegis/database" "aegis/dto" - "aegis/repository" - "aegis/service/common" + "aegis/model" + dataset "aegis/module/dataset" "fmt" - - "gorm.io/gorm" ) var taskTypeDatapackStates = map[consts.TaskType][]consts.DatapackState{ @@ -24,84 +21,74 @@ var taskTypeDatapackStates = map[consts.TaskType][]consts.DatapackState{ }, } -// checkLabelKeyValue checks if a label with the specified key and value exists in the provided label slice -func checkLabelKeyValue(labels []database.Label, key, value string) bool { - for _, label := range labels { - if label.Key == key && label.Value == value { - return true - } - } - return false -} - -// extractDatapacks extracts datapacks based on the provided datapack name or dataset ref -func extractDatapacks(db *gorm.DB, datapackName *string, datasetRef *dto.DatasetRef, userID int, taskType consts.TaskType) ([]database.FaultInjection, *int, error) { +func (r *Repository) ResolveDatapacks(datapackName *string, datasetRef *dto.DatasetRef, userID int, taskType consts.TaskType) ([]model.FaultInjection, *int, error) { states, exists := taskTypeDatapackStates[taskType] if !exists { return nil, nil, fmt.Errorf("unsupported task type: %s", consts.GetTaskTypeName(taskType)) } - validStates := map[consts.DatapackState]struct{}{} + validStates := make(map[consts.DatapackState]struct{}, len(states)) for _, state := range states { validStates[state] = struct{}{} } - // validateDatapack validates a single datapack's state and labels - validateDatapack := func(datapack *database.FaultInjection) error { - if _, exists := validStates[datapack.State]; !exists { + validateDatapack := func(datapack *model.FaultInjection) error { + if _, ok := validStates[datapack.State]; !ok { return fmt.Errorf("datapack %s is not in a valid state for execution", datapack.Name) } - - if len(datapack.Labels) > 0 && taskType == consts.TaskTypeRunAlgorithm { - if exists := checkLabelKeyValue(datapack.Labels, consts.LabelKeyTag, consts.DetectorNoAnomaly); exists { - return fmt.Errorf("cannot execute detector algorithm on no_anomaly datapack: %s", datapack.Name) - } + if len(datapack.Labels) > 0 && taskType == consts.TaskTypeRunAlgorithm && + hasLabelKeyValue(datapack.Labels, consts.LabelKeyTag, consts.DetectorNoAnomaly) { + return fmt.Errorf("cannot execute detector algorithm on no_anomaly datapack: %s", datapack.Name) } - return nil } if datapackName != nil { - datapack, err := repository.GetInjectionByName(db, *datapackName, true) + datapack, err := r.findInjectionByName(*datapackName, true) if err != nil { return nil, nil, fmt.Errorf("failed to get datapack: %w", err) } - if err := validateDatapack(datapack); err != nil { return nil, nil, err } - - return []database.FaultInjection{*datapack}, nil, nil + return []model.FaultInjection{*datapack}, nil, nil } if datasetRef != nil { - datasetVersionResults, err := common.MapRefsToDatasetVersions([]*dto.DatasetRef{datasetRef}, userID) + datasetVersionResults, err := dataset.NewRepository(r.db).ResolveDatasetVersions([]*dto.DatasetRef{datasetRef}, userID) if err != nil { return nil, nil, fmt.Errorf("failed to get dataset versions: %w", err) } - version, exists := datasetVersionResults[datasetRef] - if !exists { + version, ok := datasetVersionResults[datasetRef] + if !ok { return nil, nil, fmt.Errorf("dataset version not found for %v", datasetRef) } - datapacks, err := repository.ListInjectionsByDatasetVersionID(db, version.ID, true) + datapacks, err := dataset.NewRepository(r.db).ListInjectionsByDatasetVersionID(version.ID, true) if err != nil { return nil, nil, fmt.Errorf("failed to get dataset datapacks: %s", err.Error()) } - if len(datapacks) == 0 { return nil, nil, fmt.Errorf("dataset contains no datapacks") } - for _, datapack := range datapacks { - if err := validateDatapack(&datapack); err != nil { + for i := range datapacks { + if err := validateDatapack(&datapacks[i]); err != nil { return nil, nil, err } } - return datapacks, &version.ID, nil } return nil, nil, fmt.Errorf("either datapack or dataset must be specified") } + +func hasLabelKeyValue(labels []model.Label, key, value string) bool { + for _, label := range labels { + if label.Key == key && label.Value == value { + return true + } + } + return false +} diff --git a/src/dto/injection_test.go b/src/module/injection/resolve_specs_test.go similarity index 99% rename from src/dto/injection_test.go rename to src/module/injection/resolve_specs_test.go index 279a6642..289a52b6 100644 --- a/src/dto/injection_test.go +++ b/src/module/injection/resolve_specs_test.go @@ -1,4 +1,4 @@ -package dto +package injection import ( "encoding/json" diff --git a/src/module/injection/runtime_types.go b/src/module/injection/runtime_types.go new file mode 100644 index 00000000..f97fc279 --- /dev/null +++ b/src/module/injection/runtime_types.go @@ -0,0 +1,42 @@ +package injection + +import ( + "time" + + "aegis/consts" + "aegis/dto" + "aegis/model" + + chaos "github.com/OperationsPAI/chaos-experiment/handler" +) + +// RuntimeCreateInjectionReq captures fault injection writes initiated by runtime-worker-service. +type RuntimeCreateInjectionReq struct { + Name string `json:"name"` + FaultType chaos.ChaosType `json:"fault_type"` + Category chaos.SystemType `json:"category"` + Description string `json:"description"` + DisplayConfig string `json:"display_config"` + EngineConfig string `json:"engine_config"` + Groundtruths []model.Groundtruth `json:"groundtruths"` + GroundtruthSource string `json:"groundtruth_source"` + PreDuration int `json:"pre_duration"` + TaskID string `json:"task_id"` + BenchmarkID *int `json:"benchmark_id,omitempty"` + PedestalID *int `json:"pedestal_id,omitempty"` + Labels []dto.LabelItem `json:"labels,omitempty"` + State consts.DatapackState `json:"state"` +} + +// RuntimeUpdateInjectionStateReq captures datapack state mutations initiated by runtime-worker-service. +type RuntimeUpdateInjectionStateReq struct { + Name string `json:"name"` + State consts.DatapackState `json:"state"` +} + +// RuntimeUpdateInjectionTimestampReq captures datapack timestamp updates initiated by runtime-worker-service. +type RuntimeUpdateInjectionTimestampReq struct { + Name string `json:"name"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` +} diff --git a/src/module/injection/service.go b/src/module/injection/service.go new file mode 100644 index 00000000..6aad09d5 --- /dev/null +++ b/src/module/injection/service.go @@ -0,0 +1,1114 @@ +package injection + +import ( + "archive/zip" + "context" + "errors" + "fmt" + "io" + "sort" + "strings" + "time" + + "aegis/consts" + "aegis/dto" + loki "aegis/infra/loki" + redis "aegis/infra/redis" + "aegis/model" + container "aegis/module/container" + label "aegis/module/label" + "aegis/service/common" + "aegis/utils" + + chaos "github.com/OperationsPAI/chaos-experiment/handler" + "gorm.io/gorm" +) + +type Service struct { + repo *Repository + store *DatapackStore + lokiClient *loki.Client + redis *redis.Gateway +} + +func NewService(repo *Repository, store *DatapackStore, lokiClient *loki.Client, redis *redis.Gateway) *Service { + return &Service{repo: repo, store: store, lokiClient: lokiClient, redis: redis} +} + +func (s *Service) ListProjectInjections(ctx context.Context, req *ListInjectionReq, projectID int) (*dto.ListResp[InjectionResp], error) { + var project model.Project + if err := s.repo.db.Where("id = ?", projectID).First(&project).Error; err != nil { + if errors.Is(err, consts.ErrNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: project id %d not found", consts.ErrNotFound, projectID) + } + return nil, fmt.Errorf("failed to get project: %w", err) + } + + limit, offset := req.ToGormParams() + injections, total, err := s.repo.listProjectInjectionsView(projectID, limit, offset) + if err != nil { + return nil, fmt.Errorf("failed to list injections for project %d: %w", projectID, err) + } + + items := make([]InjectionResp, 0, len(injections)) + for _, injection := range injections { + items = append(items, *NewInjectionResp(&injection)) + } + + return &dto.ListResp[InjectionResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) Search(ctx context.Context, req *SearchInjectionReq, projectID *int) (*dto.SearchResp[InjectionDetailResp], error) { + if req == nil { + return nil, fmt.Errorf("search injection request is nil") + } + injections, total, err := s.repo.searchInjections(req, projectID) + if err != nil { + return nil, fmt.Errorf("failed to search injections: %w", err) + } + items := make([]InjectionDetailResp, 0, len(injections)) + for _, injection := range injections { + items = append(items, *NewInjectionDetailResp(&injection)) + } + + resp := &dto.SearchResp[InjectionDetailResp]{ + Pagination: req.ConvertToPaginationInfo(total), + } + if len(req.GroupBy) > 0 { + resp.Groups = dto.BuildGroupTree(items, req.GroupBy) + } else { + resp.Items = items + } + return resp, nil +} + +func (s *Service) ListNoIssues(ctx context.Context, req *ListInjectionNoIssuesReq, projectID *int) ([]InjectionNoIssuesResp, error) { + if len(req.Labels) == 0 { + return nil, nil + } + + labelConditions := make([]map[string]string, 0, len(req.Labels)) + for _, item := range req.Labels { + parts := splitLabelCondition(item) + labelConditions = append(labelConditions, map[string]string{"key": parts[0], "value": parts[1]}) + } + + opts, err := req.Convert() + if err != nil { + return nil, fmt.Errorf("invalid time range: %w", err) + } + + records, err := s.repo.listIssuesFreeInjections(labelConditions, &opts.CustomStartTime, &opts.CustomEndTime, projectID) + if err != nil { + return nil, fmt.Errorf("failed to list fault injections without issues: %w", err) + } + + items := make([]InjectionNoIssuesResp, 0, len(records)) + for i, record := range records { + resp, err := NewInjectionNoIssuesResp(record) + if err != nil { + return nil, fmt.Errorf("failed to create InjectionNoIssuesResp at index %d: %w", i, err) + } + items = append(items, *resp) + } + return items, nil +} + +func (s *Service) ListWithIssues(ctx context.Context, req *ListInjectionWithIssuesReq, projectID *int) ([]InjectionWithIssuesResp, error) { + if len(req.Labels) == 0 { + return nil, nil + } + + labelConditions := make([]map[string]string, 0, len(req.Labels)) + for _, item := range req.Labels { + parts := splitLabelCondition(item) + labelConditions = append(labelConditions, map[string]string{"key": parts[0], "value": parts[1]}) + } + + opts, err := req.Convert() + if err != nil { + return nil, fmt.Errorf("invalid time range: %w", err) + } + + records, err := s.repo.listIssueInjections(labelConditions, &opts.CustomStartTime, &opts.CustomEndTime, projectID) + if err != nil { + return nil, fmt.Errorf("failed to list fault injections without issues: %w", err) + } + + items := make([]InjectionWithIssuesResp, 0, len(records)) + for _, record := range records { + resp, err := NewInjectionWithIssuesResp(record) + if err != nil { + return nil, fmt.Errorf("failed to create InjectionNoIssuesResp: %w", err) + } + items = append(items, *resp) + } + return items, nil +} + +func (s *Service) SubmitFaultInjection(ctx context.Context, req *SubmitInjectionReq, groupID string, userID int, projectID *int) (*SubmitInjectionResp, error) { + if req == nil { + return nil, fmt.Errorf("submit injection request is nil") + } + db := s.repo.db + + if projectID == nil { + project, err := s.repo.resolveProject(req.ProjectName) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: project %s not found", consts.ErrNotFound, req.ProjectName) + } + return nil, fmt.Errorf("failed to get project: %w", err) + } + projectID = &project.ID + } + + pedestalVersionResults, err := container.NewRepository(db).ResolveContainerVersions([]*dto.ContainerRef{&req.Pedestal.ContainerRef}, consts.ContainerTypePedestal, userID) + if err != nil { + return nil, fmt.Errorf("failed to map pedestal container ref to version: %w", err) + } + pedestalVersion, exists := pedestalVersionResults[&req.Pedestal.ContainerRef] + if !exists { + return nil, fmt.Errorf("pedestal version not found for container: %s (version: %s)", req.Pedestal.Name, req.Pedestal.Version) + } + + helmConfig, err := s.repo.loadPedestalHelmConfig(pedestalVersion.ID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: helm config not found for pedestal version id %d", consts.ErrNotFound, pedestalVersion.ID) + } + return nil, fmt.Errorf("failed to get helm config: %w", err) + } + + params := flattenYAMLToParameters(req.Pedestal.Payload, "") + helmValues, err := container.NewRepository(db).ListHelmConfigValues(params, helmConfig) + if err != nil { + return nil, fmt.Errorf("failed to render pedestal helm values: %w", err) + } + + helmConfigItem := dto.NewHelmConfigItem(helmConfig) + helmConfigItem.DynamicValues = helmValues + + pedestalItem := dto.NewContainerVersionItem(&pedestalVersion) + pedestalItem.Extra = helmConfigItem + + benchmarkVersionResults, err := container.NewRepository(db).ResolveContainerVersions([]*dto.ContainerRef{&req.Benchmark.ContainerRef}, consts.ContainerTypeBenchmark, userID) + if err != nil { + return nil, fmt.Errorf("failed to map benchmark container ref to version: %w", err) + } + benchmarkVersion, exists := benchmarkVersionResults[&req.Benchmark.ContainerRef] + if !exists { + return nil, fmt.Errorf("benchmark version not found for container: %s (version: %s)", req.Benchmark.Name, req.Benchmark.Version) + } + + benchmarkVersionItem := dto.NewContainerVersionItem(&benchmarkVersion) + envVars, err := container.NewRepository(db).ListContainerVersionEnvVars(req.Benchmark.EnvVars, &benchmarkVersion) + if err != nil { + return nil, fmt.Errorf("failed to list benchmark env vars: %w", err) + } + benchmarkVersionItem.EnvVars = envVars + + // Use resolved fields (populated by handler-level ResolveSpecs). + // Exactly one of ResolvedSpecs or ResolvedGuidedConfigs is populated. + legacySpecs := req.ResolvedSpecs + guidedSpecs := req.ResolvedGuidedConfigs + if len(legacySpecs) == 0 && len(guidedSpecs) == 0 { + return nil, fmt.Errorf("no resolved specs available; call ResolveSpecs before SubmitFaultInjection") + } + + capacity := len(legacySpecs) + if len(guidedSpecs) > capacity { + capacity = len(guidedSpecs) + } + processedItems := make([]injectionProcessItem, 0, capacity) + var parseWarnings []string + if len(guidedSpecs) > 0 { + for i := range guidedSpecs { + item, warning, err := parseBatchGuidedSpecs(ctx, pedestalItem.ContainerName, i, guidedSpecs[i]) + if err != nil { + return nil, fmt.Errorf("failed to parse guided spec batch %d: %w", i, err) + } + if warning != "" { + parseWarnings = append(parseWarnings, warning) + } else { + processedItems = append(processedItems, *item) + } + } + } else { + for i := range legacySpecs { + item, warning, err := parseBatchInjectionSpecs(pedestalItem.ContainerName, i, legacySpecs[i]) + if err != nil { + return nil, fmt.Errorf("failed to parse injection spec batch %d: %w", i, err) + } + if warning != "" { + parseWarnings = append(parseWarnings, warning) + } else { + processedItems = append(processedItems, *item) + } + } + } + + uniqueItems, duplicatedInRequest, alreadyExisted, err := s.removeDuplicated(processedItems) + if err != nil { + return nil, fmt.Errorf("failed to remove duplicated batches: %w", err) + } + + var warnings *InjectionWarnings + if len(parseWarnings) > 0 || len(duplicatedInRequest) > 0 || len(alreadyExisted) > 0 { + warnings = &InjectionWarnings{ + DuplicateServicesInBatch: parseWarnings, + DuplicateBatchesInRequest: duplicatedInRequest, + BatchesExistInDatabase: alreadyExisted, + } + } + + if len(req.Algorithms) > 0 { + refs := make([]*dto.ContainerRef, 0, len(req.Algorithms)) + for i := range req.Algorithms { + refs = append(refs, &req.Algorithms[i].ContainerRef) + } + + algorithmVersionsResults, err := container.NewRepository(db).ResolveContainerVersions(refs, consts.ContainerTypeAlgorithm, userID) + if err != nil { + return nil, fmt.Errorf("failed to map container refs to versions: %w", err) + } + + var algorithmVersionItems []dto.ContainerVersionItem + for i := range req.Algorithms { + spec := &req.Algorithms[i] + algorithmVersion, exists := algorithmVersionsResults[&spec.ContainerRef] + if !exists { + return nil, fmt.Errorf("algorithm version not found for %v", spec) + } + + algorithmVersionItem := dto.NewContainerVersionItem(&algorithmVersion) + envVars, err := container.NewRepository(db).ListContainerVersionEnvVars(spec.EnvVars, &algorithmVersion) + if err != nil { + return nil, fmt.Errorf("failed to list algorithm env vars: %w", err) + } + + algorithmVersionItem.EnvVars = envVars + algorithmVersionItems = append(algorithmVersionItems, algorithmVersionItem) + } + + if len(algorithmVersionItems) > 0 { + if err := s.redis.SetHashField(ctx, consts.InjectionAlgorithmsKey, groupID, algorithmVersionItems); err != nil { + return nil, fmt.Errorf("failed to store injection algorithms: %w", err) + } + } + } + + injectionItems := make([]SubmitInjectionItem, 0, len(uniqueItems)) + for _, item := range uniqueItems { + injectPayload := map[string]any{ + consts.InjectBenchmark: benchmarkVersionItem, + consts.InjectPreDuration: req.PreDuration, + consts.InjectLabels: req.Labels, + consts.InjectSystem: chaos.SystemType(pedestalItem.ContainerName), + } + // Exactly one of nodes / guidedConfigs is populated on item. + if len(item.guidedConfigs) > 0 { + injectPayload[consts.InjectGuidedConfigs] = item.guidedConfigs + } else { + injectPayload[consts.InjectNodes] = item.nodes + } + payload := map[string]any{ + consts.RestartPedestal: pedestalItem, + consts.RestartHelmConfig: helmConfig, + consts.RestartIntarval: req.Interval, + consts.RestartFaultDuration: item.faultDuration, + consts.RestartInjectPayload: injectPayload, + } + + task := &dto.UnifiedTask{ + Type: consts.TaskTypeRestartPedestal, + Immediate: false, + ExecuteTime: item.executeTime.Unix(), + Payload: payload, + GroupID: groupID, + ProjectID: *projectID, + UserID: userID, + State: consts.TaskPending, + Extra: map[consts.TaskExtra]any{ + consts.TaskExtraInjectionAlgorithms: len(req.Algorithms), + }, + } + task.SetGroupCtx(ctx) + + if err := common.SubmitTaskWithDB(ctx, db, s.redis, task); err != nil { + return nil, fmt.Errorf("failed to submit fault injection task: %w", err) + } + + injectionItems = append(injectionItems, SubmitInjectionItem{ + Index: item.index, + TraceID: task.TraceID, + TaskID: task.TaskID, + }) + } + + sort.Slice(injectionItems, func(i, j int) bool { return injectionItems[i].Index < injectionItems[j].Index }) + return &SubmitInjectionResp{ + GroupID: groupID, + Items: injectionItems, + OriginalCount: len(processedItems), + Warnings: warnings, + }, nil +} + +func (s *Service) SubmitDatapackBuilding(ctx context.Context, req *SubmitDatapackBuildingReq, groupID string, userID int, projectID *int) (*SubmitDatapackBuildingResp, error) { + if req == nil { + return nil, fmt.Errorf("submit datapack building request is nil") + } + db := s.repo.db + + if projectID == nil { + project, err := s.repo.resolveProject(req.ProjectName) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: project %s not found", consts.ErrNotFound, req.ProjectName) + } + return nil, fmt.Errorf("failed to get project: %w", err) + } + projectID = &project.ID + } + + refs := make([]*dto.ContainerRef, 0, len(req.Specs)) + for i := range req.Specs { + refs = append(refs, &req.Specs[i].Benchmark.ContainerRef) + } + + benchmarkVersionResults, err := container.NewRepository(db).ResolveContainerVersions(refs, consts.ContainerTypeBenchmark, userID) + if err != nil { + return nil, fmt.Errorf("failed to map container refs to versions: %w", err) + } + + var allBuildingItems []SubmitBuildingItem + for idx, spec := range req.Specs { + datapacks, datasetVersionID, err := s.repo.ResolveDatapacks(spec.Datapack, spec.Dataset, userID, consts.TaskTypeBuildDatapack) + if err != nil { + return nil, fmt.Errorf("failed to extract datapacks: %w", err) + } + + benchmarkVersion, exists := benchmarkVersionResults[refs[idx]] + if !exists { + return nil, fmt.Errorf("benchmark version not found for %v", spec.Benchmark) + } + + benchmarkVersionItem := dto.NewContainerVersionItem(&benchmarkVersion) + envVars, err := container.NewRepository(db).ListContainerVersionEnvVars(spec.Benchmark.EnvVars, &benchmarkVersion) + if err != nil { + return nil, fmt.Errorf("failed to list benchmark env vars: %w", err) + } + benchmarkVersionItem.EnvVars = envVars + + for _, datapack := range datapacks { + if datapack.StartTime == nil || datapack.EndTime == nil { + return nil, fmt.Errorf("datapack %s does not have valid start_time and end_time", datapack.Name) + } + + payload := map[string]any{ + consts.BuildBenchmark: benchmarkVersionItem, + consts.BuildDatapack: dto.NewInjectionItem(&datapack), + consts.BuildDatasetVersionID: datasetVersionID, + consts.BuildLabels: req.Labels, + } + + task := &dto.UnifiedTask{ + Type: consts.TaskTypeBuildDatapack, + Immediate: true, + Payload: payload, + GroupID: groupID, + ProjectID: *projectID, + UserID: userID, + State: consts.TaskPending, + } + task.SetGroupCtx(ctx) + + if err := common.SubmitTaskWithDB(ctx, db, s.redis, task); err != nil { + return nil, fmt.Errorf("failed to submit datapack building task: %w", err) + } + + allBuildingItems = append(allBuildingItems, SubmitBuildingItem{ + Index: idx, + TraceID: task.TraceID, + TaskID: task.TaskID, + }) + } + } + + return &SubmitDatapackBuildingResp{ + GroupID: groupID, + Items: allBuildingItems, + }, nil +} + +func (s *Service) ListInjections(_ context.Context, req *ListInjectionReq) (*dto.ListResp[InjectionResp], error) { + limit, offset := req.ToGormParams() + injections, total, err := s.repo.listInjectionsView(limit, offset, req.ToFilterOptions()) + if err != nil { + return nil, fmt.Errorf("failed to list injections: %w", err) + } + + items := make([]InjectionResp, 0, len(injections)) + for _, injection := range injections { + items = append(items, *NewInjectionResp(&injection)) + } + + return &dto.ListResp[InjectionResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) GetInjection(_ context.Context, id int) (*InjectionDetailResp, error) { + injection, err := s.repo.getInjectionWithLabels(id) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return nil, fmt.Errorf("%w: injection id: %d", consts.ErrNotFound, id) + } + return nil, fmt.Errorf("failed to get injection: %w", err) + } + return NewInjectionDetailResp(injection), nil +} + +func (s *Service) GetMetadata(_ context.Context) (*InjectionMetadataResp, error) { + return nil, nil +} + +func (s *Service) ManageLabels(_ context.Context, req *ManageInjectionLabelReq, id int) (*InjectionResp, error) { + var managedInjection *model.FaultInjection + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + injection, err := repo.loadInjection(id) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return fmt.Errorf("%w: injection id: %d", consts.ErrNotFound, id) + } + return fmt.Errorf("failed to get injection: %w", err) + } + + if len(req.AddLabels) > 0 { + labels, err := label.NewRepository(tx).CreateOrUpdateLabelsFromItems(tx, req.AddLabels, consts.InjectionCategory) + if err != nil { + return fmt.Errorf("failed to create or update labels: %w", err) + } + labelIDs := make([]int, 0, len(labels)) + for _, label := range labels { + labelIDs = append(labelIDs, label.ID) + } + if err := repo.addInjectionLabels(injection.ID, labelIDs); err != nil { + return fmt.Errorf("failed to add injection labels: %w", err) + } + } + + if len(req.RemoveLabels) > 0 { + labelIDs, err := repo.listInjectionLabelIDsByKeys(injection.ID, req.RemoveLabels) + if err != nil { + return fmt.Errorf("failed to find label ids by keys: %w", err) + } + if len(labelIDs) > 0 { + if err := repo.clearInjectionLabels([]int{id}, labelIDs); err != nil { + return fmt.Errorf("failed to clear injection labels: %w", err) + } + if err := repo.batchDecreaseLabelUsages(labelIDs, 1); err != nil { + return fmt.Errorf("failed to decrease label usage counts: %w", err) + } + } + } + + managedInjection, err = repo.getInjectionWithLabels(id) + if err != nil { + return fmt.Errorf("failed to reload injection labels: %w", err) + } + return nil + }) + if err != nil { + return nil, err + } + return NewInjectionResp(managedInjection), nil +} + +func (s *Service) BatchManageLabels(_ context.Context, req *BatchManageInjectionLabelReq) (*BatchManageInjectionLabelResp, error) { + resp := &BatchManageInjectionLabelResp{ + FailedItems: []string{}, + SuccessItems: []InjectionResp{}, + } + if len(req.Items) == 0 { + return resp, nil + } + + return resp, s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + allInjectionIDs := make([]int, 0, len(req.Items)) + operationMap := make(map[int]*InjectionLabelOperation, len(req.Items)) + for i := range req.Items { + item := &req.Items[i] + allInjectionIDs = append(allInjectionIDs, item.InjectionID) + operationMap[item.InjectionID] = item + } + + foundIDMap, err := repo.loadExistingInjectionsByID(allInjectionIDs) + if err != nil { + return fmt.Errorf("failed to list injections: %w", err) + } + + validIDs := make([]int, 0, len(foundIDMap)) + for _, id := range allInjectionIDs { + if _, found := foundIDMap[id]; !found { + resp.FailedItems = append(resp.FailedItems, fmt.Sprintf("Injection ID %d not found", id)) + resp.FailedCount++ + delete(operationMap, id) + } else { + validIDs = append(validIDs, id) + } + } + if len(validIDs) == 0 { + return fmt.Errorf("no valid injection IDs found") + } + + allAddLabels := make([]dto.LabelItem, 0) + allRemoveLabels := make([]dto.LabelItem, 0) + labelKeySet := make(map[string]bool) + for _, op := range operationMap { + for _, label := range op.AddLabels { + key := label.Key + ":" + label.Value + if !labelKeySet[key] { + labelKeySet[key] = true + allAddLabels = append(allAddLabels, label) + } + } + for _, label := range op.RemoveLabels { + key := label.Key + ":" + label.Value + if !labelKeySet[key] { + labelKeySet[key] = true + allRemoveLabels = append(allRemoveLabels, label) + } + } + } + + var labelMap map[string]int + if len(allAddLabels) > 0 { + labels, err := label.NewRepository(tx).CreateOrUpdateLabelsFromItems(tx, allAddLabels, consts.InjectionCategory) + if err != nil { + return fmt.Errorf("failed to create or update labels: %w", err) + } + labelMap = make(map[string]int, len(labels)) + for _, label := range labels { + labelMap[label.Key+":"+label.Value] = label.ID + } + } + + var removeLabelMap map[string]int + if len(allRemoveLabels) > 0 { + labelConditions := make([]map[string]string, 0, len(allRemoveLabels)) + for _, item := range allRemoveLabels { + labelConditions = append(labelConditions, map[string]string{"key": item.Key, "value": item.Value}) + } + removeLabelMap, err = repo.loadInjectionLabelIDsByItems(labelConditions, consts.InjectionCategory) + if err != nil { + return fmt.Errorf("failed to find labels to remove: %w", err) + } + } + + for _, injectionID := range validIDs { + op := operationMap[injectionID] + if len(op.AddLabels) > 0 { + labelIDsToAdd := make([]int, 0, len(op.AddLabels)) + for _, label := range op.AddLabels { + if labelID, exists := labelMap[label.Key+":"+label.Value]; exists { + labelIDsToAdd = append(labelIDsToAdd, labelID) + } + } + if len(labelIDsToAdd) > 0 { + if err := repo.addInjectionLabels(injectionID, labelIDsToAdd); err != nil { + resp.FailedItems = append(resp.FailedItems, fmt.Sprintf("Injection ID %d: failed to add labels - %s", injectionID, err.Error())) + resp.FailedCount++ + delete(foundIDMap, injectionID) + continue + } + } + } + + if len(op.RemoveLabels) > 0 && removeLabelMap != nil { + labelIDsToRemove := make([]int, 0, len(op.RemoveLabels)) + for _, label := range op.RemoveLabels { + if labelID, exists := removeLabelMap[label.Key+":"+label.Value]; exists { + labelIDsToRemove = append(labelIDsToRemove, labelID) + } + } + if len(labelIDsToRemove) > 0 { + if err := repo.clearInjectionLabels([]int{injectionID}, labelIDsToRemove); err != nil { + resp.FailedItems = append(resp.FailedItems, fmt.Sprintf("Injection ID %d: failed to remove labels - %s", injectionID, err.Error())) + resp.FailedCount++ + delete(foundIDMap, injectionID) + continue + } + } + } + } + + if len(foundIDMap) > 0 { + successIDs := make([]int, 0, len(foundIDMap)) + for id := range foundIDMap { + successIDs = append(successIDs, id) + } + updatedInjections, err := repo.listFaultInjectionsByIDWithLabels(successIDs) + if err != nil { + return fmt.Errorf("failed to fetch updated injections: %w", err) + } + for i := range updatedInjections { + injection := &updatedInjections[i] + resp.SuccessItems = append(resp.SuccessItems, *NewInjectionResp(injection)) + resp.SuccessCount++ + } + } + + return nil + }) +} + +func (s *Service) BatchDelete(ctx context.Context, req *BatchDeleteInjectionReq) error { + if len(req.IDs) > 0 { + return s.batchDeleteByIDs(req.IDs) + } + return s.batchDeleteByLabels(req.Labels) +} + +func (s *Service) Clone(_ context.Context, id int, req *CloneInjectionReq) (*InjectionDetailResp, error) { + original, err := s.repo.loadInjection(id) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return nil, fmt.Errorf("%w: injection id: %d", consts.ErrNotFound, id) + } + return nil, fmt.Errorf("failed to get injection: %w", err) + } + + cloned := &model.FaultInjection{ + Name: req.Name, + FaultType: original.FaultType, + Category: original.Category, + Description: original.Description, + DisplayConfig: original.DisplayConfig, + EngineConfig: original.EngineConfig, + Groundtruths: original.Groundtruths, + PreDuration: original.PreDuration, + StartTime: original.StartTime, + EndTime: original.EndTime, + BenchmarkID: original.BenchmarkID, + PedestalID: original.PedestalID, + State: consts.DatapackInitial, + Status: consts.CommonEnabled, + } + + err = s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if err := repo.createInjectionRecord(cloned); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: injection with name %s already exists", consts.ErrAlreadyExists, cloned.Name) + } + return fmt.Errorf("failed to create injection: %w", err) + } + if len(req.Labels) > 0 { + labels, err := label.NewRepository(tx).CreateOrUpdateLabelsFromItems(tx, req.Labels, consts.InjectionCategory) + if err != nil { + return fmt.Errorf("failed to create or update labels: %w", err) + } + labelIDs := make([]int, 0, len(labels)) + for _, label := range labels { + labelIDs = append(labelIDs, label.ID) + } + if err := repo.addInjectionLabels(cloned.ID, labelIDs); err != nil { + return fmt.Errorf("failed to add injection labels: %w", err) + } + } + return nil + }) + if err != nil { + return nil, err + } + + cloned, err = s.repo.getInjectionWithLabels(cloned.ID) + if err != nil { + return nil, fmt.Errorf("failed to get cloned injection labels: %w", err) + } + return NewInjectionDetailResp(cloned), nil +} + +func (s *Service) GetLogs(ctx context.Context, id int) (*InjectionLogsResp, error) { + injection, err := s.repo.loadInjection(id) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return nil, fmt.Errorf("%w: injection id: %d", consts.ErrNotFound, id) + } + return nil, fmt.Errorf("failed to get injection: %w", err) + } + + resp := &InjectionLogsResp{InjectionID: id, Logs: []string{}} + if injection.TaskID == nil { + return resp, nil + } + + resp.TaskID = *injection.TaskID + task, taskErr := s.repo.loadTask(*injection.TaskID) + if taskErr != nil { + return resp, nil + } + + lokiCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + logEntries, lokiErr := s.lokiClient.QueryJobLogs(lokiCtx, *injection.TaskID, loki.QueryOpts{ + Start: task.CreatedAt, + Direction: "forward", + }) + if lokiErr != nil { + return resp, nil + } + for _, entry := range logEntries { + resp.Logs = append(resp.Logs, entry.Line) + } + return resp, nil +} + +func (s *Service) GetDatapackFilename(_ context.Context, id int) (string, error) { + injection, err := s.repo.loadInjection(id) + if err != nil { + if errors.Is(err, consts.ErrNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + return "", fmt.Errorf("%w: injection id: %d", consts.ErrNotFound, id) + } + return "", fmt.Errorf("failed to get injection: %w", err) + } + if injection.State < consts.DatapackBuildSuccess { + return "", fmt.Errorf("datapack for injection id %d is not ready for download", id) + } + return injection.Name, nil +} + +func (s *Service) DownloadDatapack(_ context.Context, zipWriter *zip.Writer, excludeRules []utils.ExculdeRule, id int) error { + if zipWriter == nil { + return fmt.Errorf("zip writer cannot be nil") + } + injection, err := s.getReadyDatapack(id) + if err != nil { + return err + } + if err := s.store.Package(zipWriter, injection.Name, excludeRules); err != nil { + return fmt.Errorf("failed to package injection to zip: %w", err) + } + return nil +} + +func (s *Service) GetDatapackFiles(_ context.Context, id int, baseURL string) (*DatapackFilesResp, error) { + injection, err := s.getReadyDatapack(id) + if err != nil { + return nil, err + } + resp, err := s.store.BuildFileTree(injection.Name, baseURL, id) + if err != nil { + return nil, fmt.Errorf("failed to build file tree: %w", err) + } + return resp, nil +} + +func (s *Service) DownloadDatapackFile(_ context.Context, id int, filePath string) (string, string, int64, io.ReadSeekCloser, error) { + injection, err := s.getReadyDatapack(id) + if err != nil { + return "", "", 0, nil, err + } + return s.store.OpenFile(injection.Name, filePath) +} + +func (s *Service) QueryDatapackFile(ctx context.Context, id int, filePath string) (string, int64, io.ReadCloser, error) { + return s.queryDatapackFileContent(ctx, id, filePath) +} + +func (s *Service) UpdateGroundtruth(_ context.Context, id int, req *UpdateGroundtruthReq) error { + if _, err := s.repo.loadInjection(id); err != nil { + return err + } + return s.repo.updateGroundtruth(id, req.Groundtruths, consts.GroundtruthSourceManual) +} + +func (s *Service) CreateInjectionRecord(_ context.Context, req *RuntimeCreateInjectionReq) (*dto.InjectionItem, error) { + if req == nil { + return nil, fmt.Errorf("runtime create injection request is nil") + } + if req.Name == "" || req.TaskID == "" { + return nil, fmt.Errorf("%w: name and task_id are required", consts.ErrBadRequest) + } + + var created *model.FaultInjection + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + injection := &model.FaultInjection{ + Name: req.Name, + Source: consts.DatapackSourceInjection, + FaultType: req.FaultType, + Category: req.Category, + Description: req.Description, + DisplayConfig: utils.StringPtr(req.DisplayConfig), + EngineConfig: req.EngineConfig, + Groundtruths: req.Groundtruths, + GroundtruthSource: req.GroundtruthSource, + PreDuration: req.PreDuration, + TaskID: utils.StringPtr(req.TaskID), + BenchmarkID: req.BenchmarkID, + PedestalID: req.PedestalID, + State: req.State, + Status: consts.CommonEnabled, + } + + if err := repo.createInjectionRecord(injection); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: injection %s already exists", consts.ErrAlreadyExists, req.Name) + } + return err + } + + if len(req.Labels) > 0 { + createdLabels, err := label.NewRepository(tx).CreateOrUpdateLabelsFromItems(tx, req.Labels, consts.InjectionCategory) + if err != nil { + return fmt.Errorf("failed to create or update labels: %w", err) + } + + labelIDs := make([]int, 0, len(createdLabels)) + for _, label := range createdLabels { + labelIDs = append(labelIDs, label.ID) + } + + if err := repo.addInjectionLabels(injection.ID, labelIDs); err != nil { + return fmt.Errorf("failed to add injection labels: %w", err) + } + } + + created = injection + return nil + }) + if err != nil { + return nil, err + } + + item := dto.NewInjectionItem(created) + return &item, nil +} + +func (s *Service) UpdateInjectionState(_ context.Context, req *RuntimeUpdateInjectionStateReq) error { + if req == nil { + return fmt.Errorf("runtime update injection state request is nil") + } + if req.Name == "" { + return fmt.Errorf("%w: name is required", consts.ErrBadRequest) + } + + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + injection, err := repo.findInjectionByName(req.Name, false) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: injection %s not found", consts.ErrNotFound, req.Name) + } + return err + } + return repo.updateInjectionFields(injection.ID, map[string]any{"state": req.State}) + }) +} + +func (s *Service) UpdateInjectionTimestamps(_ context.Context, req *RuntimeUpdateInjectionTimestampReq) (*dto.InjectionItem, error) { + if req == nil { + return nil, fmt.Errorf("runtime update injection timestamp request is nil") + } + if req.Name == "" { + return nil, fmt.Errorf("%w: name is required", consts.ErrBadRequest) + } + + var updated *model.FaultInjection + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + injection, err := repo.findInjectionByName(req.Name, false) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: injection %s not found", consts.ErrNotFound, req.Name) + } + return err + } + if err := repo.updateInjectionFields(injection.ID, map[string]any{ + "start_time": req.StartTime, + "end_time": req.EndTime, + }); err != nil { + return err + } + + reloaded, err := repo.loadInjection(injection.ID) + if err != nil { + return err + } + updated = reloaded + return nil + }) + if err != nil { + return nil, err + } + + item := dto.NewInjectionItem(updated) + return &item, nil +} + +func (s *Service) UploadDatapack(_ context.Context, req *UploadDatapackReq, file io.Reader, fileSize int64) (*UploadDatapackResp, error) { + _ = fileSize + + labels, err := req.ParseLabels() + if err != nil { + return nil, fmt.Errorf("%w: %s", consts.ErrBadRequest, err.Error()) + } + + groundtruths, err := req.ParseGroundtruths() + if err != nil { + return nil, fmt.Errorf("%w: %s", consts.ErrBadRequest, err.Error()) + } + + existing, _ := s.repo.findInjectionByName(req.Name, false) + if existing != nil { + return nil, fmt.Errorf("%w: injection with name %s already exists", consts.ErrAlreadyExists, req.Name) + } + + tmpFile, err := s.store.CreateUploadTempFile() + if err != nil { + return nil, fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer func() { _ = s.store.Remove(tmpPath) }() + + if _, err := io.Copy(tmpFile, file); err != nil { + _ = tmpFile.Close() + return nil, fmt.Errorf("failed to save uploaded file: %w", err) + } + if err := tmpFile.Close(); err != nil { + return nil, fmt.Errorf("failed to close uploaded file: %w", err) + } + + if err := s.store.ValidateArchive(tmpPath); err != nil { + return nil, fmt.Errorf("%w: %s", consts.ErrBadRequest, err.Error()) + } + + targetDir, err := s.store.EnsureDatapackDirAvailable(req.Name) + if err != nil { + return nil, err + } + if err := s.store.ExtractArchive(tmpPath, targetDir); err != nil { + _ = s.store.RemoveAll(targetDir) + return nil, fmt.Errorf("failed to extract archive: %w", err) + } + + groundtruthSource := "" + if len(groundtruths) > 0 { + groundtruthSource = consts.GroundtruthSourceManual + } else { + groundtruths = s.store.ExtractGroundtruths(targetDir) + if len(groundtruths) > 0 { + groundtruthSource = consts.GroundtruthSourceImported + } + } + + category := chaos.SystemType("") + if req.Category != "" { + category = chaos.SystemType(req.Category) + } + + injection := &model.FaultInjection{ + Name: req.Name, + Source: consts.DatapackSourceManual, + FaultType: chaos.ChaosType(0), + Category: category, + Description: req.Description, + EngineConfig: "", + Groundtruths: groundtruths, + GroundtruthSource: groundtruthSource, + PreDuration: 0, + BenchmarkID: nil, + PedestalID: nil, + State: consts.DatapackBuildSuccess, + Status: consts.CommonEnabled, + } + + err = s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if err := repo.createInjectionRecord(injection); err != nil { + return err + } + + if len(labels) > 0 { + createdLabels, err := label.NewRepository(tx).CreateOrUpdateLabelsFromItems(tx, labels, consts.InjectionCategory) + if err != nil { + return fmt.Errorf("failed to create or update labels: %w", err) + } + + labelIDs := make([]int, 0, len(createdLabels)) + for _, label := range createdLabels { + labelIDs = append(labelIDs, label.ID) + } + + if err := repo.addInjectionLabels(injection.ID, labelIDs); err != nil { + return fmt.Errorf("failed to add injection labels: %w", err) + } + } + return nil + }) + if err != nil { + _ = s.store.RemoveAll(targetDir) + return nil, err + } + + return &UploadDatapackResp{ + ID: injection.ID, + Name: injection.Name, + }, nil +} + +func (s *Service) getReadyDatapack(id int) (*model.FaultInjection, error) { + injection, err := s.repo.loadInjection(id) + if err != nil { + if errors.Is(err, consts.ErrNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: injection id: %d", consts.ErrNotFound, id) + } + return nil, fmt.Errorf("failed to get injection: %w", err) + } + if injection.State < consts.DatapackBuildSuccess { + return nil, fmt.Errorf("datapack %d is not ready", id) + } + return injection, nil +} + +func (s *Service) batchDeleteByIDs(injectionIDs []int) error { + if len(injectionIDs) == 0 { + return nil + } + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + return repo.deleteInjectionsCascade(injectionIDs) + }) +} + +func (s *Service) batchDeleteByLabels(labelItems []dto.LabelItem) error { + if len(labelItems) == 0 { + return nil + } + labelConditions := make([]map[string]string, 0, len(labelItems)) + for _, item := range labelItems { + labelConditions = append(labelConditions, map[string]string{"key": item.Key, "value": item.Value}) + } + injectionIDs, err := s.repo.listInjectionIDsByLabelConditions(labelConditions) + if err != nil { + return fmt.Errorf("failed to list injection ids by labels: %w", err) + } + return s.batchDeleteByIDs(injectionIDs) +} + +func splitLabelCondition(item string) [2]string { + parts := strings.SplitN(item, ":", 2) + if len(parts) == 1 { + return [2]string{parts[0], ""} + } + return [2]string{parts[0], parts[1]} +} diff --git a/src/module/injection/service_test.go b/src/module/injection/service_test.go new file mode 100644 index 00000000..261993fe --- /dev/null +++ b/src/module/injection/service_test.go @@ -0,0 +1,146 @@ +package injection + +import ( + "regexp" + "testing" + "time" + + "aegis/consts" + "aegis/dto" + redis "aegis/infra/redis" + "aegis/testutil" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func newInjectionService(t *testing.T) (*Service, sqlmock.Sqlmock, func()) { + t.Helper() + + addr, cleanupRedis := testutil.StartRedisStub(t) + viper.Set("redis.host", addr) + + sqlDB, mock, err := sqlmock.New() + require.NoError(t, err) + + db, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + require.NoError(t, err) + + return NewService(NewRepository(db), nil, nil, redis.NewGateway(nil)), mock, func() { + cleanupRedis() + _ = sqlDB.Close() + } +} + +func TestServiceSearchNilRequest(t *testing.T) { + service := NewService(nil, nil, nil, nil) + + _, err := service.Search(t.Context(), nil, nil) + + require.Error(t, err) + require.ErrorContains(t, err, "search injection request is nil") +} + +func TestServiceListNoIssuesEmptyLabelsSucceeds(t *testing.T) { + service := NewService(nil, nil, nil, nil) + + resp, err := service.ListNoIssues(t.Context(), &ListInjectionNoIssuesReq{}, nil) + + require.NoError(t, err) + require.Nil(t, resp) +} + +func TestServiceListProjectInjectionsSuccess(t *testing.T) { + service, mock, cleanup := newInjectionService(t) + defer cleanup() + + now := time.Now() + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `projects` WHERE id = ? ORDER BY `projects`.`id` LIMIT ?")). + WithArgs(7, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "description", "team_id", "is_public", "status", "created_at", "updated_at", + }).AddRow(7, "demo-project", "demo", nil, true, consts.CommonEnabled, now, now)) + mock.ExpectQuery("SELECT count\\(\\*\\) FROM `fault_injections` JOIN tasks ON tasks\\.id = fault_injections\\.task_id JOIN traces on traces\\.id = tasks\\.trace_id WHERE traces\\.project_id = \\? AND fault_injections\\.status != \\?"). + WithArgs(7, consts.CommonDeleted). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + mock.ExpectQuery("SELECT `fault_injections`\\.`id`,`fault_injections`\\.`name`.*FROM `fault_injections` JOIN tasks ON tasks\\.id = fault_injections\\.task_id JOIN traces on traces\\.id = tasks\\.trace_id WHERE traces\\.project_id = \\? AND fault_injections\\.status != \\? ORDER BY fault_injections\\.updated_at DESC LIMIT \\?"). + WithArgs(7, consts.CommonDeleted, 20). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "source", "fault_type", "category", "description", "display_config", "engine_config", "groundtruths", "groundtruth_source", "pre_duration", "start_time", "end_time", "benchmark_id", "pedestal_id", "task_id", "state", "status", "created_at", "updated_at", + })) + + resp, err := service.ListProjectInjections(t.Context(), &ListInjectionReq{}, 7) + + require.NoError(t, err) + require.Empty(t, resp.Items) + require.Equal(t, int64(0), resp.Pagination.Total) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestServiceSubmitDatapackBuildingSuccess(t *testing.T) { + addr, cleanupRedis := testutil.StartRedisStub(t) + defer cleanupRedis() + viper.Set("redis.host", addr) + + service, mock, cleanup := newInjectionService(t) + defer cleanup() + + mock.MatchExpectationsInOrder(false) + + now := time.Now() + start := now.Add(-10 * time.Minute) + end := now.Add(-2 * time.Minute) + projectID := 9 + datapackName := "dp-build" + + mock.ExpectQuery("SELECT .* FROM container_versions cv .*"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "name_major", "name_minor", "name_patch", "github_link", "registry", "namespace", "repository", "tag", "command", "usage_count", "container_id", "user_id", "status", "created_at", "updated_at", + }).AddRow(4, "1.0.0", 1, 0, 0, "", "docker.io", "", "bench", "latest", "", 0, 6, 1, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `containers` WHERE `containers`.`id` = ?")). + WithArgs(6). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "type", "readme", "is_public", "status", "created_at", "updated_at", + }).AddRow(6, "bench", consts.ContainerTypeBenchmark, "", true, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `fault_injections` WHERE name = ? AND status != ? ORDER BY `fault_injections`.`id` LIMIT ?")). + WithArgs(datapackName, consts.CommonDeleted, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "source", "fault_type", "category", "description", "display_config", "engine_config", "groundtruths", "groundtruth_source", "pre_duration", "start_time", "end_time", "benchmark_id", "pedestal_id", "task_id", "state", "status", "created_at", "updated_at", + }).AddRow(11, datapackName, consts.DatapackSourceInjection, 0, "ts", "", nil, "{}", "[]", "auto", 5, start, end, nil, nil, nil, consts.DatapackInjectSuccess, consts.CommonEnabled, now, now)) + mock.ExpectQuery("SELECT .* FROM `fault_injection_labels` .*"). + WillReturnRows(sqlmock.NewRows([]string{"fault_injection_id", "label_id"})) + mock.ExpectQuery("SELECT .* FROM `parameter_configs` JOIN container_version_env_vars .*"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "config_key", "type", "category", "value_type", "description", "default_value", "template_string", "required", "overridable", + })) + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `traces`")). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `tasks`")). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + resp, err := service.SubmitDatapackBuilding(t.Context(), &SubmitDatapackBuildingReq{ + Specs: []BuildingSpec{ + { + Benchmark: dto.ContainerSpec{ + ContainerRef: dto.ContainerRef{Name: "bench", Version: "1.0.0"}, + }, + Datapack: &datapackName, + }, + }, + }, "group-build", 1, &projectID) + + require.NoError(t, err) + require.Equal(t, "group-build", resp.GroupID) + require.Len(t, resp.Items, 1) + require.NotEmpty(t, resp.Items[0].TaskID) + require.NotEmpty(t, resp.Items[0].TraceID) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/src/service/producer/spec_convert.go b/src/module/injection/spec_convert.go similarity index 59% rename from src/service/producer/spec_convert.go rename to src/module/injection/spec_convert.go index 47f779ad..dcd5467d 100644 --- a/src/service/producer/spec_convert.go +++ b/src/module/injection/spec_convert.go @@ -1,4 +1,4 @@ -package producer +package injection import ( "encoding/json" @@ -9,8 +9,6 @@ import ( "strings" "time" - "aegis/dto" - chaos "github.com/OperationsPAI/chaos-experiment/handler" ) @@ -27,13 +25,12 @@ func init() { } // FriendlySpecToNode converts a human-readable FriendlyFaultSpec into a chaos.Node tree -// that is compatible with the existing parseBatchInjectionSpecs pipeline. -func FriendlySpecToNode(spec *dto.FriendlyFaultSpec) (chaos.Node, error) { +// compatible with parseBatchInjectionSpecs. +func FriendlySpecToNode(spec *FriendlyFaultSpec) (chaos.Node, error) { if spec.Type == "" { return chaos.Node{}, fmt.Errorf("fault type is required") } - // Resolve fault type name to ChaosType index typeIdx, ok := chaosTypeNameToIndex[spec.Type] if !ok { typeIdx, ok = chaosTypeNameToIndex[strings.ToLower(spec.Type)] @@ -46,36 +43,28 @@ func FriendlySpecToNode(spec *dto.FriendlyFaultSpec) (chaos.Node, error) { } } - // Parse duration to minutes (the chaos library uses integer minutes) durationMinutes, err := parseDurationToMinutes(spec.Duration) if err != nil { return chaos.Node{}, fmt.Errorf("invalid duration %q: %w", spec.Duration, err) } - // Resolve namespace string to its index in chaos.NamespacePrefixs namespaceIdx, err := resolveNamespaceIndex(spec.Namespace) if err != nil { return chaos.Node{}, fmt.Errorf("failed to resolve namespace %q: %w", spec.Namespace, err) } - // Resolve target to a numeric index. - // The target field maps to the 3rd field (index 2) of the spec struct, - // which is ContainerIdx, AppIdx, etc. depending on the fault type. - // If target is a numeric string, use it directly. Otherwise default to 0. targetIdx, err := resolveTargetIndex(spec.Target) if err != nil { return chaos.Node{}, fmt.Errorf("failed to resolve target %q: %w", spec.Target, err) } - // Build the inner children map — these map to spec struct field indices. // Field 0 = Duration, Field 1 = Namespace, Field 2 = ContainerIdx/AppIdx/etc. specChildren := map[string]*chaos.Node{ - "0": {Value: durationMinutes}, // Duration - "1": {Value: namespaceIdx}, // Namespace - "2": {Value: targetIdx}, // ContainerIdx / AppIdx / etc. + "0": {Value: durationMinutes}, + "1": {Value: namespaceIdx}, + "2": {Value: targetIdx}, } - // Map additional params to their corresponding field indices (fields 3+) if len(spec.Params) > 0 { specType := getSpecType(typeIdx) if specType != nil { @@ -85,8 +74,6 @@ func FriendlySpecToNode(spec *dto.FriendlyFaultSpec) (chaos.Node, error) { } } - // Build the chaos.Node tree structure expected by parseBatchInjectionSpecs: - // {Value: , Children: {"": {Children: {"0": ..., "1": ..., "2": ...}}}} typeIdxStr := strconv.Itoa(typeIdx) node := chaos.Node{ Value: typeIdx, @@ -100,14 +87,12 @@ func FriendlySpecToNode(spec *dto.FriendlyFaultSpec) (chaos.Node, error) { return node, nil } -// parseDurationToMinutes converts a duration string (e.g., "60s", "5m", "1h") to integer minutes. -// Also accepts plain integer strings interpreted as minutes. +// parseDurationToMinutes converts "60s" / "5m" / "1h" / plain integer to minutes. func parseDurationToMinutes(duration string) (int, error) { if duration == "" { return 0, fmt.Errorf("duration is required") } - // Try Go duration format first (e.g., "60s", "5m") d, err := time.ParseDuration(duration) if err == nil { minutes := int(math.Ceil(d.Minutes())) @@ -117,7 +102,6 @@ func parseDurationToMinutes(duration string) (int, error) { return minutes, nil } - // Fall back to plain integer (interpreted as minutes) if mins, err2 := strconv.Atoi(duration); err2 == nil && mins > 0 { return mins, nil } @@ -125,50 +109,34 @@ func parseDurationToMinutes(duration string) (int, error) { return 0, fmt.Errorf("cannot parse duration %q: expected Go duration (e.g., \"60s\", \"5m\") or integer minutes", duration) } -// resolveNamespaceIndex maps a namespace prefix string to its index in the registered system list. +// resolveNamespaceIndex accepts a namespace field from FriendlyFaultSpec. +// Under chaos-experiment v1.0.1+, namespace resolution moved to per-system +// registrations (GetNamespaceByIndex), and the old package-level +// chaos.NamespacePrefixs slice no longer exists. The backend's downstream +// pipeline owns name→index resolution, so here we accept numeric strings +// directly and fall back to 0 for names (best-effort, matching +// resolveTargetIndex behavior). func resolveNamespaceIndex(namespace string) (int, error) { if namespace == "" { return 0, nil } - - systems := chaos.GetAllSystemTypes() - - // Exact match - for idx, system := range systems { - if system.String() == namespace { - return idx, nil - } - } - - // Prefix-based match (e.g., "ts0" matches "ts") - for idx, system := range systems { - prefix := system.String() - if strings.HasPrefix(prefix, namespace) || strings.HasPrefix(namespace, prefix) { - return idx, nil - } + if idx, err := strconv.Atoi(namespace); err == nil { + return idx, nil } - - return 0, fmt.Errorf("namespace %q not found in registered systems: %v", namespace, systems) + return 0, nil } -// resolveTargetIndex resolves the target field to a numeric index. -// If target is numeric, parse it directly. If it's a name string, return 0 with -// a note that the downstream pipeline will validate against the actual cluster state. +// resolveTargetIndex turns numeric strings into indices; non-numeric names default to 0. +// Full name→index resolution requires K8s state (internal to chaos-experiment). func resolveTargetIndex(target string) (int, error) { if target == "" { return 0, nil } - // Try numeric index first if idx, err := strconv.Atoi(target); err == nil { return idx, nil } - // Non-numeric target: the name-to-index resolution requires K8s cluster state - // (via resourcelookup, which is internal to chaos-experiment). - // Return 0 as default — users should use the `aegisctl inject metadata` command - // to look up numeric indices for named targets before submission. - // TODO: When chaos-experiment exposes public lookup APIs, resolve names here. return 0, nil } @@ -181,20 +149,17 @@ func getSpecType(typeIdx int) any { return nil } -// mapParamsToFieldIndices maps user-provided param names to spec struct field indices. -// Fields 0-2 are already populated (Duration, Namespace, ContainerIdx/AppIdx). -// This handles fields 3+ (e.g., CPULoad, CPUWorker for CPUStressChaosSpec). +// mapParamsToFieldIndices maps param names to spec struct field indices (3+). +// Fields 0-2 are already populated (Duration, Namespace, Target). func mapParamsToFieldIndices(params map[string]any, specType any, children map[string]*chaos.Node) error { rt := reflect.TypeOf(specType) if rt.Kind() == reflect.Ptr { rt = rt.Elem() } - // Build name → field index map for fields 3+ nameToIdx := make(map[string]int, rt.NumField()) for i := 3; i < rt.NumField(); i++ { field := rt.Field(i) - // Skip the NamespaceTarget field — it's set internally if field.Name == "NamespaceTarget" { continue } @@ -209,7 +174,6 @@ func mapParamsToFieldIndices(params map[string]any, specType any, children map[s idx, ok = nameToIdx[strings.ToLower(key)] } if !ok { - // Unknown params are silently skipped to allow forward compatibility continue } @@ -224,7 +188,8 @@ func mapParamsToFieldIndices(params map[string]any, specType any, children map[s return nil } -// toSnakeCase converts CamelCase to snake_case (e.g., "CPULoad" → "cpu_load"). +// toSnakeCase converts CamelCase → snake_case ("CPULoad" → "c_p_u_load"). +// Acceptable for key lookup (we also try the lowercase and exact forms). func toSnakeCase(s string) string { var result strings.Builder for i, r := range s { @@ -240,7 +205,7 @@ func toSnakeCase(s string) string { return result.String() } -// toInt converts various numeric types to int. +// toInt converts numeric types to int. func toInt(v any) (int, error) { switch val := v.(type) { case int: diff --git a/src/service/producer/spec_convert_test.go b/src/module/injection/spec_convert_test.go similarity index 97% rename from src/service/producer/spec_convert_test.go rename to src/module/injection/spec_convert_test.go index 150199e1..d9da645a 100644 --- a/src/service/producer/spec_convert_test.go +++ b/src/module/injection/spec_convert_test.go @@ -1,9 +1,8 @@ -package producer +package injection import ( "testing" - "aegis/dto" chaos "github.com/OperationsPAI/chaos-experiment/handler" ) @@ -20,7 +19,7 @@ func setupNamespacePrefixes(t *testing.T) (string, int) { func TestFriendlySpecToNode_CPUStress(t *testing.T) { namespace, namespaceIdx := setupNamespacePrefixes(t) - spec := &dto.FriendlyFaultSpec{ + spec := &FriendlyFaultSpec{ Type: "CPUStress", Namespace: namespace, Target: "0", // container index as string @@ -102,7 +101,7 @@ func TestFriendlySpecToNode_CPUStress(t *testing.T) { func TestFriendlySpecToNode_InvalidFaultType(t *testing.T) { setupNamespacePrefixes(t) - spec := &dto.FriendlyFaultSpec{ + spec := &FriendlyFaultSpec{ Type: "NonExistentChaosType", Duration: "5m", } @@ -116,7 +115,7 @@ func TestFriendlySpecToNode_InvalidFaultType(t *testing.T) { func TestFriendlySpecToNode_EmptyFaultType(t *testing.T) { setupNamespacePrefixes(t) - spec := &dto.FriendlyFaultSpec{ + spec := &FriendlyFaultSpec{ Type: "", Duration: "5m", } @@ -130,7 +129,7 @@ func TestFriendlySpecToNode_EmptyFaultType(t *testing.T) { func TestFriendlySpecToNode_InvalidDuration(t *testing.T) { setupNamespacePrefixes(t) - spec := &dto.FriendlyFaultSpec{ + spec := &FriendlyFaultSpec{ Type: "CPUStress", Duration: "not-a-duration", } @@ -144,7 +143,7 @@ func TestFriendlySpecToNode_InvalidDuration(t *testing.T) { func TestFriendlySpecToNode_MemoryStress(t *testing.T) { namespace, _ := setupNamespacePrefixes(t) - spec := &dto.FriendlyFaultSpec{ + spec := &FriendlyFaultSpec{ Type: "MemoryStress", Namespace: namespace, Target: "0", @@ -202,7 +201,7 @@ func TestFriendlySpecToNode_DurationCeiling(t *testing.T) { namespace, _ := setupNamespacePrefixes(t) // 90s should become 2 minutes (ceiling) - spec := &dto.FriendlyFaultSpec{ + spec := &FriendlyFaultSpec{ Type: "CPUStress", Namespace: namespace, Target: "0", @@ -239,7 +238,7 @@ func TestFriendlySpecToNode_ParamsMapping(t *testing.T) { // The local toSnakeCase produces "c_p_u_load" for "CPULoad" (not "cpu_load"), // but mapParamsToFieldIndices also accepts exact field names and lowercase field names. // JSON numbers are float64, so pass float64 values. - spec := &dto.FriendlyFaultSpec{ + spec := &FriendlyFaultSpec{ Type: "CPUStress", Namespace: namespace, Target: "0", @@ -284,7 +283,7 @@ func TestFriendlySpecToNode_ParamsMapping(t *testing.T) { func TestFriendlySpecToNode_EmptyNamespaceDefaultsToZero(t *testing.T) { setupNamespacePrefixes(t) - spec := &dto.FriendlyFaultSpec{ + spec := &FriendlyFaultSpec{ Type: "CPUStress", Namespace: "", // empty namespace should default to index 0 Target: "0", @@ -313,7 +312,7 @@ func TestFriendlySpecToNode_EmptyNamespaceDefaultsToZero(t *testing.T) { func TestFriendlySpecToNode_EmptyTargetDefaultsToZero(t *testing.T) { namespace, _ := setupNamespacePrefixes(t) - spec := &dto.FriendlyFaultSpec{ + spec := &FriendlyFaultSpec{ Type: "CPUStress", Namespace: namespace, Target: "", // empty target should default to index 0 @@ -343,7 +342,7 @@ func TestFriendlySpecToNode_CaseInsensitiveType(t *testing.T) { namespace, _ := setupNamespacePrefixes(t) // The init() populates lowercase keys too, so "cpustress" should work. - spec := &dto.FriendlyFaultSpec{ + spec := &FriendlyFaultSpec{ Type: "cpustress", Namespace: namespace, Duration: "5m", @@ -363,7 +362,7 @@ func TestFriendlySpecToNode_NoParams(t *testing.T) { namespace, _ := setupNamespacePrefixes(t) // A spec without Params should still produce correct Duration/Namespace/Target nodes - spec := &dto.FriendlyFaultSpec{ + spec := &FriendlyFaultSpec{ Type: "CPUStress", Namespace: namespace, Target: "0", @@ -399,7 +398,7 @@ func TestFriendlySpecToNode_NoParams(t *testing.T) { func TestFriendlySpecToNode_NodeTreeStructure(t *testing.T) { namespace, _ := setupNamespacePrefixes(t) - spec := &dto.FriendlyFaultSpec{ + spec := &FriendlyFaultSpec{ Type: "CPUStress", Namespace: namespace, Duration: "5m", @@ -430,7 +429,7 @@ func TestFriendlySpecToNode_LowercaseParamNames(t *testing.T) { namespace, _ := setupNamespacePrefixes(t) // Lowercase field names (e.g., "cpuload") are also accepted by mapParamsToFieldIndices. - spec := &dto.FriendlyFaultSpec{ + spec := &FriendlyFaultSpec{ Type: "CPUStress", Namespace: namespace, Target: "0", @@ -466,7 +465,7 @@ func TestFriendlySpecToNode_SnakeCaseParamsMismatch(t *testing.T) { // The local toSnakeCase("CPULoad") = "c_p_u_load" (not "cpu_load"). // So "cpu_load" does NOT match and the param is silently skipped. // This documents the known limitation. - spec := &dto.FriendlyFaultSpec{ + spec := &FriendlyFaultSpec{ Type: "CPUStress", Namespace: namespace, Target: "0", @@ -666,7 +665,7 @@ func TestToInt(t *testing.T) { func TestFriendlySpecToNode_ParseBatchKeyInvariant(t *testing.T) { namespace, _ := setupNamespacePrefixes(t) - cases := []dto.FriendlyFaultSpec{ + cases := []FriendlyFaultSpec{ {Type: "CPUStress", Namespace: namespace, Target: "0", Duration: "1m"}, {Type: "NetworkDelay", Namespace: namespace, Target: "0", Duration: "30s"}, {Type: "PodFailure", Namespace: namespace, Target: "0", Duration: "1m"}, diff --git a/src/module/injection/submit.go b/src/module/injection/submit.go new file mode 100644 index 00000000..4367baae --- /dev/null +++ b/src/module/injection/submit.go @@ -0,0 +1,279 @@ +package injection + +import ( + "aegis/consts" + "aegis/dto" + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + chaos "github.com/OperationsPAI/chaos-experiment/handler" + "github.com/OperationsPAI/chaos-experiment/pkg/guidedcli" + "github.com/sirupsen/logrus" +) + +type injectionProcessItem struct { + index int + faultDuration int + nodes []chaos.Node // legacy Node-DSL batch + // guidedConfigs is populated when the submission came through the guided + // CLI path. Mutually exclusive with nodes: exactly one is set per item. + guidedConfigs []guidedcli.GuidedConfig + executeTime time.Time +} + +func parseBatchInjectionSpecs(pedestal string, batchIndex int, specs []chaos.Node) (*injectionProcessItem, string, error) { + if len(specs) == 0 { + return nil, "", fmt.Errorf("empty fault injection batch at index %d", batchIndex) + } + + maxDuration := 0 + nodes := make([]chaos.Node, 0, len(specs)) + for idx, spec := range specs { + childNode, exists := spec.Children[strconv.Itoa(spec.Value)] + if !exists { + return nil, "", fmt.Errorf("failed to find key %d in the children at index %d", spec.Value, idx) + } + if len(childNode.Children) < 3 { + return nil, "", fmt.Errorf("no child nodes found for fault spec at index %d", idx) + } + + faultDuration := childNode.Children[consts.DurationNodeKey].Value + if faultDuration > maxDuration { + maxDuration = faultDuration + } + + systemIdx := childNode.Children[consts.SystemNodeKey].Value + system := chaos.GetAllSystemTypes()[systemIdx] + if pedestal != system.String() { + return nil, "", fmt.Errorf("mismatched system type %s for pedestal %s at index %d", system.String(), pedestal, idx) + } + + nodes = append(nodes, spec) + } + + uniqueServices := make(map[string]int, len(nodes)) + var duplicateServiceWarnings []string + ctx := context.Background() + for idx, node := range nodes { + conf, err := chaos.NodeToStruct[chaos.InjectionConf](ctx, &node) + if err != nil { + return nil, "", fmt.Errorf("failed to convert node to InjectionConf at index %d: %w", idx, err) + } + + groundtruth, err := conf.GetGroundtruth(ctx) + if err != nil { + return nil, "", fmt.Errorf("failed to get groundtruth from InjectionConf at index %d: %w", idx, err) + } + + for _, service := range groundtruth.Service { + if service == "" { + continue + } + if oldIdx, exists := uniqueServices[service]; exists { + duplicateServiceWarnings = append(duplicateServiceWarnings, fmt.Sprintf("service '%s' at positions %d and %d", service, oldIdx, idx)) + continue + } + uniqueServices[service] = idx + } + } + + nodes = sortNodes(nodes) + + var warning string + if len(duplicateServiceWarnings) > 0 { + warning = fmt.Sprintf("Batch %d contains duplicate service injections: %s", batchIndex, strings.Join(duplicateServiceWarnings, "; ")) + } + + return &injectionProcessItem{ + index: batchIndex, + faultDuration: maxDuration, + nodes: nodes, + }, warning, nil +} + +func flattenYAMLToParameters(data map[string]any, prefix string) []dto.ParameterSpec { + var params []dto.ParameterSpec + for key, value := range data { + fullKey := key + if prefix != "" { + fullKey = prefix + "." + key + } + + switch v := value.(type) { + case map[string]any: + params = append(params, flattenYAMLToParameters(v, fullKey)...) + case []any: + jsonBytes, err := json.Marshal(v) + if err != nil { + logrus.Warnf("Failed to marshal array for key %s: %v", fullKey, err) + continue + } + params = append(params, dto.ParameterSpec{Key: fullKey, Value: string(jsonBytes)}) + default: + params = append(params, dto.ParameterSpec{Key: fullKey, Value: v}) + } + } + return params +} + +func (s *Service) removeDuplicated(items []injectionProcessItem) ([]injectionProcessItem, []int, []int, error) { + engineConfigStrs := make([]string, len(items)) + for i, item := range items { + var payload any + switch { + case len(item.guidedConfigs) > 0: + payload = item.guidedConfigs + case len(item.nodes) > 0: + payload = item.nodes + default: + continue + } + + b, err := json.Marshal(payload) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to marshal engine config at batch index %d: %w", i, err) + } + engineConfigStrs[i] = string(b) + } + + orderedUniqueIdx := make([]int, 0, len(engineConfigStrs)) + seen := make(map[string]struct{}, len(engineConfigStrs)) + duplicatedInRequest := make([]int, 0) + for i, key := range engineConfigStrs { + if key == "" { + orderedUniqueIdx = append(orderedUniqueIdx, i) + continue + } + if _, ok := seen[key]; ok { + duplicatedInRequest = append(duplicatedInRequest, items[i].index) + continue + } + seen[key] = struct{}{} + orderedUniqueIdx = append(orderedUniqueIdx, i) + } + + keys := make([]string, 0, len(seen)) + for k := range seen { + keys = append(keys, k) + } + + existed := make(map[string]struct{}) + for start := 0; start < len(keys); start += 100 { + end := min(start+100, len(keys)) + existing, err := s.repo.listExistingEngineConfigs(keys[start:end]) + if err != nil { + return nil, nil, nil, err + } + for _, v := range existing { + existed[v] = struct{}{} + } + } + + out := make([]injectionProcessItem, 0, len(orderedUniqueIdx)) + alreadyExisted := make([]int, 0) + for _, idx := range orderedUniqueIdx { + key := engineConfigStrs[idx] + if key != "" { + if _, ok := existed[key]; ok { + alreadyExisted = append(alreadyExisted, items[idx].index) + continue + } + } + + items[idx].executeTime = time.Now().Add(time.Duration(idx*2) * time.Second) + out = append(out, items[idx]) + } + + return out, duplicatedInRequest, alreadyExisted, nil +} + +func sortNodes(nodes []chaos.Node) []chaos.Node { + if len(nodes) <= 1 { + return nodes + } + + sortedNodes := make([]chaos.Node, len(nodes)) + copy(sortedNodes, nodes) + for i := 0; i < len(sortedNodes)-1; i++ { + for j := i + 1; j < len(sortedNodes); j++ { + if sortedNodes[i].Value > sortedNodes[j].Value { + sortedNodes[i], sortedNodes[j] = sortedNodes[j], sortedNodes[i] + continue + } + if sortedNodes[i].Value == sortedNodes[j].Value { + iJSON, _ := json.Marshal(sortedNodes[i]) + jJSON, _ := json.Marshal(sortedNodes[j]) + if string(iJSON) > string(jJSON) { + sortedNodes[i], sortedNodes[j] = sortedNodes[j], sortedNodes[i] + } + } + } + } + return sortedNodes +} + +// parseBatchGuidedSpecs parses a single batch of GuidedConfig specs for +// parallel execution. Each GuidedConfig is resolved to an InjectionConf via +// guidedcli.BuildInjection solely to compute duration, system-type sanity +// check, and groundtruth-service dedup warnings. The returned item carries +// the original GuidedConfigs; the actual BuildInjection call at execute-time +// lives in the consumer. +func parseBatchGuidedSpecs(ctx context.Context, pedestal string, batchIndex int, configs []guidedcli.GuidedConfig) (*injectionProcessItem, string, error) { + if len(configs) == 0 { + return nil, "", fmt.Errorf("empty guided fault batch at index %d", batchIndex) + } + + maxDuration := 0 + uniqueServices := make(map[string]int, len(configs)) + var duplicateServiceWarnings []string + + for idx, cfg := range configs { + conf, systemType, err := guidedcli.BuildInjection(ctx, cfg) + if err != nil { + return nil, "", fmt.Errorf("failed to build injection from guided config at index %d: %w", idx, err) + } + if pedestal != systemType.String() { + return nil, "", fmt.Errorf("mismatched system type %s for pedestal %s at index %d", systemType.String(), pedestal, idx) + } + + duration := 0 + if cfg.Duration != nil { + duration = *cfg.Duration + } + if duration > maxDuration { + maxDuration = duration + } + + groundtruth, err := conf.GetGroundtruth(ctx) + if err != nil { + return nil, "", fmt.Errorf("failed to get groundtruth from guided config at index %d: %w", idx, err) + } + for _, service := range groundtruth.Service { + if service == "" { + continue + } + if oldIdx, exists := uniqueServices[service]; exists { + duplicateServiceWarnings = append(duplicateServiceWarnings, + fmt.Sprintf("service '%s' at positions %d and %d", service, oldIdx, idx)) + continue + } + uniqueServices[service] = idx + } + } + + var warning string + if len(duplicateServiceWarnings) > 0 { + warning = fmt.Sprintf("Batch %d contains duplicate service injections: %s", + batchIndex, strings.Join(duplicateServiceWarnings, "; ")) + } + + return &injectionProcessItem{ + index: batchIndex, + faultDuration: maxDuration, + guidedConfigs: configs, + }, warning, nil +} diff --git a/src/dto/request.go b/src/module/injection/time_range.go similarity index 56% rename from src/dto/request.go rename to src/module/injection/time_range.go index 02592b06..c3e66818 100644 --- a/src/dto/request.go +++ b/src/module/injection/time_range.go @@ -1,4 +1,4 @@ -package dto +package injection import ( "fmt" @@ -23,36 +23,28 @@ type TimeFilterOptions struct { } func (req *TimeRangeQuery) Convert() (*TimeFilterOptions, error) { - opts := &TimeFilterOptions{ - Lookback: 0, - UseCustomRange: false, - CustomStartTime: time.Time{}, - CustomEndTime: time.Time{}, - } - + opts := &TimeFilterOptions{} if req.Lookback != "custom" { duration, err := parseLookbackDuration(req.Lookback) if err != nil { return nil, fmt.Errorf("invalid lookback value: %v", err) } - opts.Lookback = duration - } else { - customStart, err := time.Parse(time.RFC3339, req.CustomStartStr) - if err != nil { - return nil, fmt.Errorf("invalid custom start time: %v", err) - } - - customEnd, err := time.Parse(time.RFC3339, req.CustomEndStr) - if err != nil { - return nil, fmt.Errorf("invalid custom end time: %v", err) - } + return opts, nil + } - opts.UseCustomRange = true - opts.CustomStartTime = customStart - opts.CustomEndTime = customEnd + customStart, err := time.Parse(time.RFC3339, req.CustomStartStr) + if err != nil { + return nil, fmt.Errorf("invalid custom start time: %v", err) + } + customEnd, err := time.Parse(time.RFC3339, req.CustomEndStr) + if err != nil { + return nil, fmt.Errorf("invalid custom end time: %v", err) } + opts.UseCustomRange = true + opts.CustomStartTime = customStart + opts.CustomEndTime = customEnd return opts, nil } @@ -61,44 +53,36 @@ func (req *TimeRangeQuery) Validate() error { if _, err := parseLookbackDuration(req.Lookback); err != nil { return fmt.Errorf("invalid lookback value: %s", req.Lookback) } - } else { - if req.CustomStartStr == "" || req.CustomEndStr == "" { - return fmt.Errorf("custom start and end times are required for custom lookback") - } - - startTime, err := time.Parse(time.RFC3339, req.CustomStartStr) - if err != nil { - return fmt.Errorf("invalid custom start time: %v", err) - } - - endTime, err := time.Parse(time.RFC3339, req.CustomEndStr) - if err != nil { - return fmt.Errorf("invalid custom end time: %v", err) - } + return nil + } - if startTime.After(endTime) { - return fmt.Errorf("custom start time cannot be after custom end time") - } + if req.CustomStartStr == "" || req.CustomEndStr == "" { + return fmt.Errorf("custom start and end times are required for custom lookback") } + startTime, err := time.Parse(time.RFC3339, req.CustomStartStr) + if err != nil { + return fmt.Errorf("invalid custom start time: %v", err) + } + endTime, err := time.Parse(time.RFC3339, req.CustomEndStr) + if err != nil { + return fmt.Errorf("invalid custom end time: %v", err) + } + if startTime.After(endTime) { + return fmt.Errorf("custom start time cannot be after custom end time") + } return nil } func (opts *TimeFilterOptions) GetTimeRange() (time.Time, time.Time) { now := time.Now() - var startTime, endTime time.Time if opts.UseCustomRange { - startTime = opts.CustomStartTime - endTime = opts.CustomEndTime - } else if opts.Lookback != 0 { - endTime = now - startTime = now.Add(-opts.Lookback) - } else { - endTime = now - startTime = time.Time{} + return opts.CustomStartTime, opts.CustomEndTime } - - return startTime, endTime + if opts.Lookback != 0 { + return now.Add(-opts.Lookback), now + } + return time.Time{}, now } func (opts *TimeFilterOptions) AddTimeFilter(query *gorm.DB, column string) *gorm.DB { @@ -106,17 +90,13 @@ func (opts *TimeFilterOptions) AddTimeFilter(query *gorm.DB, column string) *gor return query.Where(fmt.Sprintf("%s >= ? AND %s <= ?", column, column), startTime, endTime) } -// parseLookbackDuration parses a duration string with format like "5m", "2h", "1d" -// Supports: m (minutes), h (hours), d (days) func parseLookbackDuration(lookback string) (time.Duration, error) { if lookback == "" { return 0, nil } - // Use regex to match patterns like "5m", "2h", "1d" re := regexp.MustCompile(`^(\d+)([mhd])$`) matches := re.FindStringSubmatch(lookback) - if len(matches) != 3 { return 0, fmt.Errorf("invalid duration format: %s (expected format: 5m, 2h, 1d)", lookback) } @@ -125,13 +105,11 @@ func parseLookbackDuration(lookback string) (time.Duration, error) { if err != nil { return 0, fmt.Errorf("invalid duration value: %s", matches[1]) } - if value <= 0 { return 0, fmt.Errorf("duration value must be a positive integer: %s", matches[1]) } - unit := matches[2] - switch unit { + switch matches[2] { case "m": return time.Duration(value) * time.Minute, nil case "h": @@ -139,6 +117,6 @@ func parseLookbackDuration(lookback string) (time.Duration, error) { case "d": return time.Duration(value) * 24 * time.Hour, nil default: - return 0, fmt.Errorf("invalid duration unit: %s (supported: m, h, d)", unit) + return 0, fmt.Errorf("invalid duration unit: %s (supported: m, h, d)", matches[2]) } } diff --git a/src/module/label/api_types.go b/src/module/label/api_types.go new file mode 100644 index 00000000..5a66f4fa --- /dev/null +++ b/src/module/label/api_types.go @@ -0,0 +1,219 @@ +package label + +import ( + "fmt" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/model" + "aegis/utils" +) + +// BatchDeleteLabelReq represents the request to batch delete labels. +type BatchDeleteLabelReq struct { + IDs []int `json:"ids" binding:"omitempty"` +} + +func (req *BatchDeleteLabelReq) Validate() error { + if len(req.IDs) == 0 { + return fmt.Errorf("ids cannot be empty") + } + for i, id := range req.IDs { + if id <= 0 { + return fmt.Errorf("invalid id at index %d: %d", i, id) + } + } + return nil +} + +// CreateLabelReq represents label creation request. +type CreateLabelReq struct { + Key string `json:"key" binding:"required"` + Value string `json:"value" binding:"required"` + Category consts.LabelCategory `json:"category" bindging:"required"` + Description string `json:"description" binding:"omitempty"` + Color *string `json:"color" binding:"omitempty"` +} + +func (req *CreateLabelReq) Validate() error { + if err := validateKeyAndValue(req.Key, req.Value); err != nil { + return err + } + if err := validateLabelCategory(req.Category); err != nil { + return err + } + if err := validateColor(req.Color); err != nil { + return err + } + return nil +} + +func (req *CreateLabelReq) ConvertToLabel() *model.Label { + return &model.Label{ + Key: req.Key, + Value: req.Value, + Category: req.Category, + Description: req.Description, + Color: utils.GetStringValue(req.Color, "#1890ff"), + IsSystem: false, + Usage: consts.DefaultLabelUsage, + } +} + +// ListLabelReq is the list-label query contract for the label module. +type ListLabelReq struct { + dto.PaginationReq + + Key string `form:"key" binding:"omitempty"` + Value string `form:"value" binding:"omitempty"` + Category *consts.LabelCategory `form:"category" binding:"omitempty"` + IsSystem *bool `form:"is_system" binding:"omitempty"` + Status *consts.StatusType `form:"status" binding:"omitempty"` +} + +type ListLabelFilters struct { + Key string + Value string + Category *consts.LabelCategory + IsSystem *bool + Status *consts.StatusType +} + +func (req *ListLabelReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + if err := validateKeyAndValue(req.Key, req.Value); err != nil { + return err + } + if req.Category != nil { + if err := validateLabelCategory(*req.Category); err != nil { + return err + } + } + return validateStatus(req.Status, false) +} + +func (req *ListLabelReq) ToFilterOptions() *ListLabelFilters { + return &ListLabelFilters{ + Key: req.Key, + Value: req.Value, + Category: req.Category, + IsSystem: req.IsSystem, + Status: req.Status, + } +} + +// UpdateLabelReq represents label update request. +type UpdateLabelReq struct { + Description *string `json:"description" binding:"omitempty"` + Color *string `json:"color" binding:"omitempty"` + Status *consts.StatusType `json:"status,omitempty"` +} + +func (req *UpdateLabelReq) Validate() error { + if err := validateColor(req.Color); err != nil { + return err + } + return validateStatus(req.Status, true) +} + +func (req *UpdateLabelReq) PatchLabelModel(target *model.Label) { + if req.Description != nil { + target.Description = *req.Description + } + if req.Color != nil { + target.Color = *req.Color + } + if req.Status != nil { + target.Status = *req.Status + } +} + +// LabelResp represents a label response. +type LabelResp struct { + ID int `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + Category string `json:"category"` + Color string `json:"color"` + Usage int `json:"usage"` + IsSystem bool `json:"is_system"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func NewLabelResp(label *model.Label) *LabelResp { + return &LabelResp{ + ID: label.ID, + Key: label.Key, + Value: label.Value, + Category: consts.GetLabelCategoryName(label.Category), + Color: label.Color, + Usage: label.Usage, + IsSystem: label.IsSystem, + Status: consts.GetStatusTypeName(label.Status), + CreatedAt: label.CreatedAt, + UpdatedAt: label.UpdatedAt, + } +} + +// LabelDetailResp represents a detailed label response. +type LabelDetailResp struct { + LabelResp + + Description string `json:"description"` +} + +func NewLabelDetailResp(label *model.Label) *LabelDetailResp { + return &LabelDetailResp{ + LabelResp: *NewLabelResp(label), + Description: label.Description, + } +} + +func validateColor(color *string) error { + if color == nil { + return nil + } + if !utils.IsValidHexColor(*color) { + return fmt.Errorf("invalid color format: %s", *color) + } + return nil +} + +func validateKeyAndValue(key, value string) error { + if key == "" && value == "" { + return nil + } + if key == "" { + return fmt.Errorf("label key cannot be empty when value is provided") + } + if value == "" { + return fmt.Errorf("label value cannot be empty when key is provided") + } + return nil +} + +func validateLabelCategory(category consts.LabelCategory) error { + if _, exists := consts.ValidLabelCategories[category]; !exists { + return fmt.Errorf("invalid label category: %d", category) + } + return nil +} + +func validateStatus(statusPtr *consts.StatusType, isMutation bool) error { + if statusPtr == nil { + return nil + } + status := *statusPtr + if _, exists := consts.ValidStatuses[status]; !exists { + return fmt.Errorf("invalid status value: %d", status) + } + if isMutation && status == consts.CommonDeleted { + return fmt.Errorf("status value cannot be set to deleted (%d) directly through this update/create operation", consts.CommonDeleted) + } + return nil +} diff --git a/src/module/label/core.go b/src/module/label/core.go new file mode 100644 index 00000000..8f96bad6 --- /dev/null +++ b/src/module/label/core.go @@ -0,0 +1,102 @@ +package label + +import ( + "aegis/consts" + "aegis/dto" + "aegis/model" + "aegis/utils" + "errors" + "fmt" + "sort" + + "gorm.io/gorm" +) + +func (r *Repository) CreateLabelCore(db *gorm.DB, label *model.Label) (*model.Label, error) { + query := r.useDB(db).Where("label_key = ? AND label_value = ?", label.Key, label.Value). + Where("status != ?", consts.CommonDeleted) + + var existingLabel model.Label + err := query.First(&existingLabel).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("failed to check existing label: %w", err) + } + + if errors.Is(err, gorm.ErrRecordNotFound) { + if err := r.useDB(db).Omit(labelKeyOmitFields).Create(label).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return nil, fmt.Errorf("%w: label with key %s and value %s already exists", consts.ErrAlreadyExists, label.Key, label.Value) + } + return nil, fmt.Errorf("failed to create label: %w", err) + } + return label, nil + } + + existingLabel.Category = label.Category + existingLabel.Description = label.Description + existingLabel.Color = label.Color + existingLabel.Status = consts.CommonEnabled + if err := r.useDB(db).Omit(labelKeyOmitFields).Save(&existingLabel).Error; err != nil { + return nil, fmt.Errorf("failed to update existing label: %w", err) + } + return &existingLabel, nil +} + +func (r *Repository) CreateOrUpdateLabelsFromItems(db *gorm.DB, labelItems []dto.LabelItem, category consts.LabelCategory) ([]model.Label, error) { + if len(labelItems) == 0 { + return []model.Label{}, nil + } + + repo := r + if db != nil { + repo = NewRepository(db) + } + kvMap := make(map[string]dto.LabelItem, len(labelItems)) + for _, item := range labelItems { + kvMap[item.Key] = item + } + + existingLabels, err := repo.listLabelsByConditions(dto.ConvertLabelItemsToConditions(labelItems)) + if err != nil { + return nil, fmt.Errorf("failed to find existing labels: %w", err) + } + + result := make([]model.Label, 0, len(labelItems)) + existingIDs := make([]int, 0, len(existingLabels)) + for _, existing := range existingLabels { + if item, ok := kvMap[existing.Key]; ok && item.Value == existing.Value { + result = append(result, existing) + existingIDs = append(existingIDs, existing.ID) + delete(kvMap, existing.Key) + } + } + + if len(existingIDs) > 0 { + if err := repo.batchIncreaseLabelUsages(existingIDs, 1); err != nil { + return nil, fmt.Errorf("failed to increase usage for existing labels: %w", err) + } + } + + if len(kvMap) > 0 { + newLabels := make([]model.Label, 0, len(kvMap)) + for key, item := range kvMap { + newLabels = append(newLabels, model.Label{ + Key: key, + Value: item.Value, + Category: category, + Description: fmt.Sprintf(consts.CustomLabelDescriptionTemplate, key, consts.GetLabelCategoryName(category)), + Color: utils.GenerateColorFromKey(key), + Usage: consts.DefaultLabelUsage, + IsSystem: item.IsSystem, + Status: consts.CommonEnabled, + }) + } + if err := repo.batchCreateLabels(newLabels); err != nil { + return nil, fmt.Errorf("failed to create new labels: %w", err) + } + result = append(result, newLabels...) + } + + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result, nil +} diff --git a/src/handlers/v2/labels.go b/src/module/label/handler.go similarity index 50% rename from src/handlers/v2/labels.go rename to src/module/label/handler.go index 53a77187..e4f54770 100644 --- a/src/handlers/v2/labels.go +++ b/src/module/label/handler.go @@ -1,17 +1,22 @@ -package v2 +package label import ( - "aegis/consts" + "aegis/httpx" "net/http" "strconv" + "aegis/consts" "aegis/dto" - "aegis/handlers" - producer "aegis/service/producer" "github.com/gin-gonic/gin" ) +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { return &Handler{service: service} } + // BatchDeleteLabels handles batch deletion of labels // // @Summary Batch delete labels @@ -21,31 +26,27 @@ import ( // @Accept json // @Produce json // @Security BearerAuth -// @Param request body dto.BatchDeleteLabelReq true "Batch delete request" +// @Param request body BatchDeleteLabelReq true "Batch delete request" // @Success 200 {object} dto.GenericResponse[any] "Labels deleted successfully" // @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" // @Failure 401 {object} dto.GenericResponse[any] "Authentication required" // @Failure 403 {object} dto.GenericResponse[any] "Permission denied" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/labels/batch-delete [post] -// @x-api-type {"sdk":"true"} -func BatchDeleteLabels(c *gin.Context) { - var req dto.BatchDeleteLabelReq +// @x-api-type {"portal":"true"} +func (h *Handler) BatchDeleteLabels(c *gin.Context) { + var req BatchDeleteLabelReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - err := producer.BatchDeleteLabels(req.IDs) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.BatchDelete(c.Request.Context(), req.IDs)) { return } - dto.JSONResponse[any](c, http.StatusNoContent, "Labels deleted successfully", nil) } @@ -58,32 +59,29 @@ func BatchDeleteLabels(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param label body dto.CreateLabelReq true "Label creation request" -// @Success 201 {object} dto.GenericResponse[dto.LabelResp] "Label created successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format/parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 409 {object} dto.GenericResponse[any] "Label already exists" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param label body CreateLabelReq true "Label creation request" +// @Success 201 {object} dto.GenericResponse[LabelResp] "Label created successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format/parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 409 {object} dto.GenericResponse[any] "Label already exists" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/labels [post] -// @x-api-type {"sdk":"true"} -func CreateLabel(c *gin.Context) { - var req dto.CreateLabelReq +// @x-api-type {"portal":"true"} +func (h *Handler) CreateLabel(c *gin.Context) { + var req CreateLabelReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format:"+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - resp, err := producer.CreateLabel(&req) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.Create(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { return } - dto.JSONResponse(c, http.StatusCreated, "Label created successfully", resp) } @@ -103,20 +101,15 @@ func CreateLabel(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "Label not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/labels/{label_id} [delete] -// @x-api-type {"sdk":"true"} -func DeleteLabel(c *gin.Context) { - labelIdStr := c.Param(consts.URLPathLabelID) - labelID, err := strconv.Atoi(labelIdStr) - if err != nil || labelID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid label ID") +// @x-api-type {"portal":"true"} +func (h *Handler) DeleteLabel(c *gin.Context) { + id, ok := parseLabelID(c) + if !ok { return } - - err = producer.DeleteLabel(labelID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.Delete(c.Request.Context(), id)) { return } - dto.JSONResponse[any](c, http.StatusNoContent, "Label deleted successfully", nil) } @@ -128,28 +121,24 @@ func DeleteLabel(c *gin.Context) { // @ID get_label_by_id // @Produce json // @Security BearerAuth -// @Param label_id path int true "Label ID" -// @Success 200 {object} dto.GenericResponse[dto.LabelDetailResp] "Label retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid label ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Label not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param label_id path int true "Label ID" +// @Success 200 {object} dto.GenericResponse[LabelDetailResp] "Label retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid label ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Label not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/labels/{label_id} [get] -// @x-api-type {"sdk":"true"} -func GetLabelDetail(c *gin.Context) { - labelIdStr := c.Param(consts.URLPathLabelID) - labelID, err := strconv.Atoi(labelIdStr) - if err != nil || labelID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid label ID") +// @x-api-type {"portal":"true"} +func (h *Handler) GetLabelDetail(c *gin.Context) { + id, ok := parseLabelID(c) + if !ok { return } - - resp, err := producer.GetLabelDetail(labelID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.GetDetail(c.Request.Context(), id) + if httpx.HandleServiceError(c, err) { return } - dto.SuccessResponse(c, resp) } @@ -161,37 +150,34 @@ func GetLabelDetail(c *gin.Context) { // @ID list_labels // @Produce json // @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param key query string false "Filter by label key" -// @Param value query string false "Filter by label value" -// @Param category query consts.LabelCategory false "Filter by category" -// @Param is_system query bool false "Filter by system label" -// @Param status query consts.StatusType false "Filter by status" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.LabelResp]] "Labels retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Param key query string false "Filter by label key" +// @Param value query string false "Filter by label value" +// @Param category query consts.LabelCategory false "Filter by category" +// @Param is_system query bool false "Filter by system label" +// @Param status query consts.StatusType false "Filter by status" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[LabelResp]] "Labels retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/labels [get] -// @x-api-type {"sdk":"true"} -func ListLabels(c *gin.Context) { - var req dto.ListLabelReq +// @x-api-type {"portal":"true"} +func (h *Handler) ListLabels(c *gin.Context) { + var req ListLabelReq if err := c.ShouldBindQuery(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - resp, err := producer.ListLabels(&req) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.List(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { return } - dto.SuccessResponse(c, resp) } @@ -204,39 +190,43 @@ func ListLabels(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param label_id path int true "Label ID" -// @Param request body dto.UpdateLabelReq true "Label update request" -// @Success 202 {object} dto.GenericResponse[dto.LabelResp] "Label updated successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid label ID or invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Label not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param label_id path int true "Label ID" +// @Param request body UpdateLabelReq true "Label update request" +// @Success 202 {object} dto.GenericResponse[LabelResp] "Label updated successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid label ID or invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Label not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/labels/{label_id} [patch] -// @x-api-type {"sdk":"true"} -func UpdateLabel(c *gin.Context) { - labelIdStr := c.Param(consts.URLPathLabelID) - labelID, err := strconv.Atoi(labelIdStr) - if err != nil || labelID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid label ID") +// @x-api-type {"portal":"true"} +func (h *Handler) UpdateLabel(c *gin.Context) { + id, ok := parseLabelID(c) + if !ok { return } - - var req dto.UpdateLabelReq + var req UpdateLabelReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - resp, err := producer.UpdateLabel(&req, labelID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.Update(c.Request.Context(), &req, id) + if httpx.HandleServiceError(c, err) { return } - dto.JSONResponse(c, http.StatusAccepted, "Label updated successfully", resp) } + +func parseLabelID(c *gin.Context) (int, bool) { + v := c.Param(consts.URLPathLabelID) + id, err := strconv.Atoi(v) + if err != nil || id <= 0 { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid label ID") + return 0, false + } + return id, true +} diff --git a/src/module/label/handler_service.go b/src/module/label/handler_service.go new file mode 100644 index 00000000..e80ca0b1 --- /dev/null +++ b/src/module/label/handler_service.go @@ -0,0 +1,21 @@ +package label + +import ( + "context" + + "aegis/dto" +) + +// HandlerService captures the label operations consumed by HTTP and resource gRPC handlers. +type HandlerService interface { + BatchDelete(context.Context, []int) error + Create(context.Context, *CreateLabelReq) (*LabelResp, error) + Delete(context.Context, int) error + GetDetail(context.Context, int) (*LabelDetailResp, error) + List(context.Context, *ListLabelReq) (*dto.ListResp[LabelResp], error) + Update(context.Context, *UpdateLabelReq, int) (*LabelResp, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/label/module.go b/src/module/label/module.go new file mode 100644 index 00000000..df04d5d4 --- /dev/null +++ b/src/module/label/module.go @@ -0,0 +1,10 @@ +package label + +import "go.uber.org/fx" + +var Module = fx.Module("label", + fx.Provide(NewRepository), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/module/label/repository.go b/src/module/label/repository.go new file mode 100644 index 00000000..9b1fcc83 --- /dev/null +++ b/src/module/label/repository.go @@ -0,0 +1,321 @@ +package label + +import ( + "aegis/consts" + "aegis/model" + "errors" + "fmt" + "strings" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const labelKeyOmitFields = "active_key_value" + +type Repository struct { + db *gorm.DB +} + +type labelCountResult struct { + LabelID int `gorm:"column:label_id"` + Count int64 `gorm:"column:count"` +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) ListLabelsByID(db *gorm.DB, labelIDs []int) ([]model.Label, error) { + if len(labelIDs) == 0 { + return []model.Label{}, nil + } + + var labels []model.Label + if err := r.useDB(db). + Where("id IN (?) AND status != ?", labelIDs, consts.CommonDeleted). + Find(&labels).Error; err != nil { + return nil, fmt.Errorf("failed to list labels by IDs: %w", err) + } + return labels, nil +} + +func (r *Repository) BatchUpdateLabels(db *gorm.DB, labels []model.Label) error { + if len(labels) == 0 { + return fmt.Errorf("no labels to update") + } + + if err := r.useDB(db).Omit(labelKeyOmitFields).Save(&labels).Error; err != nil { + return fmt.Errorf("failed to batch update labels: %w", err) + } + return nil +} + +func (r *Repository) BatchDeleteLabels(db *gorm.DB, labelIDs []int) error { + if len(labelIDs) == 0 { + return nil + } + + if err := r.useDB(db).Model(&model.Label{}). + Where("id IN (?) AND status != ?", labelIDs, consts.CommonDeleted). + Update("status", consts.CommonDeleted).Error; err != nil { + return fmt.Errorf("failed to batch delete labels: %w", err) + } + return nil +} + +func (r *Repository) GetLabelByKeyAndValue(db *gorm.DB, key, value string, status ...consts.StatusType) (*model.Label, error) { + query := r.useDB(db).Where("label_key = ? AND label_value = ?", key, value) + if len(status) == 0 { + query = query.Where("status != ?", consts.CommonDeleted) + } else if len(status) == 1 { + query = query.Where("status = ?", status[0]) + } else { + query = query.Where("status IN (?)", status) + } + + var label model.Label + if err := query.First(&label).Error; err != nil { + return nil, fmt.Errorf("failed to get label: %w", err) + } + return &label, nil +} + +func (r *Repository) batchCreateLabels(labels []model.Label) error { + if len(labels) == 0 { + return nil + } + if err := r.db.Omit(labelKeyOmitFields).Create(&labels).Error; err != nil { + return fmt.Errorf("failed to batch upsert labels: %w", err) + } + return nil +} + +func (r *Repository) batchIncreaseLabelUsages(labelIDs []int, increment int) error { + if len(labelIDs) == 0 { + return nil + } + + expr := gorm.Expr("usage_count + ?", increment) + if err := r.db.Model(&model.Label{}). + Where("id IN (?)", labelIDs). + UpdateColumn("usage_count", expr).Error; err != nil { + return fmt.Errorf("failed to batch increase label usages: %w", err) + } + return nil +} + +func (r *Repository) listLabelsByConditions(conditions []map[string]string) ([]model.Label, error) { + if len(conditions) == 0 { + return []model.Label{}, nil + } + + var labels []model.Label + query := r.db.Model(&model.Label{}) + var whereClauses []string + var whereArgs []any + + for _, condition := range conditions { + whereClauses = append(whereClauses, "(label_key = ? AND label_value = ?)") + whereArgs = append(whereArgs, condition["key"], condition["value"]) + } + + if len(whereClauses) > 0 { + query = query.Where(strings.Join(whereClauses, " OR "), whereArgs...) + } + + if err := query.Find(&labels).Error; err != nil { + return nil, fmt.Errorf("failed to list labels by conditions: %w", err) + } + return labels, nil +} + +func (r *Repository) CreateLabel(db *gorm.DB, label *model.Label) error { + if err := r.useDB(db).Omit(labelKeyOmitFields).Create(label).Error; err != nil { + return fmt.Errorf("failed to create label: %w", err) + } + return nil +} + +func (r *Repository) UpdateLabel(db *gorm.DB, label *model.Label) error { + if err := r.useDB(db).Omit(labelKeyOmitFields).Save(label).Error; err != nil { + return fmt.Errorf("failed to update label: %w", err) + } + return nil +} + +func (r *Repository) GetLabelByID(db *gorm.DB, id int) (*model.Label, error) { + var label model.Label + if err := r.useDB(db).First(&label, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("label with id %d not found", id) + } + return nil, fmt.Errorf("failed to get label: %w", err) + } + return &label, nil +} + +func (r *Repository) RemoveContainersFromLabel(db *gorm.DB, labelID int) (int64, error) { + return r.removeAssociationsFromLabel(db, &model.ContainerLabel{}, labelID, "containers") +} + +func (r *Repository) RemoveDatasetsFromLabel(db *gorm.DB, labelID int) (int64, error) { + return r.removeAssociationsFromLabel(db, &model.DatasetLabel{}, labelID, "datasets") +} + +func (r *Repository) RemoveProjectsFromLabel(db *gorm.DB, labelID int) (int64, error) { + return r.removeAssociationsFromLabel(db, &model.ProjectLabel{}, labelID, "projects") +} + +func (r *Repository) RemoveInjectionsFromLabel(db *gorm.DB, labelID int) (int64, error) { + return r.removeAssociationsFromLabel(db, &model.FaultInjectionLabel{}, labelID, "injection-label associations") +} + +func (r *Repository) RemoveExecutionsFromLabel(db *gorm.DB, labelID int) (int64, error) { + return r.removeAssociationsFromLabel(db, &model.ExecutionInjectionLabel{}, labelID, "execution-label associations") +} + +func (r *Repository) ListContainerLabelCounts(db *gorm.DB, labelIDs []int) (map[int]int64, error) { + return r.listAssociationCounts(db, &model.ContainerLabel{}, labelIDs) +} + +func (r *Repository) RemoveContainersFromLabels(db *gorm.DB, labelIDs []int) (int64, error) { + return r.removeAssociationsFromLabels(db, &model.ContainerLabel{}, labelIDs, "containers") +} + +func (r *Repository) ListDatasetLabelCounts(db *gorm.DB, labelIDs []int) (map[int]int64, error) { + return r.listAssociationCounts(db, &model.DatasetLabel{}, labelIDs) +} + +func (r *Repository) RemoveDatasetsFromLabels(db *gorm.DB, labelIDs []int) (int64, error) { + return r.removeAssociationsFromLabels(db, &model.DatasetLabel{}, labelIDs, "datasets") +} + +func (r *Repository) ListProjectLabelCounts(db *gorm.DB, labelIDs []int) (map[int]int64, error) { + return r.listAssociationCounts(db, &model.ProjectLabel{}, labelIDs) +} + +func (r *Repository) RemoveProjectsFromLabels(db *gorm.DB, labelIDs []int) (int64, error) { + return r.removeAssociationsFromLabels(db, &model.ProjectLabel{}, labelIDs, "projects") +} + +func (r *Repository) ListInjectionLabelCounts(db *gorm.DB, labelIDs []int) (map[int]int64, error) { + return r.listAssociationCounts(db, &model.FaultInjectionLabel{}, labelIDs) +} + +func (r *Repository) RemoveInjectionsFromLabels(db *gorm.DB, labelIDs []int) (int64, error) { + return r.removeAssociationsFromLabels(db, &model.FaultInjectionLabel{}, labelIDs, "injection-label associations") +} + +func (r *Repository) ListExecutionLabelCounts(db *gorm.DB, labelIDs []int) (map[int]int64, error) { + return r.listAssociationCounts(db, &model.ExecutionInjectionLabel{}, labelIDs) +} + +func (r *Repository) RemoveExecutionsFromLabels(db *gorm.DB, labelIDs []int) (int64, error) { + return r.removeAssociationsFromLabels(db, &model.ExecutionInjectionLabel{}, labelIDs, "execution-label associations") +} + +func (r *Repository) BatchDecreaseLabelUsages(db *gorm.DB, labelIDs []int, decrement int) error { + if len(labelIDs) == 0 { + return nil + } + + expr := gorm.Expr("GREATEST(0, usage_count - ?)", decrement) + if err := r.useDB(db).Model(&model.Label{}). + Where("id IN (?)", labelIDs). + Clauses(clause.Returning{}). + UpdateColumn("usage_count", expr).Error; err != nil { + return fmt.Errorf("failed to batch decrease label usages: %w", err) + } + return nil +} + +func (r *Repository) DeleteLabel(db *gorm.DB, labelID int) (int64, error) { + result := r.useDB(db).Model(&model.Label{}). + Where("id = ? AND status != ?", labelID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if result.Error != nil { + return 0, fmt.Errorf("failed to soft delete label %d: %w", labelID, result.Error) + } + return result.RowsAffected, nil +} + +func (r *Repository) ListLabels(limit, offset int, filterOptions *ListLabelFilters) ([]model.Label, int64, error) { + var ( + labels []model.Label + total int64 + ) + + query := r.db.Model(&model.Label{}) + if filterOptions.Key != "" { + query = query.Where("label_key = ?", filterOptions.Key) + } + if filterOptions.Value != "" { + query = query.Where("label_value = ?", filterOptions.Value) + } + if filterOptions.Category != nil { + query = query.Where("category = ?", *filterOptions.Category) + } + if filterOptions.IsSystem != nil { + query = query.Where("is_system = ?", *filterOptions.IsSystem) + } + if filterOptions.Status != nil { + query = query.Where("status = ?", *filterOptions.Status) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count labels: %w", err) + } + if err := query.Limit(limit).Offset(offset).Order("usage_count DESC, created_at DESC").Find(&labels).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list labels: %w", err) + } + return labels, total, nil +} + +func (r *Repository) useDB(db *gorm.DB) *gorm.DB { + if db != nil { + return db + } + return r.db +} + +func (r *Repository) removeAssociationsFromLabel(db *gorm.DB, model any, labelID int, target string) (int64, error) { + result := r.useDB(db).Where("label_id = ?", labelID).Delete(model) + if err := result.Error; err != nil { + return 0, fmt.Errorf("failed to remove %s from label %d: %w", target, labelID, err) + } + return result.RowsAffected, nil +} + +func (r *Repository) removeAssociationsFromLabels(db *gorm.DB, model any, labelIDs []int, target string) (int64, error) { + if len(labelIDs) == 0 { + return 0, nil + } + + result := r.useDB(db).Where("label_id IN (?)", labelIDs).Delete(model) + if err := result.Error; err != nil { + return 0, fmt.Errorf("failed to remove %s from labels %v: %w", target, labelIDs, err) + } + return result.RowsAffected, nil +} + +func (r *Repository) listAssociationCounts(db *gorm.DB, model any, labelIDs []int) (map[int]int64, error) { + if len(labelIDs) == 0 { + return map[int]int64{}, nil + } + + var results []labelCountResult + if err := r.useDB(db).Model(model). + Select("label_id, COUNT(label_id) AS count"). + Where("label_id IN (?)", labelIDs). + Group("label_id"). + Scan(&results).Error; err != nil { + return nil, fmt.Errorf("failed to count associations: %w", err) + } + + countMap := make(map[int]int64, len(results)) + for _, result := range results { + countMap[result.LabelID] = result.Count + } + return countMap, nil +} diff --git a/src/module/label/service.go b/src/module/label/service.go new file mode 100644 index 00000000..ae364921 --- /dev/null +++ b/src/module/label/service.go @@ -0,0 +1,283 @@ +package label + +import ( + "context" + "errors" + "fmt" + + "aegis/consts" + "aegis/dto" + "aegis/model" + + "gorm.io/gorm" +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) BatchDelete(_ context.Context, ids []int) error { + if len(ids) == 0 { + return nil + } + + return s.repo.db.Transaction(func(tx *gorm.DB) error { + labels, err := s.repo.ListLabelsByID(tx, ids) + if err != nil { + return fmt.Errorf("failed to list labels by IDs: %w", err) + } + if len(labels) == 0 { + return fmt.Errorf("no labels found for the provided IDs") + } + if len(labels) != len(ids) { + return fmt.Errorf("some labels not found for the provided IDs") + } + + labelMap := make(map[int]*model.Label, len(labels)) + for _, label := range labels { + labelMap[label.ID] = &label + } + + containerCountMap, err := s.removeContainersFromLabels(tx, ids) + if err != nil { + return fmt.Errorf("failed to delete container-label associations: %v", err) + } + datasetCountMap, err := s.removeDatasetsFromLabels(tx, ids) + if err != nil { + return fmt.Errorf("failed to delete dataset-label associations: %v", err) + } + projectCountMap, err := s.removeProjectsFromLabels(tx, ids) + if err != nil { + return fmt.Errorf("failed to delete project-label associations: %v", err) + } + injectionCountMap, err := s.removeInjectionsFromLabels(tx, ids) + if err != nil { + return fmt.Errorf("failed to delete injection-label associations: %v", err) + } + executionCountMap, err := s.removeExecutionsFromLabels(tx, ids) + if err != nil { + return fmt.Errorf("failed to delete execution-label associations: %v", err) + } + + toUpdatedLabels := make([]model.Label, 0, len(ids)) + for labelID, label := range labelMap { + totalDecrement := int64(0) + totalDecrement += containerCountMap[labelID] + totalDecrement += datasetCountMap[labelID] + totalDecrement += projectCountMap[labelID] + totalDecrement += injectionCountMap[labelID] + totalDecrement += executionCountMap[labelID] + label.Usage = max(label.Usage-int(totalDecrement), 0) + toUpdatedLabels = append(toUpdatedLabels, *label) + } + + if err := s.repo.BatchUpdateLabels(tx, toUpdatedLabels); err != nil { + return fmt.Errorf("failed to update label usages: %v", err) + } + if err := s.repo.BatchDeleteLabels(tx, ids); err != nil { + return fmt.Errorf("failed to batch delete labels: %v", err) + } + return nil + }) +} + +func (s *Service) Create(_ context.Context, req *CreateLabelReq) (*LabelResp, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("label validation failed: %w", err) + } + + label := req.ConvertToLabel() + var createdLabel *model.Label + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + item, err := s.createLabelCore(tx, label) + if err != nil { + return fmt.Errorf("failed to create label: %w", err) + } + createdLabel = item + return nil + }) + if err != nil { + return nil, err + } + + return NewLabelResp(createdLabel), nil +} + +func (s *Service) Delete(_ context.Context, id int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + label, err := s.repo.GetLabelByID(tx, id) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return fmt.Errorf("%w: label with id %d not found", consts.ErrNotFound, id) + } + return fmt.Errorf("failed to get label: %v", err) + } + + containerRows, err := s.repo.RemoveContainersFromLabel(tx, label.ID) + if err != nil { + return fmt.Errorf("failed to delete container-label associations: %v", err) + } + datasetRows, err := s.repo.RemoveDatasetsFromLabel(tx, label.ID) + if err != nil { + return fmt.Errorf("failed to delete dataset-label associations: %v", err) + } + projectRows, err := s.repo.RemoveProjectsFromLabel(tx, label.ID) + if err != nil { + return fmt.Errorf("failed to delete project-label associations: %v", err) + } + injectionRows, err := s.repo.RemoveInjectionsFromLabel(tx, label.ID) + if err != nil { + return fmt.Errorf("failed to delete injection-label associations: %v", err) + } + executionRows, err := s.repo.RemoveExecutionsFromLabel(tx, label.ID) + if err != nil { + return fmt.Errorf("failed to delete execution-label associations: %v", err) + } + + totalRows := int(containerRows + datasetRows + projectRows + injectionRows + executionRows) + if err := s.repo.BatchDecreaseLabelUsages(tx, []int{label.ID}, totalRows); err != nil { + return fmt.Errorf("failed to decrease label usage: %v", err) + } + + rows, err := s.repo.DeleteLabel(tx, id) + if err != nil { + return fmt.Errorf("failed to delete label: %w", err) + } + if rows == 0 { + return fmt.Errorf("%w: label id %d not found", consts.ErrNotFound, id) + } + return nil + }) +} + +func (s *Service) GetDetail(_ context.Context, id int) (*LabelDetailResp, error) { + label, err := s.repo.GetLabelByID(s.repo.db, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: label with ID %d not found", consts.ErrNotFound, id) + } + return nil, fmt.Errorf("failed to get label: %w", err) + } + return NewLabelDetailResp(label), nil +} + +func (s *Service) List(_ context.Context, req *ListLabelReq) (*dto.ListResp[LabelResp], error) { + limit, offset := req.ToGormParams() + filterOptions := req.ToFilterOptions() + labels, total, err := s.repo.ListLabels(limit, offset, filterOptions) + if err != nil { + return nil, fmt.Errorf("failed to list labels: %w", err) + } + items := make([]LabelResp, 0, len(labels)) + for i := range labels { + items = append(items, *NewLabelResp(&labels[i])) + } + return &dto.ListResp[LabelResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) Update(_ context.Context, req *UpdateLabelReq, id int) (*LabelResp, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + var updatedLabel *model.Label + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + existingLabel, err := s.repo.GetLabelByID(tx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: label with ID %d not found", consts.ErrNotFound, id) + } + return fmt.Errorf("failed to get label: %w", err) + } + + req.PatchLabelModel(existingLabel) + if err := s.repo.UpdateLabel(tx, existingLabel); err != nil { + return fmt.Errorf("failed to update label: %w", err) + } + updatedLabel = existingLabel + return nil + }) + if err != nil { + return nil, err + } + + return NewLabelResp(updatedLabel), nil +} + +type labelRemovalOps struct { + countFunc func(*gorm.DB, []int) (map[int]int64, error) + removeFunc func(*gorm.DB, []int) (int64, error) + entityName string +} + +func (s *Service) createLabelCore(db *gorm.DB, label *model.Label) (*model.Label, error) { + return s.repo.CreateLabelCore(db, label) +} + +func (s *Service) removeAssociationsFromLabels(db *gorm.DB, labelIDs []int, ops labelRemovalOps) (map[int]int64, error) { + if len(labelIDs) == 0 { + return nil, nil + } + countsMap, err := ops.countFunc(db, labelIDs) + if err != nil { + return nil, fmt.Errorf("failed to get %s-label counts: %w", ops.entityName, err) + } + if len(countsMap) == 0 { + return nil, nil + } + rows, err := ops.removeFunc(db, labelIDs) + if err != nil { + return nil, fmt.Errorf("failed to remove %ss from labels: %w", ops.entityName, err) + } + if rows == 0 { + return nil, nil + } + return countsMap, nil +} + +func (s *Service) removeContainersFromLabels(db *gorm.DB, labelIDs []int) (map[int]int64, error) { + return s.removeAssociationsFromLabels(db, labelIDs, labelRemovalOps{ + countFunc: s.repo.ListContainerLabelCounts, + removeFunc: s.repo.RemoveContainersFromLabels, + entityName: "container", + }) +} + +func (s *Service) removeDatasetsFromLabels(db *gorm.DB, labelIDs []int) (map[int]int64, error) { + return s.removeAssociationsFromLabels(db, labelIDs, labelRemovalOps{ + countFunc: s.repo.ListDatasetLabelCounts, + removeFunc: s.repo.RemoveDatasetsFromLabels, + entityName: "dataset", + }) +} + +func (s *Service) removeProjectsFromLabels(db *gorm.DB, labelIDs []int) (map[int]int64, error) { + return s.removeAssociationsFromLabels(db, labelIDs, labelRemovalOps{ + countFunc: s.repo.ListProjectLabelCounts, + removeFunc: s.repo.RemoveProjectsFromLabels, + entityName: "project", + }) +} + +func (s *Service) removeInjectionsFromLabels(db *gorm.DB, labelIDs []int) (map[int]int64, error) { + return s.removeAssociationsFromLabels(db, labelIDs, labelRemovalOps{ + countFunc: s.repo.ListInjectionLabelCounts, + removeFunc: s.repo.RemoveInjectionsFromLabels, + entityName: "injection", + }) +} + +func (s *Service) removeExecutionsFromLabels(db *gorm.DB, labelIDs []int) (map[int]int64, error) { + return s.removeAssociationsFromLabels(db, labelIDs, labelRemovalOps{ + countFunc: s.repo.ListExecutionLabelCounts, + removeFunc: s.repo.RemoveExecutionsFromLabels, + entityName: "execution", + }) +} diff --git a/src/module/metric/api_types.go b/src/module/metric/api_types.go new file mode 100644 index 00000000..f0f102e1 --- /dev/null +++ b/src/module/metric/api_types.go @@ -0,0 +1,65 @@ +package metric + +import ( + "fmt" + "time" +) + +// GetMetricsReq represents the request to get metrics with time range and filters. +type GetMetricsReq struct { + StartTime *time.Time `form:"start_time" binding:"omitempty"` + EndTime *time.Time `form:"end_time" binding:"omitempty"` + FaultType *string `form:"fault_type" binding:"omitempty"` + AlgorithmID *int `form:"algorithm_id" binding:"omitempty"` +} + +func (req *GetMetricsReq) Validate() error { + if req.StartTime != nil && req.EndTime != nil && req.EndTime.Before(*req.StartTime) { + return fmt.Errorf("end_time must be after start_time") + } + if req.AlgorithmID != nil && *req.AlgorithmID <= 0 { + return fmt.Errorf("algorithm_id must be positive") + } + return nil +} + +// InjectionMetrics represents aggregated metrics for injections. +type InjectionMetrics struct { + TotalCount int `json:"total_count"` + SuccessCount int `json:"success_count"` + FailedCount int `json:"failed_count"` + SuccessRate float64 `json:"success_rate"` + AvgDuration float64 `json:"avg_duration"` + MinDuration float64 `json:"min_duration"` + MaxDuration float64 `json:"max_duration"` + StateDistrib map[string]int `json:"state_distribution" swaggertype:"object"` + FaultTypeDistrib map[string]int `json:"fault_type_distribution" swaggertype:"object"` +} + +// ExecutionMetrics represents aggregated metrics for algorithm executions. +type ExecutionMetrics struct { + TotalCount int `json:"total_count"` + SuccessCount int `json:"success_count"` + FailedCount int `json:"failed_count"` + SuccessRate float64 `json:"success_rate"` + AvgDuration float64 `json:"avg_duration"` + MinDuration float64 `json:"min_duration"` + MaxDuration float64 `json:"max_duration"` + StateDistrib map[string]int `json:"state_distribution" swaggertype:"object"` +} + +// AlgorithmMetrics represents comparative metrics across different algorithms. +type AlgorithmMetrics struct { + Algorithms []AlgorithmMetricItem `json:"algorithms"` +} + +// AlgorithmMetricItem represents metrics for a single algorithm. +type AlgorithmMetricItem struct { + AlgorithmID int `json:"algorithm_id"` + AlgorithmName string `json:"algorithm_name"` + ExecutionCount int `json:"execution_count"` + SuccessCount int `json:"success_count"` + FailedCount int `json:"failed_count"` + SuccessRate float64 `json:"success_rate"` + AvgDuration float64 `json:"avg_duration"` +} diff --git a/src/module/metric/handler.go b/src/module/metric/handler.go new file mode 100644 index 00000000..7c6bf1f6 --- /dev/null +++ b/src/module/metric/handler.go @@ -0,0 +1,114 @@ +package metric + +import ( + "aegis/httpx" + "net/http" + + "aegis/dto" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + +// GetInjectionMetrics handles retrieval of injection metrics +// +// @Summary Get injection metrics +// @Description Get aggregated metrics for injections including success rate, duration stats, and state distribution +// @Tags Metrics +// @ID get_injection_metrics +// @Produce json +// @Security BearerAuth +// @Param start_time query string false "Start time (RFC3339)" +// @Param end_time query string false "End time (RFC3339)" +// @Param fault_type query string false "Filter by fault type" +// @Success 200 {object} dto.GenericResponse[InjectionMetrics] "Injection metrics" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/metrics/injections [get] +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) GetInjectionMetrics(c *gin.Context) { + var req GetMetricsReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + metrics, err := h.service.GetInjectionMetrics(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse(c, http.StatusOK, "Injection metrics retrieved successfully", metrics) +} + +// GetExecutionMetrics handles retrieval of execution metrics +// +// @Summary Get execution metrics +// @Description Get aggregated metrics for algorithm executions including performance stats and state distribution +// @Tags Metrics +// @ID get_execution_metrics +// @Produce json +// @Security BearerAuth +// @Param start_time query string false "Start time (RFC3339)" +// @Param end_time query string false "End time (RFC3339)" +// @Param algorithm_id query int false "Filter by algorithm ID" +// @Success 200 {object} dto.GenericResponse[ExecutionMetrics] "Execution metrics" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/metrics/executions [get] +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) GetExecutionMetrics(c *gin.Context) { + var req GetMetricsReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + metrics, err := h.service.GetExecutionMetrics(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse(c, http.StatusOK, "Execution metrics retrieved successfully", metrics) +} + +// GetAlgorithmMetrics handles retrieval of algorithm comparison metrics +// +// @Summary Get algorithm comparison metrics +// @Description Get comparative metrics across different algorithms for performance analysis +// @Tags Metrics +// @ID get_algorithm_metrics +// @Produce json +// @Security BearerAuth +// @Param algorithm_ids query string false "Comma-separated algorithm IDs" +// @Param start_time query string false "Start time (RFC3339)" +// @Param end_time query string false "End time (RFC3339)" +// @Success 200 {object} dto.GenericResponse[AlgorithmMetrics] "Algorithm metrics" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/metrics/algorithms [get] +// @x-api-type {"portal":"true","sdk":"true"} +func (h *Handler) GetAlgorithmMetrics(c *gin.Context) { + var req GetMetricsReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + metrics, err := h.service.GetAlgorithmMetrics(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse(c, http.StatusOK, "Algorithm metrics retrieved successfully", metrics) +} diff --git a/src/module/metric/handler_service.go b/src/module/metric/handler_service.go new file mode 100644 index 00000000..25ce3201 --- /dev/null +++ b/src/module/metric/handler_service.go @@ -0,0 +1,14 @@ +package metric + +import "context" + +// HandlerService captures the metric operations consumed by the HTTP handler. +type HandlerService interface { + GetInjectionMetrics(context.Context, *GetMetricsReq) (*InjectionMetrics, error) + GetExecutionMetrics(context.Context, *GetMetricsReq) (*ExecutionMetrics, error) + GetAlgorithmMetrics(context.Context, *GetMetricsReq) (*AlgorithmMetrics, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/metric/module.go b/src/module/metric/module.go new file mode 100644 index 00000000..356bb015 --- /dev/null +++ b/src/module/metric/module.go @@ -0,0 +1,10 @@ +package metric + +import "go.uber.org/fx" + +var Module = fx.Module("metric", + fx.Provide(NewRepository), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/module/metric/repository.go b/src/module/metric/repository.go new file mode 100644 index 00000000..eef9f458 --- /dev/null +++ b/src/module/metric/repository.go @@ -0,0 +1,39 @@ +package metric + +import ( + "aegis/model" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) ListFaultInjections(query func(*gorm.DB) *gorm.DB) ([]model.FaultInjection, error) { + var items []model.FaultInjection + if err := query(r.db).Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *Repository) ListExecutions(query func(*gorm.DB) *gorm.DB) ([]model.Execution, error) { + var items []model.Execution + if err := query(r.db).Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *Repository) ListAlgorithmContainers() ([]model.Container, error) { + var items []model.Container + if err := r.db.Where("type = ?", 2).Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} diff --git a/src/service/producer/metrics.go b/src/module/metric/service.go similarity index 52% rename from src/service/producer/metrics.go rename to src/module/metric/service.go index 48ac554a..057eb2f7 100644 --- a/src/service/producer/metrics.go +++ b/src/module/metric/service.go @@ -1,15 +1,24 @@ -package producer +package metric import ( - "aegis/database" - "aegis/dto" + "context" "fmt" + "aegis/model" + "github.com/sirupsen/logrus" + "gorm.io/gorm" ) -// GetInjectionMetrics retrieves aggregated metrics for fault injections -func GetInjectionMetrics(req *dto.GetMetricsReq) (*dto.InjectionMetrics, error) { +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) GetInjectionMetrics(_ context.Context, req *GetMetricsReq) (*InjectionMetrics, error) { if err := req.Validate(); err != nil { return nil, fmt.Errorf("invalid request: %w", err) } @@ -20,28 +29,110 @@ func GetInjectionMetrics(req *dto.GetMetricsReq) (*dto.InjectionMetrics, error) "fault_type": req.FaultType, }).Info("GetInjectionMetrics: starting") - var injections []database.FaultInjection - query := database.DB + injections, err := s.repo.ListFaultInjections(func(db *gorm.DB) *gorm.DB { + query := db + if req.StartTime != nil { + query = query.Where("created_at >= ?", req.StartTime) + } + if req.EndTime != nil { + query = query.Where("created_at <= ?", req.EndTime) + } + if req.FaultType != nil { + query = query.Where("fault_type = ?", *req.FaultType) + } + return query + }) + if err != nil { + return nil, fmt.Errorf("failed to query injections: %w", err) + } + + metrics := buildInjectionMetrics(injections) + logrus.WithField("metrics", metrics).Info("GetInjectionMetrics: completed") + return metrics, nil +} + +func (s *Service) GetExecutionMetrics(_ context.Context, req *GetMetricsReq) (*ExecutionMetrics, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + logrus.WithFields(map[string]interface{}{ + "start_time": req.StartTime, + "end_time": req.EndTime, + "algorithm_id": req.AlgorithmID, + }).Info("GetExecutionMetrics: starting") + + executions, err := s.repo.ListExecutions(func(db *gorm.DB) *gorm.DB { + query := db + if req.StartTime != nil { + query = query.Where("created_at >= ?", req.StartTime) + } + if req.EndTime != nil { + query = query.Where("created_at <= ?", req.EndTime) + } + if req.AlgorithmID != nil { + query = query.Where("algorithm_id = ?", *req.AlgorithmID) + } + return query + }) + if err != nil { + return nil, fmt.Errorf("failed to query executions: %w", err) + } + + metrics := buildExecutionMetrics(executions) + logrus.WithField("metrics", metrics).Info("GetExecutionMetrics: completed") + return metrics, nil +} - // Apply time range filter - if req.StartTime != nil { - query = query.Where("created_at >= ?", req.StartTime) +func (s *Service) GetAlgorithmMetrics(_ context.Context, req *GetMetricsReq) (*AlgorithmMetrics, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) } - if req.EndTime != nil { - query = query.Where("created_at <= ?", req.EndTime) + + logrus.WithFields(map[string]interface{}{ + "start_time": req.StartTime, + "end_time": req.EndTime, + }).Info("GetAlgorithmMetrics: starting") + + algorithms, err := s.repo.ListAlgorithmContainers() + if err != nil { + return nil, fmt.Errorf("failed to query algorithms: %w", err) } - // Apply fault type filter - if req.FaultType != nil { - query = query.Where("fault_type = ?", *req.FaultType) + metrics := &AlgorithmMetrics{ + Algorithms: make([]AlgorithmMetricItem, 0, len(algorithms)), } - if err := query.Find(&injections).Error; err != nil { - return nil, fmt.Errorf("failed to query injections: %w", err) + for _, algo := range algorithms { + if req.AlgorithmID != nil && algo.ID != *req.AlgorithmID { + continue + } + executions, err := s.repo.ListExecutions(func(db *gorm.DB) *gorm.DB { + query := db.Where("algorithm_id = ?", algo.ID) + if req.StartTime != nil { + query = query.Where("created_at >= ?", req.StartTime) + } + if req.EndTime != nil { + query = query.Where("created_at <= ?", req.EndTime) + } + return query + }) + if err != nil { + logrus.WithError(err).Warnf("failed to query executions for algorithm %d", algo.ID) + continue + } + item, ok := buildAlgorithmMetricItem(algo, executions) + if ok { + metrics.Algorithms = append(metrics.Algorithms, item) + } } - // Calculate metrics - metrics := &dto.InjectionMetrics{ + logrus.WithField("algorithm_count", len(metrics.Algorithms)).Info("GetAlgorithmMetrics: completed") + return metrics, nil +} + +func buildInjectionMetrics(injections []model.FaultInjection) *InjectionMetrics { + metrics := &InjectionMetrics{ TotalCount: len(injections), StateDistrib: make(map[string]int), FaultTypeDistrib: make(map[string]int), @@ -52,19 +143,15 @@ func GetInjectionMetrics(req *dto.GetMetricsReq) (*dto.InjectionMetrics, error) failedCount := 0 for _, inj := range injections { - // Count by state stateName := fmt.Sprintf("%d", inj.State) metrics.StateDistrib[stateName]++ - // Count by fault type faultTypeName := fmt.Sprintf("%d", inj.FaultType) metrics.FaultTypeDistrib[faultTypeName]++ - // Calculate duration stats if inj.StartTime != nil && inj.EndTime != nil { duration := inj.EndTime.Sub(*inj.StartTime).Seconds() totalDuration += duration - if metrics.MinDuration == 0 || duration < metrics.MinDuration { metrics.MinDuration = duration } @@ -73,61 +160,25 @@ func GetInjectionMetrics(req *dto.GetMetricsReq) (*dto.InjectionMetrics, error) } } - // Count success/failed switch inj.State { - case 2: // success state + case 2: successCount++ - case 3: // failed state + case 3: failedCount++ } } metrics.SuccessCount = successCount metrics.FailedCount = failedCount - if metrics.TotalCount > 0 { metrics.SuccessRate = float64(successCount) / float64(metrics.TotalCount) * 100 metrics.AvgDuration = totalDuration / float64(metrics.TotalCount) } - - logrus.WithField("metrics", metrics).Info("GetInjectionMetrics: completed") - return metrics, nil + return metrics } -// GetExecutionMetrics retrieves aggregated metrics for algorithm executions -func GetExecutionMetrics(req *dto.GetMetricsReq) (*dto.ExecutionMetrics, error) { - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("invalid request: %w", err) - } - - logrus.WithFields(map[string]interface{}{ - "start_time": req.StartTime, - "end_time": req.EndTime, - "algorithm_id": req.AlgorithmID, - }).Info("GetExecutionMetrics: starting") - - var executions []database.Execution - query := database.DB - - // Apply time range filter - if req.StartTime != nil { - query = query.Where("created_at >= ?", req.StartTime) - } - if req.EndTime != nil { - query = query.Where("created_at <= ?", req.EndTime) - } - - // Apply algorithm filter - if req.AlgorithmID != nil { - query = query.Where("algorithm_id = ?", *req.AlgorithmID) - } - - if err := query.Find(&executions).Error; err != nil { - return nil, fmt.Errorf("failed to query executions: %w", err) - } - - // Calculate metrics - metrics := &dto.ExecutionMetrics{ +func buildExecutionMetrics(executions []model.Execution) *ExecutionMetrics { + metrics := &ExecutionMetrics{ TotalCount: len(executions), StateDistrib: make(map[string]int), } @@ -137,14 +188,10 @@ func GetExecutionMetrics(req *dto.GetMetricsReq) (*dto.ExecutionMetrics, error) failedCount := 0 for _, exec := range executions { - // Count by state stateName := fmt.Sprintf("%d", exec.State) metrics.StateDistrib[stateName]++ - - // Calculate duration stats if exec.Duration > 0 { totalDuration += exec.Duration - if metrics.MinDuration == 0 || exec.Duration < metrics.MinDuration { metrics.MinDuration = exec.Duration } @@ -152,108 +199,52 @@ func GetExecutionMetrics(req *dto.GetMetricsReq) (*dto.ExecutionMetrics, error) metrics.MaxDuration = exec.Duration } } - - // Count success/failed switch exec.State { - case 2: // success state + case 2: successCount++ - case 3: // failed state + case 3: failedCount++ } } metrics.SuccessCount = successCount metrics.FailedCount = failedCount - if metrics.TotalCount > 0 { metrics.SuccessRate = float64(successCount) / float64(metrics.TotalCount) * 100 metrics.AvgDuration = totalDuration / float64(metrics.TotalCount) } - - logrus.WithField("metrics", metrics).Info("GetExecutionMetrics: completed") - return metrics, nil + return metrics } -// GetAlgorithmMetrics retrieves comparative metrics across different algorithms -func GetAlgorithmMetrics(req *dto.GetMetricsReq) (*dto.AlgorithmMetrics, error) { - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("invalid request: %w", err) - } - - logrus.WithFields(map[string]interface{}{ - "start_time": req.StartTime, - "end_time": req.EndTime, - }).Info("GetAlgorithmMetrics: starting") - - // Get all algorithms - var algorithms []database.Container - query := database.DB.Where("type = ?", 2) // Assuming 2 is algorithm type - - if err := query.Find(&algorithms).Error; err != nil { - return nil, fmt.Errorf("failed to query algorithms: %w", err) +func buildAlgorithmMetricItem(algo model.Container, executions []model.Execution) (AlgorithmMetricItem, bool) { + if len(executions) == 0 { + return AlgorithmMetricItem{}, false } - metrics := &dto.AlgorithmMetrics{ - Algorithms: make([]dto.AlgorithmMetricItem, 0, len(algorithms)), + item := AlgorithmMetricItem{ + AlgorithmID: algo.ID, + AlgorithmName: algo.Name, + ExecutionCount: len(executions), } - // Calculate metrics for each algorithm - for _, algo := range algorithms { - var executions []database.Execution - execQuery := database.DB.Where("algorithm_id = ?", algo.ID) - - // Apply time range filter - if req.StartTime != nil { - execQuery = execQuery.Where("created_at >= ?", req.StartTime) - } - if req.EndTime != nil { - execQuery = execQuery.Where("created_at <= ?", req.EndTime) - } - - if err := execQuery.Find(&executions).Error; err != nil { - logrus.WithError(err).Warnf("failed to query executions for algorithm %d", algo.ID) - continue - } - - if len(executions) == 0 { - continue - } - - item := dto.AlgorithmMetricItem{ - AlgorithmID: algo.ID, - AlgorithmName: algo.Name, - ExecutionCount: len(executions), - } - - var totalDuration float64 - successCount := 0 - failedCount := 0 - - for _, exec := range executions { - // Calculate duration stats - if exec.Duration > 0 { - totalDuration += exec.Duration - } - - // Count success/failed - switch exec.State { - case 2: // success state - successCount++ - case 3: // failed state - failedCount++ - } + var totalDuration float64 + successCount := 0 + failedCount := 0 + for _, exec := range executions { + if exec.Duration > 0 { + totalDuration += exec.Duration } - - item.SuccessCount = successCount - item.FailedCount = failedCount - item.SuccessRate = float64(successCount) / float64(item.ExecutionCount) * 100 - if item.ExecutionCount > 0 { - item.AvgDuration = totalDuration / float64(item.ExecutionCount) + switch exec.State { + case 2: + successCount++ + case 3: + failedCount++ } - - metrics.Algorithms = append(metrics.Algorithms, item) } - logrus.WithField("algorithm_count", len(metrics.Algorithms)).Info("GetAlgorithmMetrics: completed") - return metrics, nil + item.SuccessCount = successCount + item.FailedCount = failedCount + item.SuccessRate = float64(successCount) / float64(item.ExecutionCount) * 100 + item.AvgDuration = totalDuration / float64(item.ExecutionCount) + return item, true } diff --git a/src/dto/notification.go b/src/module/notification/api_types.go similarity index 95% rename from src/dto/notification.go rename to src/module/notification/api_types.go index e80df471..6fd83c50 100644 --- a/src/dto/notification.go +++ b/src/module/notification/api_types.go @@ -1,4 +1,4 @@ -package dto +package notification import "time" diff --git a/src/handlers/v2/notifications.go b/src/module/notification/handler.go similarity index 72% rename from src/handlers/v2/notifications.go rename to src/module/notification/handler.go index 59212aae..54342b71 100644 --- a/src/handlers/v2/notifications.go +++ b/src/module/notification/handler.go @@ -1,21 +1,29 @@ -package v2 +package notification import ( - "aegis/consts" - "aegis/dto" - producer "aegis/service/producer" "context" "errors" "fmt" "net/http" "time" + "aegis/consts" + "aegis/dto" + "github.com/gin-contrib/sse" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" "github.com/sirupsen/logrus" ) +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + // GetNotificationStream handles streaming of global workflow notifications via Server-Sent Events (SSE) // // @Summary Stream global notifications in real-time @@ -31,13 +39,13 @@ import ( // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/notifications/stream [get] // @x-request-type {"stream":"true"} -func GetNotificationStream(c *gin.Context) { - var req dto.GetNotificationStreamReq +// @x-api-type {"portal":"true"} +func (h *Handler) GetStream(c *gin.Context) { + var req GetNotificationStreamReq if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format") + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return @@ -45,18 +53,14 @@ func GetNotificationStream(c *gin.Context) { ctx, cancel := context.WithCancel(c.Request.Context()) defer cancel() - if c.IsAborted() { return } streamKey := consts.NotificationStreamKey - logEntry := logrus.WithFields(logrus.Fields{ - "stream_key": streamKey, - }) + logEntry := logrus.WithField("stream_key", streamKey) - logEntry.Infof("Reading historical notifications from Stream") - historicalMessages, err := producer.ReadNotificationStreamMessages(ctx, streamKey, req.LastID, 100, 0) + historicalMessages, err := h.service.ReadStreamMessages(ctx, streamKey, req.LastID, 100, 0) if err != nil { logEntry.Errorf("failed to read historical notifications from redis: %v", err) dto.ErrorResponse(c, http.StatusInternalServerError, "failed to read notification history") @@ -73,44 +77,33 @@ func GetNotificationStream(c *gin.Context) { req.LastID = lastID } - logEntry.Infof("Switching to real-time notification monitoring from ID: %s", req.LastID) for { select { case <-c.Done(): - logEntry.Info("Request context done") return - default: - newMessages, err := producer.ReadNotificationStreamMessages(ctx, streamKey, req.LastID, 10, time.Second) + newMessages, err := h.service.ReadStreamMessages(ctx, streamKey, req.LastID, 10, time.Second) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - logEntry.Infof("Context done while reading stream: %v", err) return } - logEntry.Errorf("Error reading notification stream: %v", err) dto.ErrorResponse(c, http.StatusInternalServerError, "failed to read notification events") return } - if len(newMessages) == 0 { - logEntry.Debug("No new notifications, continuing") continue } - lastID, err := sendNotificationSSEEvents(c, newMessages) if err != nil { logEntry.Errorf("failed to send notification events of ID %s: %v", lastID, err) return } - req.LastID = lastID - logrus.Info("Sent notification SSE messages, lastID:", lastID) } } } -// sendNotificationSSEEvents processes and sends notification messages as SSE events func sendNotificationSSEEvents(c *gin.Context, streams []redis.XStream) (string, error) { if len(streams) == 0 || len(streams[0].Messages) == 0 { return "", fmt.Errorf("no messages to process") @@ -119,27 +112,15 @@ func sendNotificationSSEEvents(c *gin.Context, streams []redis.XStream) (string, var lastID string for _, msg := range streams[0].Messages { lastID = msg.ID - - // Parse notification event from message notification := parseNotificationMessage(msg) - - c.Render(-1, sse.Event{ - Id: lastID, - Event: "notification", - Data: notification, - }) + c.Render(-1, sse.Event{Id: lastID, Event: "notification", Data: notification}) c.Writer.Flush() } - return lastID, nil } -// parseNotificationMessage converts a Redis stream message to a notification event -func parseNotificationMessage(msg redis.XMessage) dto.NotificationEvent { - notification := dto.NotificationEvent{ - Timestamp: time.Now(), - } - +func parseNotificationMessage(msg redis.XMessage) NotificationEvent { + notification := NotificationEvent{Timestamp: time.Now()} for key, val := range msg.Values { switch key { case "type": @@ -152,6 +133,5 @@ func parseNotificationMessage(msg redis.XMessage) dto.NotificationEvent { notification.Status = val.(string) } } - return notification } diff --git a/src/module/notification/handler_service.go b/src/module/notification/handler_service.go new file mode 100644 index 00000000..6c838a52 --- /dev/null +++ b/src/module/notification/handler_service.go @@ -0,0 +1,17 @@ +package notification + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" +) + +// HandlerService captures notification stream operations consumed by HTTP handlers and gateway adapters. +type HandlerService interface { + ReadStreamMessages(context.Context, string, string, int64, time.Duration) ([]redis.XStream, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/notification/module.go b/src/module/notification/module.go new file mode 100644 index 00000000..21a1d42f --- /dev/null +++ b/src/module/notification/module.go @@ -0,0 +1,10 @@ +package notification + +import "go.uber.org/fx" + +var Module = fx.Module("notification", + fx.Provide(NewRepository), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/module/notification/repository.go b/src/module/notification/repository.go new file mode 100644 index 00000000..48e5426b --- /dev/null +++ b/src/module/notification/repository.go @@ -0,0 +1,11 @@ +package notification + +import "gorm.io/gorm" + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} diff --git a/src/module/notification/service.go b/src/module/notification/service.go new file mode 100644 index 00000000..40c01716 --- /dev/null +++ b/src/module/notification/service.go @@ -0,0 +1,32 @@ +package notification + +import ( + "context" + "fmt" + "time" + + redisinfra "aegis/infra/redis" + + goredis "github.com/redis/go-redis/v9" +) + +type Service struct { + repo *Repository + redis *redisinfra.Gateway +} + +func NewService(repo *Repository, redis *redisinfra.Gateway) *Service { + return &Service{repo: repo, redis: redis} +} + +func (s *Service) ReadStreamMessages(ctx context.Context, streamKey, lastID string, count int64, block time.Duration) ([]goredis.XStream, error) { + if lastID == "" { + lastID = "0" + } + + messages, err := s.redis.XRead(ctx, []string{streamKey, lastID}, count, block) + if err != nil { + return nil, fmt.Errorf("failed to read notification stream messages: %w", err) + } + return messages, nil +} diff --git a/src/module/pedestal/api_types.go b/src/module/pedestal/api_types.go new file mode 100644 index 00000000..500cb1bc --- /dev/null +++ b/src/module/pedestal/api_types.go @@ -0,0 +1,39 @@ +package pedestal + +// ---------------------- Pedestal Helm DTOs ------------------ + +// PedestalHelmConfigResp represents a full helm_configs row for CLI/API consumers. +type PedestalHelmConfigResp struct { + ID int `json:"id"` + ContainerVersionID int `json:"container_version_id"` + ChartName string `json:"chart_name"` + Version string `json:"version"` + RepoURL string `json:"repo_url"` + RepoName string `json:"repo_name"` + ValueFile string `json:"value_file"` + LocalPath string `json:"local_path"` + Checksum string `json:"checksum"` +} + +// UpsertPedestalHelmConfigReq is the body for PUT /api/v2/pedestal/helm/:container_version_id +type UpsertPedestalHelmConfigReq struct { + ChartName string `json:"chart_name" binding:"required"` + Version string `json:"version" binding:"required"` + RepoURL string `json:"repo_url" binding:"required"` + RepoName string `json:"repo_name" binding:"required"` + ValueFile string `json:"value_file"` + LocalPath string `json:"local_path"` +} + +// PedestalHelmVerifyCheck is a single step in the verify pipeline. +type PedestalHelmVerifyCheck struct { + Name string `json:"name"` + OK bool `json:"ok"` + Detail string `json:"detail,omitempty"` +} + +// PedestalHelmVerifyResp is the aggregated verify response. +type PedestalHelmVerifyResp struct { + OK bool `json:"ok"` + Checks []PedestalHelmVerifyCheck `json:"checks"` +} diff --git a/src/module/pedestal/handler.go b/src/module/pedestal/handler.go new file mode 100644 index 00000000..6f1c4333 --- /dev/null +++ b/src/module/pedestal/handler.go @@ -0,0 +1,178 @@ +package pedestal + +import ( + "errors" + "net/http" + "strconv" + + "aegis/dto" + "aegis/middleware" + "aegis/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type Handler struct { + repo *Repository + runner Runner +} + +func NewHandler(repo *Repository) *Handler { + return &Handler{repo: repo, runner: RealRunner{}} +} + +// GetPedestalHelmConfig returns the helm_configs row for a given container_version_id. +// +// @Summary Get pedestal helm config +// @Description Retrieve the helm chart configuration bound to a pedestal container version. +// @Tags Pedestal +// @ID get_pedestal_helm_config +// @Produce json +// @Security BearerAuth +// @Param container_version_id path int true "Container version ID" +// @Success 200 {object} dto.GenericResponse[PedestalHelmConfigResp] +// @Failure 400 {object} dto.GenericResponse[any] +// @Failure 401 {object} dto.GenericResponse[any] +// @Failure 404 {object} dto.GenericResponse[any] +// @Router /api/v2/pedestal/helm/{container_version_id} [get] +// @x-api-type {"sdk":"true"} +func (h *Handler) GetPedestalHelmConfig(c *gin.Context) { + if _, ok := middleware.GetCurrentUserID(c); !ok { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + versionID, ok := parseVersionID(c) + if !ok { + return + } + + cfg, err := h.repo.GetHelmConfigByContainerVersionID(versionID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + dto.ErrorResponse(c, http.StatusNotFound, "Helm config not found for container_version_id") + return + } + dto.ErrorResponse(c, http.StatusInternalServerError, "Failed to load helm config: "+err.Error()) + return + } + + dto.SuccessResponse(c, toHelmConfigResp(cfg)) +} + +// UpsertPedestalHelmConfig creates or updates the helm_configs row for the given container version. +// +// @Summary Upsert pedestal helm config +// @Description Create or update the helm_configs row for a pedestal container version. Admin-only. +// @Tags Pedestal +// @ID upsert_pedestal_helm_config +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param container_version_id path int true "Container version ID" +// @Param request body UpsertPedestalHelmConfigReq true "Helm config fields" +// @Success 200 {object} dto.GenericResponse[PedestalHelmConfigResp] +// @Router /api/v2/pedestal/helm/{container_version_id} [put] +// @x-api-type {"sdk":"true"} +func (h *Handler) UpsertPedestalHelmConfig(c *gin.Context) { + if _, ok := middleware.GetCurrentUserID(c); !ok { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + versionID, ok := parseVersionID(c) + if !ok { + return + } + + var req UpsertPedestalHelmConfigReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + fresh, err := h.repo.UpsertHelmConfig(versionID, &model.HelmConfig{ + ChartName: req.ChartName, + Version: req.Version, + RepoURL: req.RepoURL, + RepoName: req.RepoName, + ValueFile: req.ValueFile, + LocalPath: req.LocalPath, + }) + if err != nil { + dto.ErrorResponse(c, http.StatusInternalServerError, "Failed to upsert helm config: "+err.Error()) + return + } + + dto.SuccessResponse(c, toHelmConfigResp(fresh)) +} + +// VerifyPedestalHelmConfig dry-runs helm repo add + helm pull + value-file parse. +// +// @Summary Verify pedestal helm config +// @Description Dry-run helm repo add + pull and parse the values file without starting a task. +// @Tags Pedestal +// @ID verify_pedestal_helm_config +// @Produce json +// @Security BearerAuth +// @Param container_version_id path int true "Container version ID" +// @Success 200 {object} dto.GenericResponse[PedestalHelmVerifyResp] +// @Router /api/v2/pedestal/helm/{container_version_id}/verify [post] +// @x-api-type {"sdk":"true"} +func (h *Handler) VerifyPedestalHelmConfig(c *gin.Context) { + if _, ok := middleware.GetCurrentUserID(c); !ok { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + versionID, ok := parseVersionID(c) + if !ok { + return + } + + cfg, err := h.repo.GetHelmConfigByContainerVersionID(versionID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + dto.ErrorResponse(c, http.StatusNotFound, "Helm config not found for container_version_id") + return + } + dto.ErrorResponse(c, http.StatusInternalServerError, "Failed to load helm config: "+err.Error()) + return + } + + result := Run(h.runner, Config{ + ChartName: cfg.ChartName, + Version: cfg.Version, + RepoURL: cfg.RepoURL, + RepoName: cfg.RepoName, + ValueFile: cfg.ValueFile, + }, VerifyValueFile) + + resp := PedestalHelmVerifyResp{OK: result.OK, Checks: make([]PedestalHelmVerifyCheck, len(result.Checks))} + for i, chk := range result.Checks { + resp.Checks[i] = PedestalHelmVerifyCheck{Name: chk.Name, OK: chk.OK, Detail: chk.Detail} + } + dto.SuccessResponse(c, resp) +} + +func parseVersionID(c *gin.Context) (int, bool) { + raw := c.Param("container_version_id") + id, err := strconv.Atoi(raw) + if err != nil || id <= 0 { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container_version_id: "+raw) + return 0, false + } + return id, true +} + +func toHelmConfigResp(cfg *model.HelmConfig) PedestalHelmConfigResp { + return PedestalHelmConfigResp{ + ID: cfg.ID, + ContainerVersionID: cfg.ContainerVersionID, + ChartName: cfg.ChartName, + Version: cfg.Version, + RepoURL: cfg.RepoURL, + RepoName: cfg.RepoName, + ValueFile: cfg.ValueFile, + LocalPath: cfg.LocalPath, + Checksum: cfg.Checksum, + } +} diff --git a/src/module/pedestal/module.go b/src/module/pedestal/module.go new file mode 100644 index 00000000..0efbef1b --- /dev/null +++ b/src/module/pedestal/module.go @@ -0,0 +1,8 @@ +package pedestal + +import "go.uber.org/fx" + +var Module = fx.Module("pedestal", + fx.Provide(NewRepository), + fx.Provide(NewHandler), +) diff --git a/src/module/pedestal/repository.go b/src/module/pedestal/repository.go new file mode 100644 index 00000000..9f9bff13 --- /dev/null +++ b/src/module/pedestal/repository.go @@ -0,0 +1,53 @@ +package pedestal + +import ( + "fmt" + + "aegis/model" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +// GetHelmConfigByContainerVersionID returns the helm_configs row bound to the +// given container version. Returns gorm.ErrRecordNotFound if absent. +func (r *Repository) GetHelmConfigByContainerVersionID(versionID int) (*model.HelmConfig, error) { + var cfg model.HelmConfig + if err := r.db.Where("container_version_id = ?", versionID).First(&cfg).Error; err != nil { + return nil, err + } + return &cfg, nil +} + +// UpsertHelmConfig creates a new row if versionID has none, else updates +// the existing row in place. Returns the (fresh) row. +func (r *Repository) UpsertHelmConfig(versionID int, fields *model.HelmConfig) (*model.HelmConfig, error) { + existing, err := r.GetHelmConfigByContainerVersionID(versionID) + if err != nil && err != gorm.ErrRecordNotFound { + return nil, fmt.Errorf("query existing helm config: %w", err) + } + if existing != nil && existing.ID != 0 { + existing.ChartName = fields.ChartName + existing.Version = fields.Version + existing.RepoURL = fields.RepoURL + existing.RepoName = fields.RepoName + existing.ValueFile = fields.ValueFile + existing.LocalPath = fields.LocalPath + if err := r.db.Save(existing).Error; err != nil { + return nil, fmt.Errorf("update helm config: %w", err) + } + return existing, nil + } + fields.ContainerVersionID = versionID + if err := r.db.Create(fields).Error; err != nil { + return nil, fmt.Errorf("create helm config: %w", err) + } + return fields, nil +} diff --git a/src/handlers/v2/pedestalhelm/verify.go b/src/module/pedestal/verify.go similarity index 68% rename from src/handlers/v2/pedestalhelm/verify.go rename to src/module/pedestal/verify.go index 29caa26c..938e07ee 100644 --- a/src/handlers/v2/pedestalhelm/verify.go +++ b/src/module/pedestal/verify.go @@ -1,10 +1,6 @@ -// Package pedestalhelm holds the dry-run verification pipeline for -// helm_configs rows used by the /api/v2/pedestal/helm/:id/verify endpoint. -// -// It is intentionally separated from the handlers/v2 package so the pure -// pipeline can be unit-tested without dragging in the full server build -// graph (which currently has an unrelated compile break in injections.go). -package pedestalhelm +// Package pedestal holds dry-run verification pipeline for helm_configs rows +// used by the /api/v2/pedestal/helm/:id/verify endpoint. +package pedestal import ( "fmt" @@ -15,8 +11,7 @@ import ( "gopkg.in/yaml.v3" ) -// Config is the minimal projection of database.HelmConfig that the verify -// pipeline needs. We avoid importing the full entity graph here. +// Config is the minimal projection of model.HelmConfig that the verify pipeline needs. type Config struct { ChartName string Version string @@ -49,38 +44,28 @@ type Runner interface { type RealRunner struct{} func (RealRunner) RepoAdd(name, url string) (string, error) { - cmd := exec.Command("helm", "repo", "add", name, url, "--force-update") - out, err := cmd.CombinedOutput() + out, err := exec.Command("helm", "repo", "add", name, url, "--force-update").CombinedOutput() return string(out), err } func (RealRunner) RepoUpdate() (string, error) { - cmd := exec.Command("helm", "repo", "update") - out, err := cmd.CombinedOutput() + out, err := exec.Command("helm", "repo", "update").CombinedOutput() return string(out), err } func (RealRunner) Pull(repo, chart, version, destDir string) (string, error) { - cmd := exec.Command("helm", "pull", fmt.Sprintf("%s/%s", repo, chart), - "--version", version, "--destination", destDir) - out, err := cmd.CombinedOutput() + out, err := exec.Command("helm", "pull", fmt.Sprintf("%s/%s", repo, chart), + "--version", version, "--destination", destDir).CombinedOutput() return string(out), err } // Run drives the check pipeline. -// -// TODO: add `skopeo inspect` reachability checks for image.repository / -// image.tag pairs found in the values file. Intentionally omitted today -// because the skopeo round-trip is slow and flaky on constrained -// networks; callers can gate verification separately once image -// reachability matters. func Run(runner Runner, cfg Config, valueFileVerifier func(string) error) Result { checks := make([]Check, 0, 4) if out, err := runner.RepoAdd(cfg.RepoName, cfg.RepoURL); err != nil { checks = append(checks, Check{ - Name: "repo_add", - OK: false, + Name: "repo_add", OK: false, Detail: fmt.Sprintf("helm repo add failed: %v\n%s", err, out), }) return Result{OK: false, Checks: checks} @@ -89,8 +74,7 @@ func Run(runner Runner, cfg Config, valueFileVerifier func(string) error) Result if out, err := runner.RepoUpdate(); err != nil { checks = append(checks, Check{ - Name: "repo_update", - OK: false, + Name: "repo_update", OK: false, Detail: fmt.Sprintf("helm repo update failed: %v\n%s", err, out), }) return Result{OK: false, Checks: checks} @@ -110,8 +94,7 @@ func Run(runner Runner, cfg Config, valueFileVerifier func(string) error) Result if out, err := runner.Pull(cfg.RepoName, cfg.ChartName, cfg.Version, tmpDir); err != nil { allOK = false checks = append(checks, Check{ - Name: "helm_pull", - OK: false, + Name: "helm_pull", OK: false, Detail: fmt.Sprintf("helm pull failed: %v\n%s", err, out), }) } else { @@ -121,9 +104,7 @@ func Run(runner Runner, cfg Config, valueFileVerifier func(string) error) Result if cfg.ValueFile != "" { if err := valueFileVerifier(cfg.ValueFile); err != nil { allOK = false - checks = append(checks, Check{ - Name: "value_file", OK: false, Detail: err.Error(), - }) + checks = append(checks, Check{Name: "value_file", OK: false, Detail: err.Error()}) } else { checks = append(checks, Check{Name: "value_file", OK: true}) } @@ -133,8 +114,6 @@ func Run(runner Runner, cfg Config, valueFileVerifier func(string) error) Result } // VerifyValueFile opens the values file and asserts it parses as YAML. -// If image.repository / image.tag pairs exist they are required to be -// scalar; reachability is intentionally skipped (see Run's TODO). func VerifyValueFile(path string) error { abs := path if !filepath.IsAbs(abs) { diff --git a/src/module/project/api_types.go b/src/module/project/api_types.go new file mode 100644 index 00000000..1cea634f --- /dev/null +++ b/src/module/project/api_types.go @@ -0,0 +1,188 @@ +package project + +import ( + "fmt" + "strings" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/model" + container "aegis/module/container" + dataset "aegis/module/dataset" + injection "aegis/module/injection" +) + +type ProjectContainerItem = container.ContainerResp +type ProjectDatasetItem = dataset.DatasetResp + +// CreateProjectReq represents project creation request. +type CreateProjectReq struct { + Name string `json:"name" binding:"required"` + Description string `json:"description" binding:"omitempty"` + IsPublic *bool `json:"is_public" binding:"omitempty"` +} + +func (req *CreateProjectReq) Validate() error { + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + return fmt.Errorf("project name cannot be empty") + } + if req.IsPublic == nil { + defaultPublic := true + req.IsPublic = &defaultPublic + } + return nil +} + +func (req *CreateProjectReq) ConvertToProject() *model.Project { + return &model.Project{ + Name: req.Name, + Description: req.Description, + IsPublic: *req.IsPublic, + Status: consts.CommonEnabled, + } +} + +// ListProjectReq represents project list query parameters. +type ListProjectReq struct { + dto.PaginationReq + IsPublic *bool `form:"is_public" binding:"omitempty"` + Status *consts.StatusType `form:"status" binding:"omitempty"` + TeamID *int `form:"team_id" binding:"omitempty"` + IncludeStatistics *bool `form:"include_statistics" binding:"omitempty"` +} + +func (req *ListProjectReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + if req.TeamID != nil && *req.TeamID <= 0 { + return fmt.Errorf("team_id must be greater than 0") + } + return validateStatus(req.Status, false) +} + +// UpdateProjectReq represents project update request. +type UpdateProjectReq struct { + Description *string `json:"description,omitempty"` + IsPublic *bool `json:"is_public,omitempty"` + Status *consts.StatusType `json:"status,omitempty"` +} + +func (req *UpdateProjectReq) Validate() error { + return validateStatus(req.Status, true) +} + +func (req *UpdateProjectReq) PatchProjectModel(target *model.Project) { + if req.Description != nil { + target.Description = *req.Description + } + if req.IsPublic != nil { + target.IsPublic = *req.IsPublic + } + if req.Status != nil { + target.Status = *req.Status + } +} + +// ManageProjectLabelReq represents project label management request. +type ManageProjectLabelReq struct { + AddLabels []dto.LabelItem `json:"add_labels" binding:"omitempty"` + RemoveLabels []string `json:"remove_labels" binding:"omitempty"` +} + +func (req *ManageProjectLabelReq) Validate() error { + if len(req.AddLabels) == 0 && len(req.RemoveLabels) == 0 { + return fmt.Errorf("at least one of add_labels or remove_labels must be provided") + } + + for i, label := range req.AddLabels { + if strings.TrimSpace(label.Key) == "" { + return fmt.Errorf("empty label key at index %d in add_labels", i) + } + if strings.TrimSpace(label.Value) == "" { + return fmt.Errorf("empty label value at index %d in add_labels", i) + } + } + + for i, key := range req.RemoveLabels { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("empty label key at index %d in remove_labels", i) + } + } + + return nil +} + +// ProjectResp represents basic project response. +type ProjectResp struct { + ID int `json:"id"` + Name string `json:"name"` + IsPublic bool `json:"is_public"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + LastInjectionAt *time.Time `json:"last_injection_at,omitempty"` + LastExecutionAt *time.Time `json:"last_execution_at,omitempty"` + InjectionCount int `json:"injection_count"` + ExecutionCount int `json:"execution_count"` + Labels []dto.LabelItem `json:"labels,omitempty"` +} + +func NewProjectResp(project *model.Project, stats *dto.ProjectStatistics) *ProjectResp { + resp := &ProjectResp{ + ID: project.ID, + Name: project.Name, + IsPublic: project.IsPublic, + Status: consts.GetStatusTypeName(project.Status), + CreatedAt: project.CreatedAt, + UpdatedAt: project.UpdatedAt, + } + + if stats != nil { + resp.LastInjectionAt = stats.LastInjectionAt + resp.LastExecutionAt = stats.LastExecutionAt + resp.InjectionCount = stats.InjectionCount + resp.ExecutionCount = stats.ExecutionCount + } + + if project.Labels != nil { + resp.Labels = make([]dto.LabelItem, len(project.Labels)) + for i, label := range project.Labels { + resp.Labels[i] = dto.LabelItem{Key: label.Key, Value: label.Value} + } + } + return resp +} + +// ProjectDetailResp represents detailed project response. +type ProjectDetailResp struct { + ProjectResp + + Containers []ProjectContainerItem `json:"containers,omitempty"` + Datapacks []injection.InjectionResp `json:"datapacks,omitempty"` + Datasets []ProjectDatasetItem `json:"datasets,omitempty"` + UserCount int `json:"user_count"` +} + +func NewProjectDetailResp(project *model.Project, stats *dto.ProjectStatistics) *ProjectDetailResp { + return &ProjectDetailResp{ + ProjectResp: *NewProjectResp(project, stats), + } +} + +func validateStatus(statusPtr *consts.StatusType, isMutation bool) error { + if statusPtr == nil { + return nil + } + status := *statusPtr + if _, exists := consts.ValidStatuses[status]; !exists { + return fmt.Errorf("invalid status value: %d", status) + } + if isMutation && status == consts.CommonDeleted { + return fmt.Errorf("status value cannot be set to deleted (%d) directly through this update/create operation", consts.CommonDeleted) + } + return nil +} diff --git a/src/module/project/handler.go b/src/module/project/handler.go new file mode 100644 index 00000000..fb0388fa --- /dev/null +++ b/src/module/project/handler.go @@ -0,0 +1,264 @@ +package project + +import ( + "aegis/httpx" + "net/http" + "strconv" + + "aegis/consts" + "aegis/dto" + "aegis/middleware" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + +// CreateProject handles project creation +// +// @Summary Create a new project +// @Description Create a new project with specified details +// @Tags Projects +// @ID create_project +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body CreateProjectReq true "Project creation request" +// @Success 201 {object} dto.GenericResponse[ProjectResp] "Project created successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 409 {object} dto.GenericResponse[any] "Project already exists" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/projects [post] +// @x-api-type {"portal":"true"} +func (h *Handler) CreateProject(c *gin.Context) { + userID, exists := middleware.GetCurrentUserID(c) + if !exists { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + + var req CreateProjectReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.CreateProject(c.Request.Context(), &req, userID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse(c, http.StatusCreated, "Project created successfully", resp) +} + +// DeleteProject handles project deletion +// +// @Summary Delete project +// @Description Delete a project +// @Tags Projects +// @ID delete_project +// @Produce json +// @Security BearerAuth +// @Param project_id path int true "Project ID" +// @Success 204 {object} dto.GenericResponse[any] "Project deleted successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid project ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Project not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/projects/{project_id} [delete] +// @x-api-type {"portal":"true"} +func (h *Handler) DeleteProject(c *gin.Context) { + projectID, ok := parseProjectID(c) + if !ok { + return + } + + err := h.service.DeleteProject(c.Request.Context(), projectID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse[any](c, http.StatusNoContent, "Project deleted successfully", nil) +} + +// GetProjectDetail handles getting a single project by ID +// +// @Summary Get project by ID +// @Description Get detailed information about a specific project +// @Tags Projects +// @ID get_project_by_id +// @Produce json +// @Security BearerAuth +// @Param project_id path int true "Project ID" +// @Success 200 {object} dto.GenericResponse[ProjectDetailResp] "Project retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid project ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Project not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/projects/{project_id} [get] +// @x-api-type {"portal":"true"} +func (h *Handler) GetProjectDetail(c *gin.Context) { + projectID, ok := parseProjectID(c) + if !ok { + return + } + + resp, err := h.service.GetProjectDetail(c.Request.Context(), projectID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.SuccessResponse(c, resp) +} + +// ListProjects handles listing projects with pagination and filtering +// +// @Summary List projects +// @Description Get paginated list of projects with filtering +// @Tags Projects +// @ID list_projects +// @Produce json +// @Security BearerAuth +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Param is_public query bool false "Filter by public status" +// @Param status query consts.StatusType false "Filter by status" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[ProjectResp]] "Projects retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/projects [get] +// @x-api-type {"portal":"true"} +func (h *Handler) ListProjects(c *gin.Context) { + var req ListProjectReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.ListProjects(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + + dto.SuccessResponse(c, resp) +} + +// UpdateProject handles project updates +// +// @Summary Update project +// @Description Update an existing project's information +// @Tags Projects +// @ID update_project +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param project_id path int true "Project ID" +// @Param request body UpdateProjectReq true "Project update request" +// @Success 202 {object} dto.GenericResponse[ProjectResp] "Project updated successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid project ID or invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Project not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/projects/{project_id} [patch] +// @x-api-type {"portal":"true"} +func (h *Handler) UpdateProject(c *gin.Context) { + projectID, ok := parseProjectID(c) + if !ok { + return + } + + var req UpdateProjectReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.UpdateProject(c.Request.Context(), &req, projectID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.JSONResponse[any](c, http.StatusAccepted, "Project updated successfully", resp) +} + +// ManageProjectCustomLabels manages project custom labels (key-value pairs) +// +// @Summary Manage project custom labels +// @Description Add or remove custom labels (key-value pairs) for a project +// @Tags Projects +// @ID update_project_labels +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param project_id path int true "Project ID" +// @Param manage body ManageProjectLabelReq true "Label management request" +// @Success 200 {object} dto.GenericResponse[ProjectResp] "Labels managed successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid project ID or invalid request format/parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Project not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/projects/{project_id}/labels [patch] +// @x-api-type {"portal":"true"} +func (h *Handler) ManageProjectCustomLabels(c *gin.Context) { + projectID, ok := parseProjectID(c) + if !ok { + return + } + + var req ManageProjectLabelReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.ManageProjectLabels(c.Request.Context(), &req, projectID) + if httpx.HandleServiceError(c, err) { + return + } + + dto.SuccessResponse(c, resp) +} + +func parseProjectID(c *gin.Context) (int, bool) { + projectIDStr := c.Param(consts.URLPathProjectID) + projectID, err := strconv.Atoi(projectIDStr) + if err != nil || projectID <= 0 { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") + return 0, false + } + return projectID, true +} diff --git a/src/module/project/handler_service.go b/src/module/project/handler_service.go new file mode 100644 index 00000000..2463064d --- /dev/null +++ b/src/module/project/handler_service.go @@ -0,0 +1,21 @@ +package project + +import ( + "context" + + "aegis/dto" +) + +// HandlerService captures the project operations consumed by the HTTP handler. +type HandlerService interface { + CreateProject(context.Context, *CreateProjectReq, int) (*ProjectResp, error) + DeleteProject(context.Context, int) error + GetProjectDetail(context.Context, int) (*ProjectDetailResp, error) + ListProjects(context.Context, *ListProjectReq) (*dto.ListResp[ProjectResp], error) + UpdateProject(context.Context, *UpdateProjectReq, int) (*ProjectResp, error) + ManageProjectLabels(context.Context, *ManageProjectLabelReq, int) (*ProjectResp, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/project/module.go b/src/module/project/module.go new file mode 100644 index 00000000..48d8eeac --- /dev/null +++ b/src/module/project/module.go @@ -0,0 +1,15 @@ +package project + +import ( + "go.uber.org/fx" +) + +var Module = fx.Module("project", + fx.Provide( + NewRepository, + newProjectStatisticsSource, + NewService, + AsHandlerService, + NewHandler, + ), +) diff --git a/src/module/project/project_statistics.go b/src/module/project/project_statistics.go new file mode 100644 index 00000000..997892df --- /dev/null +++ b/src/module/project/project_statistics.go @@ -0,0 +1,61 @@ +package project + +import ( + "context" + "fmt" + + "aegis/dto" + "aegis/internalclient/orchestratorclient" + + "go.uber.org/fx" +) + +type projectStatisticsSource interface { + ListProjectStatistics(context.Context, []int) (map[int]*dto.ProjectStatistics, error) +} + +type projectStatisticsSourceParams struct { + fx.In + + Repository *Repository + Orchestrator *orchestratorclient.Client `optional:"true"` +} + +type projectStatisticsAdapter struct { + orchestrator *orchestratorclient.Client + repository *Repository + requireRemote bool +} + +func newProjectStatisticsSource(params projectStatisticsSourceParams) projectStatisticsSource { + return projectStatisticsAdapter{ + orchestrator: params.Orchestrator, + repository: params.Repository, + } +} + +func newRemoteProjectStatisticsSource(params projectStatisticsSourceParams) projectStatisticsSource { + return projectStatisticsAdapter{ + orchestrator: params.Orchestrator, + repository: params.Repository, + requireRemote: true, + } +} + +func (a projectStatisticsAdapter) ListProjectStatistics(ctx context.Context, projectIDs []int) (map[int]*dto.ProjectStatistics, error) { + if a.orchestrator != nil && a.orchestrator.Enabled() { + return a.orchestrator.ListProjectStatistics(ctx, projectIDs) + } + if a.requireRemote { + return nil, fmt.Errorf("orchestrator-service project statistics source is not configured") + } + if a.repository == nil { + return nil, fmt.Errorf("project statistics source is not configured") + } + return a.repository.ListProjectStatistics(projectIDs) +} + +// RemoteStatisticsOption forces the dedicated resource-service path to use orchestrator RPC only. +func RemoteStatisticsOption() fx.Option { + return fx.Decorate(newRemoteProjectStatisticsSource) +} diff --git a/src/module/project/repository.go b/src/module/project/repository.go new file mode 100644 index 00000000..7255ade8 --- /dev/null +++ b/src/module/project/repository.go @@ -0,0 +1,256 @@ +package project + +import ( + "aegis/consts" + "aegis/dto" + "aegis/model" + "fmt" + "time" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) createProjectWithOwner(project *model.Project, userID int) error { + var role model.Role + if err := r.db.Where("name = ? AND status != ?", consts.RoleProjectAdmin.String(), consts.CommonDeleted). + First(&role).Error; err != nil { + return fmt.Errorf("failed to get project owner role: %w", err) + } + + if err := r.db.Omit("ActiveName").Create(project).Error; err != nil { + return fmt.Errorf("failed to create project: %w", err) + } + + if err := r.db.Create(&model.UserProject{ + UserID: userID, + ProjectID: project.ID, + RoleID: role.ID, + Status: consts.CommonEnabled, + }).Error; err != nil { + return fmt.Errorf("failed to create user-project association: %w", err) + } + return nil +} + +func (r *Repository) deleteProjectCascade(projectID int) (int64, error) { + if err := r.db.Model(&model.UserProject{}). + Where("project_id = ? AND status != ?", projectID, consts.CommonDeleted). + Update("status", consts.CommonDeleted).Error; err != nil { + return 0, fmt.Errorf("failed to remove users from project: %w", err) + } + + result := r.db.Model(&model.Project{}). + Where("id = ? AND status != ?", projectID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if result.Error != nil { + return 0, fmt.Errorf("failed to soft delete project %d: %w", projectID, result.Error) + } + return result.RowsAffected, nil +} + +func (r *Repository) loadProjectDetailBase(projectID int) (*model.Project, int, error) { + project, err := r.loadProjectRecord(projectID) + if err != nil { + return nil, 0, err + } + + var userCount int64 + if err := r.db.Model(&model.UserProject{}). + Where("project_id = ? AND status = ?", project.ID, consts.CommonEnabled). + Count(&userCount).Error; err != nil { + return nil, 0, err + } + + return project, int(userCount), nil +} + +func (r *Repository) listProjectViews(limit, offset int, isPublic *bool, status *consts.StatusType, teamID *int) ([]model.Project, int64, error) { + var ( + projects []model.Project + total int64 + ) + + query := r.db.Model(&model.Project{}) + if teamID != nil { + query = query.Where("team_id = ?", *teamID) + } + if isPublic != nil { + query = query.Where("is_public = ?", *isPublic) + } + if status != nil { + query = query.Where("status = ?", *status) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count projects: %w", err) + } + if err := query.Limit(limit).Offset(offset).Find(&projects).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list projects: %w", err) + } + + projectIDs := make([]int, 0, len(projects)) + for _, project := range projects { + projectIDs = append(projectIDs, project.ID) + } + + type projectLabelResult struct { + model.Label + ProjectID int `gorm:"column:project_id"` + } + + labelsMap := make(map[int][]model.Label, len(projectIDs)) + for _, projectID := range projectIDs { + labelsMap[projectID] = []model.Label{} + } + if len(projectIDs) > 0 { + var flatResults []projectLabelResult + if err := r.db.Model(&model.Label{}). + Joins("JOIN project_labels pl ON pl.label_id = labels.id"). + Where("pl.project_id IN (?)", projectIDs). + Select("labels.*, pl.project_id"). + Find(&flatResults).Error; err != nil { + return nil, 0, fmt.Errorf("failed to batch query project labels: %w", err) + } + + for _, result := range flatResults { + labelsMap[result.ProjectID] = append(labelsMap[result.ProjectID], result.Label) + } + } + + for i := range projects { + projects[i].Labels = labelsMap[projects[i].ID] + } + + return projects, total, nil +} + +func (r *Repository) updateMutableProject(projectID int, patch func(*model.Project)) (*model.Project, error) { + var project model.Project + if err := r.db.Where("id = ?", projectID).First(&project).Error; err != nil { + return nil, fmt.Errorf("failed to find project with id %d: %w", projectID, err) + } + patch(&project) + if err := r.db.Omit("ActiveName").Save(&project).Error; err != nil { + return nil, fmt.Errorf("failed to update project: %w", err) + } + return &project, nil +} + +func (r *Repository) manageProjectLabels(projectID int, addLabelIDs []int, removeKeys []string) (*model.Project, error) { + project, err := r.loadProjectRecord(projectID) + if err != nil { + return nil, err + } + + if len(addLabelIDs) > 0 { + projectLabels := make([]model.ProjectLabel, 0, len(addLabelIDs)) + for _, labelID := range addLabelIDs { + projectLabels = append(projectLabels, model.ProjectLabel{ + ProjectID: projectID, + LabelID: labelID, + }) + } + if err := r.db.Create(&projectLabels).Error; err != nil { + return nil, fmt.Errorf("failed to add project-label associations: %w", err) + } + } + + if len(removeKeys) > 0 { + var labelIDs []int + if err := r.db.Table("labels l"). + Select("l.id"). + Joins("JOIN project_labels pl ON pl.label_id = l.id"). + Where("pl.project_id = ? AND l.label_key IN (?)", projectID, removeKeys). + Pluck("l.id", &labelIDs).Error; err != nil { + return nil, fmt.Errorf("failed to find label IDs by key '%v': %w", removeKeys, err) + } + if len(labelIDs) > 0 { + if err := r.db.Table("project_labels"). + Where("project_id = ? AND label_id IN (?)", projectID, labelIDs). + Delete(nil).Error; err != nil { + return nil, fmt.Errorf("failed to clear project labels: %w", err) + } + if err := r.db.Model(&model.Label{}). + Where("id IN (?)", labelIDs). + UpdateColumn("usage_count", gorm.Expr("GREATEST(0, usage_count - ?)", 1)).Error; err != nil { + return nil, fmt.Errorf("failed to decrease label usage counts: %w", err) + } + } + } + + var labels []model.Label + if err := r.db.Model(&model.Label{}). + Joins("JOIN project_labels pl ON pl.label_id = labels.id"). + Where("pl.project_id = ?", project.ID). + Find(&labels).Error; err != nil { + return nil, fmt.Errorf("failed to list labels for project %d: %w", project.ID, err) + } + project.Labels = labels + return project, nil +} + +func (r *Repository) loadProjectRecord(projectID int) (*model.Project, error) { + var project model.Project + if err := r.db.Where("id = ?", projectID).First(&project).Error; err != nil { + return nil, fmt.Errorf("failed to find project with id %d: %w", projectID, err) + } + return &project, nil +} + +func (r *Repository) ListProjectStatistics(projectIDs []int) (map[int]*dto.ProjectStatistics, error) { + statsMap := make(map[int]*dto.ProjectStatistics, len(projectIDs)) + for _, projectID := range projectIDs { + statsMap[projectID] = &dto.ProjectStatistics{} + } + if len(projectIDs) == 0 { + return statsMap, nil + } + + var injectionStats []struct { + ProjectID int + Count int64 + LastAt *time.Time + } + if err := r.db.Table("fault_injections fi"). + Select("tr.project_id, COUNT(*) as count, MAX(fi.updated_at) as last_at"). + Joins("JOIN tasks t ON fi.task_id = t.id"). + Joins("JOIN traces tr ON t.trace_id = tr.id"). + Where("tr.project_id IN (?) AND fi.status != ?", projectIDs, consts.CommonDeleted). + Group("tr.project_id"). + Scan(&injectionStats).Error; err != nil { + return nil, fmt.Errorf("failed to batch get injection statistics: %w", err) + } + for _, stat := range injectionStats { + statsMap[stat.ProjectID].InjectionCount = int(stat.Count) + statsMap[stat.ProjectID].LastInjectionAt = stat.LastAt + } + + var executionStats []struct { + ProjectID int + Count int64 + LastAt *time.Time + } + if err := r.db.Table("executions e"). + Select("tr.project_id, COUNT(*) as count, MAX(e.updated_at) as last_at"). + Joins("JOIN tasks t ON e.task_id = t.id"). + Joins("JOIN traces tr ON t.trace_id = tr.id"). + Where("tr.project_id IN (?) AND e.status != ?", projectIDs, consts.CommonDeleted). + Group("tr.project_id"). + Scan(&executionStats).Error; err != nil { + return nil, fmt.Errorf("failed to batch get execution statistics: %w", err) + } + for _, stat := range executionStats { + statsMap[stat.ProjectID].ExecutionCount = int(stat.Count) + statsMap[stat.ProjectID].LastExecutionAt = stat.LastAt + } + + return statsMap, nil +} diff --git a/src/module/project/service.go b/src/module/project/service.go new file mode 100644 index 00000000..a282d28d --- /dev/null +++ b/src/module/project/service.go @@ -0,0 +1,206 @@ +package project + +import ( + "context" + "errors" + "fmt" + + "aegis/consts" + "aegis/dto" + "aegis/model" + label "aegis/module/label" + + "gorm.io/gorm" +) + +type Service struct { + repository *Repository + stats projectStatisticsSource +} + +func NewService(repository *Repository, stats projectStatisticsSource) *Service { + return &Service{ + repository: repository, + stats: stats, + } +} + +func (s *Service) CreateProject(ctx context.Context, req *CreateProjectReq, userID int) (*ProjectResp, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + project := req.ConvertToProject() + + var createdProject *model.Project + err := s.repository.db.Transaction(func(tx *gorm.DB) error { + if err := NewRepository(tx).createProjectWithOwner(project, userID); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: project with name %s already exists", consts.ErrAlreadyExists, project.Name) + } + if errors.Is(err, consts.ErrNotFound) { + return fmt.Errorf("%w: role %v not found", err, consts.RoleProjectAdmin) + } + return err + } + createdProject = project + return nil + }) + if err != nil { + return nil, err + } + + return NewProjectResp(createdProject, nil), nil +} + +func (s *Service) DeleteProject(ctx context.Context, projectID int) error { + return s.repository.db.Transaction(func(tx *gorm.DB) error { + rows, err := NewRepository(tx).deleteProjectCascade(projectID) + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("%w: project id %d not found", consts.ErrNotFound, projectID) + } + + return nil + }) +} + +func (s *Service) GetProjectDetail(ctx context.Context, projectID int) (*ProjectDetailResp, error) { + project, userCount, err := s.repository.loadProjectDetailBase(projectID) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return nil, fmt.Errorf("%w: project with ID %d not found", consts.ErrNotFound, projectID) + } + return nil, fmt.Errorf("failed to get project: %w", err) + } + statsMap, err := s.stats.ListProjectStatistics(ctx, []int{project.ID}) + if err != nil { + return nil, fmt.Errorf("failed to get project statistics: %w", err) + } + stats := statsMap[project.ID] + if stats == nil { + stats = &dto.ProjectStatistics{} + } + resp := NewProjectDetailResp(project, stats) + resp.UserCount = userCount + + return resp, nil +} + +func (s *Service) ListProjects(ctx context.Context, req *ListProjectReq) (*dto.ListResp[ProjectResp], error) { + if req == nil { + return nil, fmt.Errorf("list project request is nil") + } + + limit, offset := req.ToGormParams() + includeStatistics := req.IncludeStatistics == nil || *req.IncludeStatistics + + projects, total, err := s.repository.listProjectViews(limit, offset, req.IsPublic, req.Status, req.TeamID) + if err != nil { + return nil, fmt.Errorf("failed to list projects: %w", err) + } + + statsMap := make(map[int]*dto.ProjectStatistics, len(projects)) + for i := range projects { + statsMap[projects[i].ID] = &dto.ProjectStatistics{} + } + if includeStatistics && len(projects) > 0 { + projectIDs := make([]int, 0, len(projects)) + for i := range projects { + projectIDs = append(projectIDs, projects[i].ID) + } + statsMap, err = s.stats.ListProjectStatistics(ctx, projectIDs) + if err != nil { + return nil, fmt.Errorf("failed to list project statistics: %w", err) + } + for _, projectID := range projectIDs { + if statsMap[projectID] == nil { + statsMap[projectID] = &dto.ProjectStatistics{} + } + } + } + + projectResps := make([]ProjectResp, 0, len(projects)) + for i := range projects { + var stats *dto.ProjectStatistics + if repoStats, exists := statsMap[projects[i].ID]; exists { + stats = &dto.ProjectStatistics{ + InjectionCount: repoStats.InjectionCount, + ExecutionCount: repoStats.ExecutionCount, + LastInjectionAt: repoStats.LastInjectionAt, + LastExecutionAt: repoStats.LastExecutionAt, + } + } + + projectResps = append(projectResps, *NewProjectResp(&projects[i], stats)) + } + + resp := dto.ListResp[ProjectResp]{ + Items: projectResps, + Pagination: req.ConvertToPaginationInfo(total), + } + return &resp, nil +} + +func (s *Service) UpdateProject(ctx context.Context, req *UpdateProjectReq, projectID int) (*ProjectResp, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + var updatedProject *model.Project + + err := s.repository.db.Transaction(func(tx *gorm.DB) error { + project, err := NewRepository(tx).updateMutableProject(projectID, func(existingProject *model.Project) { + req.PatchProjectModel(existingProject) + }) + if err != nil { + return fmt.Errorf("failed to get project: %w", err) + } + updatedProject = project + return nil + }) + if err != nil { + return nil, err + } + + return NewProjectResp(updatedProject, nil), nil +} + +func (s *Service) ManageProjectLabels(ctx context.Context, req *ManageProjectLabelReq, projectID int) (*ProjectResp, error) { + if req == nil { + return nil, fmt.Errorf("manage project labels request is nil") + } + + var managedProject *model.Project + err := s.repository.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + addLabelIDs := make([]int, 0, len(req.AddLabels)) + if len(req.AddLabels) > 0 { + labels, err := label.NewRepository(tx).CreateOrUpdateLabelsFromItems(tx, req.AddLabels, consts.ProjectCategory) + if err != nil { + return fmt.Errorf("failed to create or update labels: %w", err) + } + + for _, label := range labels { + addLabelIDs = append(addLabelIDs, label.ID) + } + } + + project, err := repo.manageProjectLabels(projectID, addLabelIDs, req.RemoveLabels) + if err != nil { + if errors.Is(err, consts.ErrNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: project id: %d", consts.ErrNotFound, projectID) + } + return fmt.Errorf("failed to manage project labels: %w", err) + } + managedProject = project + return nil + }) + if err != nil { + return nil, err + } + + return NewProjectResp(managedProject, nil), nil +} diff --git a/src/module/project/service_test.go b/src/module/project/service_test.go new file mode 100644 index 00000000..33414002 --- /dev/null +++ b/src/module/project/service_test.go @@ -0,0 +1,196 @@ +package project + +import ( + "regexp" + "testing" + "time" + + "aegis/consts" + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func newProjectService(t *testing.T) (*Service, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New() + require.NoError(t, err) + + db, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + require.NoError(t, err) + + repo := NewRepository(db) + stats := newProjectStatisticsSource(projectStatisticsSourceParams{Repository: repo}) + return NewService(repo, stats), mock, func() { + _ = sqlDB.Close() + } +} + +func TestProjectServiceListProjectsSuccess(t *testing.T) { + service, mock, cleanup := newProjectService(t) + defer cleanup() + + now := time.Now() + isPublic := true + status := consts.CommonEnabled + + mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM `projects` WHERE is_public = ? AND status = ?")). + WithArgs(isPublic, status). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `projects` WHERE is_public = ? AND status = ? LIMIT ?")). + WithArgs(isPublic, status, 20). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "description", "team_id", "is_public", "status", "created_at", "updated_at", + }).AddRow(1, "demo-project", "demo", nil, true, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT labels.*, pl.project_id FROM `labels` JOIN project_labels pl ON pl.label_id = labels.id WHERE pl.project_id IN (?)")). + WithArgs(1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "label_key", "label_value", "category", "description", "color", "usage_count", "is_system", "status", "created_at", "updated_at", "project_id", + }).AddRow(10, "env", "prod", consts.ProjectCategory, "", "#1890ff", 1, false, consts.CommonEnabled, now, now, 1)) + mock.ExpectQuery("SELECT tr\\.project_id, COUNT\\(\\*\\) as count, MAX\\(fi\\.updated_at\\) as last_at FROM fault_injections fi .* WHERE tr\\.project_id IN \\(\\?\\) AND fi\\.status != \\? GROUP BY `tr`\\.`project_id`"). + WithArgs(1, consts.CommonDeleted). + WillReturnRows(sqlmock.NewRows([]string{"project_id", "count", "last_at"}).AddRow(1, 2, now)) + mock.ExpectQuery("SELECT tr\\.project_id, COUNT\\(\\*\\) as count, MAX\\(e\\.updated_at\\) as last_at FROM executions e .* WHERE tr\\.project_id IN \\(\\?\\) AND e\\.status != \\? GROUP BY `tr`\\.`project_id`"). + WithArgs(1, consts.CommonDeleted). + WillReturnRows(sqlmock.NewRows([]string{"project_id", "count", "last_at"}).AddRow(1, 3, now)) + + resp, err := service.ListProjects(t.Context(), &ListProjectReq{ + IsPublic: &isPublic, + Status: &status, + }) + + require.NoError(t, err) + require.Len(t, resp.Items, 1) + require.Equal(t, "demo-project", resp.Items[0].Name) + require.Len(t, resp.Items[0].Labels, 1) + require.Equal(t, 2, resp.Items[0].InjectionCount) + require.Equal(t, 3, resp.Items[0].ExecutionCount) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestProjectServiceCreateProjectSuccess(t *testing.T) { + service, mock, cleanup := newProjectService(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `roles` WHERE name = ? AND status != ? ORDER BY `roles`.`id` LIMIT ?")). + WithArgs(consts.RoleProjectAdmin.String(), consts.CommonDeleted, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "display_name", "description", "is_system", "status", "created_at", "updated_at", + }).AddRow(3, consts.RoleProjectAdmin.String(), "Project Admin", "", true, consts.CommonEnabled, time.Now(), time.Now())) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `projects` (`name`,`description`,`team_id`,`is_public`,`status`,`created_at`,`updated_at`) VALUES (?,?,?,?,?,?,?)")). + WithArgs("demo-project", "demo", nil, true, consts.CommonEnabled, sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(11, 1)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `user_projects` (`user_id`,`project_id`,`role_id`,`workspace_config`,`status`,`created_at`,`updated_at`,`active_user_project`) VALUES (?,?,?,?,?,?,?,?)")). + WithArgs(7, 11, 3, "", consts.CommonEnabled, sqlmock.AnyArg(), sqlmock.AnyArg(), ""). + WillReturnResult(sqlmock.NewResult(21, 1)) + mock.ExpectCommit() + + isPublic := true + resp, err := service.CreateProject(t.Context(), &CreateProjectReq{ + Name: "demo-project", + Description: "demo", + IsPublic: &isPublic, + }, 7) + + require.NoError(t, err) + require.Equal(t, 11, resp.ID) + require.Equal(t, "demo-project", resp.Name) + require.True(t, resp.IsPublic) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestProjectServiceGetProjectDetailSuccess(t *testing.T) { + service, mock, cleanup := newProjectService(t) + defer cleanup() + + now := time.Now() + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `projects` WHERE id = ? ORDER BY `projects`.`id` LIMIT ?")). + WithArgs(1, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "description", "team_id", "is_public", "status", "created_at", "updated_at", + }).AddRow(1, "demo-project", "demo", nil, true, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM `user_projects` WHERE project_id = ? AND status = ?")). + WithArgs(1, consts.CommonEnabled). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(4)) + mock.ExpectQuery("SELECT tr\\.project_id, COUNT\\(\\*\\) as count, MAX\\(fi\\.updated_at\\) as last_at FROM fault_injections fi .* WHERE tr\\.project_id IN \\(\\?\\) AND fi\\.status != \\? GROUP BY `tr`\\.`project_id`"). + WithArgs(1, consts.CommonDeleted). + WillReturnRows(sqlmock.NewRows([]string{"project_id", "count", "last_at"}).AddRow(1, 2, now)) + mock.ExpectQuery("SELECT tr\\.project_id, COUNT\\(\\*\\) as count, MAX\\(e\\.updated_at\\) as last_at FROM executions e .* WHERE tr\\.project_id IN \\(\\?\\) AND e\\.status != \\? GROUP BY `tr`\\.`project_id`"). + WithArgs(1, consts.CommonDeleted). + WillReturnRows(sqlmock.NewRows([]string{"project_id", "count", "last_at"}).AddRow(1, 3, now)) + + resp, err := service.GetProjectDetail(t.Context(), 1) + + require.NoError(t, err) + require.Equal(t, "demo-project", resp.Name) + require.Equal(t, 4, resp.UserCount) + require.Equal(t, 2, resp.InjectionCount) + require.Equal(t, 3, resp.ExecutionCount) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestProjectServiceUpdateProjectSuccess(t *testing.T) { + service, mock, cleanup := newProjectService(t) + defer cleanup() + + now := time.Now() + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `projects` WHERE id = ? ORDER BY `projects`.`id` LIMIT ?")). + WithArgs(1, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "description", "team_id", "is_public", "status", "created_at", "updated_at", + }).AddRow(1, "demo-project", "old-desc", nil, true, consts.CommonEnabled, now, now)) + mock.ExpectExec(regexp.QuoteMeta("UPDATE `projects` SET `name`=?,`description`=?,`team_id`=?,`is_public`=?,`status`=?,`created_at`=?,`updated_at`=? WHERE `id` = ?")). + WithArgs("demo-project", "new-desc", nil, false, consts.CommonDisabled, sqlmock.AnyArg(), sqlmock.AnyArg(), 1). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + description := "new-desc" + isPublic := false + status := consts.CommonDisabled + resp, err := service.UpdateProject(t.Context(), &UpdateProjectReq{ + Description: &description, + IsPublic: &isPublic, + Status: &status, + }, 1) + + require.NoError(t, err) + require.Equal(t, "demo-project", resp.Name) + require.False(t, resp.IsPublic) + require.Equal(t, consts.GetStatusTypeName(consts.CommonDisabled), resp.Status) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestProjectServiceDeleteProjectSuccess(t *testing.T) { + service, mock, cleanup := newProjectService(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("UPDATE `user_projects` SET `status`=?,`updated_at`=? WHERE project_id = ? AND status != ?")). + WithArgs(consts.CommonDeleted, sqlmock.AnyArg(), 1, consts.CommonDeleted). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectExec(regexp.QuoteMeta("UPDATE `projects` SET `status`=?,`updated_at`=? WHERE id = ? AND status != ?")). + WithArgs(consts.CommonDeleted, sqlmock.AnyArg(), 1, consts.CommonDeleted). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + err := service.DeleteProject(t.Context(), 1) + + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestProjectServiceManageLabelsNilRequest(t *testing.T) { + service := NewService(nil, nil) + + _, err := service.ManageProjectLabels(t.Context(), nil, 1) + + require.Error(t, err) + require.ErrorContains(t, err, "manage project labels request is nil") +} diff --git a/src/module/ratelimiter/api_types.go b/src/module/ratelimiter/api_types.go new file mode 100644 index 00000000..8f3c73a4 --- /dev/null +++ b/src/module/ratelimiter/api_types.go @@ -0,0 +1,28 @@ +package ratelimiter + +// RateLimiterHolder describes a single task currently holding a token. +type RateLimiterHolder struct { + TaskID string `json:"task_id"` + TaskState string `json:"task_state"` + IsTerminal bool `json:"is_terminal"` +} + +// RateLimiterItem describes one token-bucket rate limiter. +type RateLimiterItem struct { + Bucket string `json:"bucket"` + Key string `json:"key"` + Capacity int `json:"capacity"` + Held int `json:"held"` + Holders []RateLimiterHolder `json:"holders"` +} + +// RateLimiterListResp is the response for GET /api/v2/rate-limiters. +type RateLimiterListResp struct { + Items []RateLimiterItem `json:"items"` +} + +// RateLimiterGCResp is the response for POST /api/v2/rate-limiters/gc. +type RateLimiterGCResp struct { + Released int `json:"released"` + TouchedBuckets int `json:"touched_buckets"` +} diff --git a/src/handlers/v2/rate_limiters.go b/src/module/ratelimiter/handler.go similarity index 68% rename from src/handlers/v2/rate_limiters.go rename to src/module/ratelimiter/handler.go index a566505e..f41db651 100644 --- a/src/handlers/v2/rate_limiters.go +++ b/src/module/ratelimiter/handler.go @@ -1,4 +1,4 @@ -package v2 +package ratelimiter import ( "fmt" @@ -6,13 +6,20 @@ import ( "aegis/consts" "aegis/dto" - "aegis/handlers" - producer "aegis/service/producer" + "aegis/httpx" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" ) +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + // ListRateLimiters // // @Summary List rate limiters @@ -21,14 +28,14 @@ import ( // @ID list_rate_limiters // @Produce json // @Security BearerAuth -// @Success 200 {object} dto.GenericResponse[dto.RateLimiterListResp] +// @Success 200 {object} dto.GenericResponse[RateLimiterListResp] // @Router /api/v2/rate-limiters [get] // @x-api-type {"sdk":"true"} -func ListRateLimiters(c *gin.Context) { - resp, err := producer.ListRateLimiters(c.Request.Context()) +func (h *Handler) ListRateLimiters(c *gin.Context) { + resp, err := h.service.List(c.Request.Context()) if err != nil { logrus.WithError(err).Error("Failed to list rate limiters") - handlers.HandleServiceError(c, fmt.Errorf("%w: %v", consts.ErrInternal, err)) + httpx.HandleServiceError(c, fmt.Errorf("%w: %v", consts.ErrInternal, err)) return } dto.JSONResponse(c, http.StatusOK, "Rate limiters retrieved successfully", resp) @@ -46,14 +53,14 @@ func ListRateLimiters(c *gin.Context) { // @Success 200 {object} dto.GenericResponse[any] // @Router /api/v2/rate-limiters/{bucket} [delete] // @x-api-type {"sdk":"true"} -func ResetRateLimiter(c *gin.Context) { +func (h *Handler) ResetRateLimiter(c *gin.Context) { bucket := c.Param("bucket") if bucket == "" { dto.ErrorResponse(c, http.StatusBadRequest, "bucket is required") return } - if err := producer.ResetRateLimiter(c.Request.Context(), bucket); err != nil { - if handlers.HandleServiceError(c, err) { + if err := h.service.Reset(c.Request.Context(), bucket); err != nil { + if httpx.HandleServiceError(c, err) { return } } @@ -68,17 +75,17 @@ func ResetRateLimiter(c *gin.Context) { // @ID gc_rate_limiters // @Produce json // @Security BearerAuth -// @Success 200 {object} dto.GenericResponse[dto.RateLimiterGCResp] +// @Success 200 {object} dto.GenericResponse[RateLimiterGCResp] // @Router /api/v2/rate-limiters/gc [post] // @x-api-type {"sdk":"true"} -func GCRateLimiters(c *gin.Context) { - released, buckets, err := producer.GCRateLimiters(c.Request.Context()) +func (h *Handler) GCRateLimiters(c *gin.Context) { + released, buckets, err := h.service.GC(c.Request.Context()) if err != nil { logrus.WithError(err).Error("Failed to gc rate limiters") - handlers.HandleServiceError(c, fmt.Errorf("%w: %v", consts.ErrInternal, err)) + httpx.HandleServiceError(c, fmt.Errorf("%w: %v", consts.ErrInternal, err)) return } - dto.JSONResponse(c, http.StatusOK, "Garbage collection complete", &dto.RateLimiterGCResp{ + dto.JSONResponse(c, http.StatusOK, "Garbage collection complete", &RateLimiterGCResp{ Released: released, TouchedBuckets: buckets, }) diff --git a/src/module/ratelimiter/module.go b/src/module/ratelimiter/module.go new file mode 100644 index 00000000..7a65e076 --- /dev/null +++ b/src/module/ratelimiter/module.go @@ -0,0 +1,8 @@ +package ratelimiter + +import "go.uber.org/fx" + +var Module = fx.Module("ratelimiter", + fx.Provide(NewService), + fx.Provide(NewHandler), +) diff --git a/src/service/producer/rate_limiter.go b/src/module/ratelimiter/service.go similarity index 54% rename from src/service/producer/rate_limiter.go rename to src/module/ratelimiter/service.go index 01f3b659..7183b8cc 100644 --- a/src/service/producer/rate_limiter.go +++ b/src/module/ratelimiter/service.go @@ -1,22 +1,36 @@ -package producer +// Package ratelimiter provides admin/operator APIs over the token-bucket +// rate limiters that back the restart_pedestal, build_container and +// algo_execution concurrency gates. Operators can inspect bucket state, +// reset a bucket, or garbage-collect tokens still held by terminal-state +// tasks (OperationsPAI/aegis#21). +package ratelimiter import ( "context" "fmt" "strings" - "aegis/client" "aegis/consts" - "aegis/database" - "aegis/dto" + redisinfra "aegis/infra/redis" + "aegis/model" - "github.com/redis/go-redis/v9" "github.com/sirupsen/logrus" "gorm.io/gorm" ) const tokenBucketKeyPrefix = "token_bucket:" +// Service exposes rate-limiter admin operations. It is deliberately thin — +// all the state lives in Redis/MySQL and we just wrap scans + set ops. +type Service struct { + redis *redisinfra.Gateway + db *gorm.DB +} + +func NewService(redis *redisinfra.Gateway, db *gorm.DB) *Service { + return &Service{redis: redis, db: db} +} + func knownBuckets() map[string]int { return map[string]int{ consts.RestartPedestalTokenBucket: consts.MaxConcurrentRestartPedestal, @@ -25,38 +39,35 @@ func knownBuckets() map[string]int { } } -// isTerminalState mirrors service/producer/task.go:isTaskTerminal. -// Issue #21 spells these "Success / Failed / -1"; codebase uses -// TaskCompleted (3), TaskError (-1), TaskCancelled (-2). +// isTerminalState mirrors the codebase's TaskCompleted (3), TaskError (-1), +// TaskCancelled (-2) states. func isTerminalState(state consts.TaskState) bool { return state == consts.TaskCompleted || state == consts.TaskError || state == consts.TaskCancelled } -// ListRateLimiters returns each token_bucket:* bucket with its holders. -func ListRateLimiters(ctx context.Context) (*dto.RateLimiterListResp, error) { - redisCli := client.GetRedisClient() +// List returns each token_bucket:* bucket with its holders. +func (s *Service) List(ctx context.Context) (*RateLimiterListResp, error) { bucketCaps := knownBuckets() - iter := redisCli.Scan(ctx, 0, tokenBucketKeyPrefix+"*", 0).Iterator() - for iter.Next(ctx) { - key := iter.Val() + extra, err := s.redis.ScanKeys(ctx, tokenBucketKeyPrefix+"*") + if err != nil { + return nil, fmt.Errorf("scan token buckets: %w", err) + } + for _, key := range extra { if _, ok := bucketCaps[key]; !ok { bucketCaps[key] = 0 } } - if err := iter.Err(); err != nil { - return nil, fmt.Errorf("scan token buckets: %w", err) - } - items := make([]dto.RateLimiterItem, 0, len(bucketCaps)) + items := make([]RateLimiterItem, 0, len(bucketCaps)) for key, capacity := range bucketCaps { - holders, err := redisCli.SMembers(ctx, key).Result() - if err != nil && err != redis.Nil { + holders, err := s.redis.SetMembers(ctx, key) + if err != nil { return nil, fmt.Errorf("smembers %s: %w", key, err) } - holderItems := make([]dto.RateLimiterHolder, 0, len(holders)) + holderItems := make([]RateLimiterHolder, 0, len(holders)) for _, taskID := range holders { - state, found, err := lookupTaskState(ctx, taskID) + state, found, err := s.lookupTaskState(ctx, taskID) if err != nil { logrus.WithError(err).WithField("task_id", taskID). Warn("lookup task state for rate-limiter holder") @@ -69,11 +80,11 @@ func ListRateLimiters(ctx context.Context) (*dto.RateLimiterListResp, error) { } else { terminal = true } - holderItems = append(holderItems, dto.RateLimiterHolder{ + holderItems = append(holderItems, RateLimiterHolder{ TaskID: taskID, TaskState: stateName, IsTerminal: terminal, }) } - items = append(items, dto.RateLimiterItem{ + items = append(items, RateLimiterItem{ Bucket: strings.TrimPrefix(key, tokenBucketKeyPrefix), Key: key, Capacity: capacity, @@ -81,17 +92,16 @@ func ListRateLimiters(ctx context.Context) (*dto.RateLimiterListResp, error) { Holders: holderItems, }) } - return &dto.RateLimiterListResp{Items: items}, nil + return &RateLimiterListResp{Items: items}, nil } -// ResetRateLimiter deletes the given bucket key from Redis. -func ResetRateLimiter(ctx context.Context, bucket string) error { +// Reset deletes the given bucket key from Redis. +func (s *Service) Reset(ctx context.Context, bucket string) error { key := resolveBucketKey(bucket) if _, ok := knownBuckets()[key]; !ok { return fmt.Errorf("%w: unknown bucket %q", consts.ErrBadRequest, bucket) } - redisCli := client.GetRedisClient() - n, err := redisCli.Del(ctx, key).Result() + n, err := s.redis.DeleteKey(ctx, key) if err != nil { return fmt.Errorf("del %s: %w", key, err) } @@ -102,17 +112,19 @@ func ResetRateLimiter(ctx context.Context, bucket string) error { return nil } -// GCRateLimiters releases tokens held by terminal-state tasks. -func GCRateLimiters(ctx context.Context) (released int, touchedBuckets int, err error) { - return gcRateLimitersWith(ctx, client.GetRedisClient(), database.DB, knownBuckets()) +// GC releases tokens held by terminal-state (or unknown) tasks across all +// known buckets. Returns (released, touchedBuckets, err). +func (s *Service) GC(ctx context.Context) (int, int, error) { + return gcWith(ctx, s.redis, s.db, knownBuckets()) } -// gcRateLimitersWith is the testable core. -func gcRateLimitersWith(ctx context.Context, redisCli *redis.Client, db *gorm.DB, buckets map[string]int) (released int, touchedBuckets int, err error) { +// gcWith is the testable core. +func gcWith(ctx context.Context, r *redisinfra.Gateway, db *gorm.DB, buckets map[string]int) (int, int, error) { + var released, touched int for key := range buckets { - holders, serr := redisCli.SMembers(ctx, key).Result() - if serr != nil && serr != redis.Nil { - return released, touchedBuckets, fmt.Errorf("smembers %s: %w", key, serr) + holders, err := r.SetMembers(ctx, key) + if err != nil { + return released, touched, fmt.Errorf("smembers %s: %w", key, err) } if len(holders) == 0 { continue @@ -132,30 +144,30 @@ func gcRateLimitersWith(ctx context.Context, redisCli *redis.Client, db *gorm.DB if len(toRelease) == 0 { continue } - n, rerr := redisCli.SRem(ctx, key, toRelease...).Result() + n, rerr := r.SetRemove(ctx, key, toRelease...) if rerr != nil { - return released, touchedBuckets, fmt.Errorf("srem %s: %w", key, rerr) + return released, touched, fmt.Errorf("srem %s: %w", key, rerr) } if n > 0 { released += int(n) - touchedBuckets++ + touched++ logrus.WithFields(logrus.Fields{ "bucket": key, "released": n, "holders": toRelease, }).Warn("rate-limiter gc: released leaked tokens") } } - return released, touchedBuckets, nil + return released, touched, nil } -func lookupTaskState(ctx context.Context, taskID string) (consts.TaskState, bool, error) { - return lookupTaskStateWith(ctx, database.DB, taskID) +func (s *Service) lookupTaskState(ctx context.Context, taskID string) (consts.TaskState, bool, error) { + return lookupTaskStateWith(ctx, s.db, taskID) } func lookupTaskStateWith(ctx context.Context, db *gorm.DB, taskID string) (consts.TaskState, bool, error) { - var task database.Task + var task model.Task err := db.WithContext(ctx).Select("state").Where("id = ?", taskID).First(&task).Error if err != nil { - if strings.Contains(err.Error(), "record not found") { + if err == gorm.ErrRecordNotFound { return 0, false, nil } return 0, false, err diff --git a/src/module/rbac/api_types.go b/src/module/rbac/api_types.go new file mode 100644 index 00000000..141b5afe --- /dev/null +++ b/src/module/rbac/api_types.go @@ -0,0 +1,326 @@ +package rbac + +import ( + "fmt" + "strings" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/model" +) + +// CreateRoleReq represents role creation request. +type CreateRoleReq struct { + Name string `json:"name" binding:"required"` + DisplayName string `json:"display_name" binding:"required"` + Description string `json:"description,omitempty" binding:"omitempty"` +} + +func (req *CreateRoleReq) ConvertToRole() *model.Role { + return &model.Role{ + Name: req.Name, + DisplayName: req.DisplayName, + Description: req.Description, + IsSystem: false, + Status: consts.CommonEnabled, + } +} + +// ListRoleReq represents role list query parameters. +type ListRoleReq struct { + dto.PaginationReq + IsSystem *bool `form:"is_system" binding:"omitempty"` + Status *consts.StatusType `form:"status" binding:"omitempty"` +} + +func (req *ListRoleReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + return validateStatus(req.Status, false) +} + +// UpdateRoleReq represents role update request. +type UpdateRoleReq struct { + DisplayName *string `json:"display_name" binding:"omitempty"` + Description *string `json:"description" binding:"omitempty"` + Status *consts.StatusType `json:"status" binding:"omitempty"` +} + +func (req *UpdateRoleReq) Validate() error { + if req.DisplayName != nil && *req.DisplayName != "" { + *req.DisplayName = strings.TrimSpace(*req.DisplayName) + } + return validateStatus(req.Status, true) +} + +func (req *UpdateRoleReq) PatchRoleModel(target *model.Role) { + if req.DisplayName != nil { + target.DisplayName = *req.DisplayName + } + if req.Description != nil { + target.Description = *req.Description + } + if req.Status != nil { + target.Status = *req.Status + } +} + +// AssignRolePermissionReq represents request to assign permissions to a role. +type AssignRolePermissionReq struct { + PermissionIDs []int `json:"permission_ids" binding:"required,min=1,non_zero_int_slice"` +} + +// RemoveRolePermissionReq represents request to remove permissions from a role. +type RemoveRolePermissionReq struct { + PermissionIDs []int `json:"permission_ids" binding:"required,min=1,non_zero_int_slice"` +} + +// ListResourceReq represents request for listing resources. +type ListResourceReq struct { + dto.PaginationReq + + Type *consts.ResourceType `form:"type" binding:"omitempty"` + Category *consts.ResourceCategory `form:"category" binding:"omitempty"` +} + +func (req *ListResourceReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + if req.Type != nil { + if _, exists := consts.ValidResourceTypes[*req.Type]; !exists { + return fmt.Errorf("invalid resource type: %d", *req.Type) + } + } + if req.Category != nil { + if _, exists := consts.ValidResourceCategories[*req.Category]; !exists { + return fmt.Errorf("invalid resource category: %d", *req.Category) + } + } + return nil +} + +// ResourceResp represents an RBAC resource response. +type ResourceResp struct { + ID int `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Type string `json:"type"` + Category string `json:"category"` + ParentID *int `json:"parent_id,omitempty"` +} + +func NewResourceResp(resource *model.Resource) *ResourceResp { + return &ResourceResp{ + ID: resource.ID, + Name: resource.Name.String(), + DisplayName: resource.DisplayName, + Type: consts.GetResourceTypeName(resource.Type), + Category: consts.GetResourceCategoryName(resource.Category), + ParentID: resource.ParentID, + } +} + +// ResourceDetailResp represents a detailed RBAC resource response. +type ResourceDetailResp struct { + ResourceResp + + Description string `json:"description,omitempty"` +} + +func NewResourceDetailResp(resource *model.Resource) *ResourceDetailResp { + return &ResourceDetailResp{ + ResourceResp: *NewResourceResp(resource), + Description: resource.Description, + } +} + +func validateStatus(statusPtr *consts.StatusType, isMutation bool) error { + if statusPtr == nil { + return nil + } + + status := *statusPtr + if _, exists := consts.ValidStatuses[status]; !exists { + return fmt.Errorf("invalid status value: %d", status) + } + if isMutation && status == consts.CommonDeleted { + return fmt.Errorf("status value cannot be set to deleted (%d) directly through this update/create operation", consts.CommonDeleted) + } + return nil +} + +// RoleResp represents role response. +type RoleResp struct { + ID int `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Type string `json:"type"` + IsSystem bool `json:"is_system"` + Status string `json:"status"` + UpdatedAt time.Time `json:"updated_at"` +} + +func NewRoleResp(role *model.Role) *RoleResp { + return &RoleResp{ + ID: role.ID, + Name: role.Name, + DisplayName: role.DisplayName, + IsSystem: role.IsSystem, + Status: consts.GetStatusTypeName(role.Status), + UpdatedAt: role.UpdatedAt, + } +} + +// RoleDetailResp represents role detail response. +type RoleDetailResp struct { + RoleResp + + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UserCount int64 `json:"user_count"` + Permissions []PermissionResp `json:"permissions"` +} + +func NewRoleDetailResp(role *model.Role) *RoleDetailResp { + return &RoleDetailResp{ + RoleResp: *NewRoleResp(role), + Description: role.Description, + CreatedAt: role.CreatedAt, + } +} + +// ListPermissionReq represents permission list query parameters. +type ListPermissionReq struct { + dto.PaginationReq + Action consts.ActionName `form:"action" binding:"omitempty"` + IsSystem *bool `form:"is_system" binding:"omitempty"` + Status *consts.StatusType `form:"status" binding:"omitempty"` +} + +func (req *ListPermissionReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + if req.Action != "" { + if _, exists := consts.ValidActions[req.Action]; !exists { + return fmt.Errorf("invalid action: %s", req.Action) + } + } + return validateStatus(req.Status, false) +} + +// PermissionBaseResp contains common fields for permission responses. +type PermissionBaseResp struct { + ID int `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Action consts.ActionName `json:"action"` + Scope consts.ResourceScope `json:"scope"` + IsSystem bool `json:"is_system"` + Status string `json:"status"` + UpdatedAt time.Time `json:"updated_at"` +} + +func NewPermissionBaseResp(perm *model.Permission) *PermissionBaseResp { + return &PermissionBaseResp{ + ID: perm.ID, + Name: perm.Name, + DisplayName: perm.DisplayName, + Action: perm.Action, + Scope: perm.Scope, + IsSystem: perm.IsSystem, + Status: consts.GetStatusTypeName(perm.Status), + UpdatedAt: perm.UpdatedAt, + } +} + +// PermissionResp represents permission summary information. +type PermissionResp struct { + PermissionBaseResp + Resource string `json:"resource_name"` +} + +func NewPermissionResp(perm *model.Permission) *PermissionResp { + resp := &PermissionResp{ + PermissionBaseResp: *NewPermissionBaseResp(perm), + } + if perm.Resource != nil { + resp.Resource = perm.Resource.Name.String() + } + return resp +} + +// PermissionDetailResp represents permission detail information. +type PermissionDetailResp struct { + PermissionBaseResp + Description string `json:"description"` + Resource *PermissionResourceResp `json:"resource,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func NewPermissionDetailResp(perm *model.Permission) *PermissionDetailResp { + resp := &PermissionDetailResp{ + PermissionBaseResp: *NewPermissionBaseResp(perm), + Description: perm.Description, + CreatedAt: perm.CreatedAt, + } + if perm.Resource != nil { + resp.Resource = NewPermissionResourceResp(perm.Resource) + } + return resp +} + +// PermissionResourceResp keeps the resource snapshot embedded in permission detail responses. +type PermissionResourceResp struct { + ID int `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Type string `json:"type"` + Category string `json:"category"` + ParentID *int `json:"parent_id,omitempty"` +} + +func NewPermissionResourceResp(resource *model.Resource) *PermissionResourceResp { + return &PermissionResourceResp{ + ID: resource.ID, + Name: resource.Name.String(), + DisplayName: resource.DisplayName, + Type: consts.GetResourceTypeName(resource.Type), + Category: consts.GetResourceCategoryName(resource.Category), + ParentID: resource.ParentID, + } +} + +// UserListItem is the RBAC-facing user summary contract for role membership queries. +type UserListItem struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + FullName string `json:"full_name"` + Avatar string `json:"avatar,omitempty"` + Phone string `json:"phone,omitempty"` + IsActive bool `json:"is_active"` + Status string `json:"status"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func NewUserListItem(user *model.User) *UserListItem { + return &UserListItem{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + FullName: user.FullName, + Avatar: user.Avatar, + Phone: user.Phone, + IsActive: user.IsActive, + Status: consts.GetStatusTypeName(user.Status), + LastLoginAt: user.LastLoginAt, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } +} diff --git a/src/module/rbac/handler.go b/src/module/rbac/handler.go new file mode 100644 index 00000000..692e1317 --- /dev/null +++ b/src/module/rbac/handler.go @@ -0,0 +1,478 @@ +package rbac + +import ( + "aegis/httpx" + "net/http" + "strconv" + + "aegis/consts" + "aegis/dto" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + +// CreateRole handles role creation +// +// @Summary Create a new role +// @Description Create a new role with specified permissions +// @Tags Roles +// @ID create_role +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body CreateRoleReq true "Role creation request" +// @Success 201 {object} dto.GenericResponse[RoleResp] "Role created successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 409 {object} dto.GenericResponse[any] "Role already exists" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/roles [post] +// @x-api-type {"admin":"true"} +func (h *Handler) CreateRole(c *gin.Context) { + var req CreateRoleReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + resp, err := h.service.CreateRole(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + dto.JSONResponse(c, http.StatusCreated, "Role created successfully", resp) +} + +// DeleteRole handles role deletion +// +// @Summary Delete role +// @Description Delete a role (soft delete by setting status to -1) +// @Tags Roles +// @ID delete_role +// @Produce json +// @Security BearerAuth +// @Param id path int true "Role ID" +// @Success 200 {object} dto.GenericResponse[any] "Role deleted successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid role ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied or cannot delete system role" +// @Failure 404 {object} dto.GenericResponse[any] "Role not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/roles/{id} [delete] +// @x-api-type {"admin":"true"} +func (h *Handler) DeleteRole(c *gin.Context) { + roleID, ok := parseID(c, consts.URLPathRoleID, "Invalid role ID") + if !ok { + return + } + if httpx.HandleServiceError(c, h.service.DeleteRole(c.Request.Context(), roleID)) { + return + } + dto.JSONResponse[any](c, http.StatusNoContent, "Role deleted successfully", nil) +} + +// GetRole handles getting a single role by ID +// +// @Summary Get role by ID +// @Description Get detailed information about a specific role +// @Tags Roles +// @ID get_role_by_id +// @Produce json +// @Security BearerAuth +// @Param id path int true "Role ID" +// @Success 200 {object} dto.GenericResponse[RoleDetailResp] "Role retrieved successfully" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid role ID" +// @Failure 404 {object} dto.GenericResponse[any] "Role not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/roles/{id} [get] +// @x-api-type {"admin":"true"} +func (h *Handler) GetRole(c *gin.Context) { + roleID, ok := parseID(c, consts.URLPathRoleID, "Invalid role ID") + if !ok { + return + } + resp, err := h.service.GetRole(c.Request.Context(), roleID) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// ListRoles handles listing roles with pagination and filtering +// +// @Summary List roles +// @Description Get paginated list of roles with optional filtering +// @Tags Roles +// @ID list_roles +// @Produce json +// @Security BearerAuth +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Param is_system query bool false "Filter by system role" +// @Param status query consts.StatusType false "Filter by status" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[RoleResp]] "Roles retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/roles [get] +// @x-api-type {"admin":"true"} +func (h *Handler) ListRoles(c *gin.Context) { + var req ListRoleReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + resp, err := h.service.ListRoles(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// UpdateRole handles role updates +// +// @Summary Update role +// @Description Update role information (partial update supported) +// @Tags Roles +// @ID update_role +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Role ID" +// @Param request body UpdateRoleReq true "Role update request" +// @Success 202 {object} dto.GenericResponse[RoleResp] "Role updated successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Role not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/roles/{id} [patch] +// @x-api-type {"admin":"true"} +func (h *Handler) UpdateRole(c *gin.Context) { + roleID, ok := parseID(c, consts.URLPathRoleID, "Invalid role ID") + if !ok { + return + } + var req UpdateRoleReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + resp, err := h.service.UpdateRole(c.Request.Context(), &req, roleID) + if httpx.HandleServiceError(c, err) { + return + } + dto.JSONResponse[any](c, http.StatusAccepted, "Role updated successfully", resp) +} + +// AssignRolePermission handles role-permission assignment +// +// @Summary Assign permissions to role +// @Description Assign multiple permissions to a role +// @Tags Roles +// @ID grant_permissions_to_role +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param role_id path int true "Role ID" +// @Param request body AssignRolePermissionReq true "Permission assignment request" +// @Success 200 {object} dto.GenericResponse[any] "Permissions assigned successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid role ID or request format" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Role not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/roles/{role_id}/permissions/assign [post] +// @x-api-type {"admin":"true"} +func (h *Handler) AssignRolePermissions(c *gin.Context) { + roleID, ok := parseID(c, consts.URLPathRoleID, "Invalid role ID") + if !ok { + return + } + var req AssignRolePermissionReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if httpx.HandleServiceError(c, h.service.AssignRolePermissions(c.Request.Context(), req.PermissionIDs, roleID)) { + return + } + dto.JSONResponse[any](c, http.StatusOK, "Permissions assigned successfully", nil) +} + +// RemovePermissionsFromRole handles permission removal from role +// +// @Summary Remove permissions from role +// @Description Remove multiple permissions from a role +// @Tags Roles +// @ID revoke_permissions_from_role +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param role_id path int true "Role ID" +// @Param request body RemoveRolePermissionReq true "Permission removal request" +// @Success 200 {object} dto.GenericResponse[any] "Permissions removed successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid role ID or request format" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Role not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/roles/{role_id}/permissions/remove [post] +// @x-api-type {"admin":"true"} +func (h *Handler) RemoveRolePermissions(c *gin.Context) { + roleID, ok := parseID(c, consts.URLPathRoleID, "Invalid role ID") + if !ok { + return + } + var req RemoveRolePermissionReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if httpx.HandleServiceError(c, h.service.RemoveRolePermissions(c.Request.Context(), req.PermissionIDs, roleID)) { + return + } + dto.JSONResponse[any](c, http.StatusOK, "Permissions removed successfully", nil) +} + +// ListUsersFromRole handles listing users assigned to a role +// +// @Summary List users from role +// @Description Get list of users assigned to a specific role +// @Tags Roles +// @ID list_users_by_role +// @Produce json +// @Security BearerAuth +// @Param role_id path int true "Role ID" +// @Success 200 {object} dto.GenericResponse[[]UserListItem] "Users retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid role ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Role not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/roles/{role_id}/users [get] +// @x-api-type {"admin":"true"} +func (h *Handler) ListUsersFromRole(c *gin.Context) { + roleID, ok := parseID(c, consts.URLPathRoleID, "Invalid role ID") + if !ok { + return + } + resp, err := h.service.ListUsersFromRole(c.Request.Context(), roleID) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// GetPermission handles getting a single permission by ID +// +// @Summary Get permission by ID +// @Description Get detailed information about a specific permission +// @Tags Permissions +// @ID get_permission_by_id +// @Produce json +// @Security BearerAuth +// @Param id path int true "Permission ID" +// @Success 200 {object} dto.GenericResponse[PermissionDetailResp] "Permission retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid permission ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Permission not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/permissions/{id} [get] +// @x-api-type {"admin":"true"} +func (h *Handler) GetPermission(c *gin.Context) { + permissionID, ok := parseID(c, consts.URLPathPermissionID, "Invalid permission ID") + if !ok { + return + } + resp, err := h.service.GetPermission(c.Request.Context(), permissionID) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// ListPermissions handles listing permissions with pagination and filtering +// +// @Summary List permissions +// @Description Get paginated list of permissions with optional filtering +// @Tags Permissions +// @ID list_permissions +// @Produce json +// @Security BearerAuth +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Param action query string false "Filter by action" +// @Param is_system query bool false "Filter by system permission" +// @Param status query consts.StatusType false "Filter by status" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[PermissionResp]] "Permissions retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/permissions [get] +// @x-api-type {"admin":"true"} +func (h *Handler) ListPermissions(c *gin.Context) { + var req ListPermissionReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + resp, err := h.service.ListPermissions(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// ListRolesFromPermission handles listing roles assigned to a permission +// +// @Summary List roles from permission +// @Description Get list of roles assigned to a specific permission +// @Tags Permissions +// @ID list_roles_with_permission +// @Produce json +// @Security BearerAuth +// @Param permission_id path int true "Permission ID" +// @Success 200 {object} dto.GenericResponse[[]RoleResp] "Roles retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid permission ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Permission not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/permissions/{permission_id}/roles [get] +// @x-api-type {"admin":"true"} +func (h *Handler) ListRolesFromPermission(c *gin.Context) { + permissionID, ok := parseID(c, consts.URLPathPermissionID, "Invalid permission ID") + if !ok { + return + } + resp, err := h.service.ListRolesFromPermission(c.Request.Context(), permissionID) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// GetResourceDetail handles getting a single resource by ID +// +// @Summary Get resource by ID +// @Description Get detailed information about a specific resource +// @Tags Resources +// @ID get_resource_by_id +// @Produce json +// @Security BearerAuth +// @Param id path int true "Resource ID" +// @Success 200 {object} dto.GenericResponse[ResourceResp] "Resource retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid resource ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Resource not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/resources/{id} [get] +// @x-api-type {"admin":"true"} +func (h *Handler) GetResource(c *gin.Context) { + resourceID, ok := parseID(c, consts.URLPathResourceID, "Invalid resource ID") + if !ok { + return + } + resp, err := h.service.GetResource(c.Request.Context(), resourceID) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// ListResources handles listing resources with pagination and filtering +// +// @Summary List resources +// @Description Get paginated list of resources with filtering +// @Tags Resources +// @ID list_resources +// @Produce json +// @Security BearerAuth +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Param type query consts.ResourceType false "Filter by resource type" +// @Param category query consts.ResourceCategory false "Filter by resource category" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[ResourceResp]] "Resources retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/resources [get] +// @x-api-type {"admin":"true"} +func (h *Handler) ListResources(c *gin.Context) { + var req ListResourceReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + resp, err := h.service.ListResources(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// ListResourcePermissions handles listing permissions by resource +// +// @Summary List permissions from resource +// @Description Get list of permissions assigned to a specific resource +// @Tags Resources +// @ID list_resource_permissions +// @Produce json +// @Security BearerAuth +// @Param id path int true "Resource ID" +// @Success 200 {object} dto.GenericResponse[[]PermissionResp] "Permissions retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid resource ID or request form" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Resource not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/resources/{id}/permissions [get] +// @x-api-type {"admin":"true"} +func (h *Handler) ListResourcePermissions(c *gin.Context) { + resourceID, ok := parseID(c, consts.URLPathResourceID, "Invalid resource ID") + if !ok { + return + } + resp, err := h.service.ListResourcePermissions(c.Request.Context(), resourceID) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +func parseID(c *gin.Context, param, message string) (int, bool) { + value := c.Param(param) + id, err := strconv.Atoi(value) + if err != nil || id <= 0 { + dto.ErrorResponse(c, http.StatusBadRequest, message) + return 0, false + } + return id, true +} diff --git a/src/module/rbac/handler_service.go b/src/module/rbac/handler_service.go new file mode 100644 index 00000000..1d66277f --- /dev/null +++ b/src/module/rbac/handler_service.go @@ -0,0 +1,29 @@ +package rbac + +import ( + "context" + + "aegis/dto" +) + +// HandlerService captures the RBAC operations consumed by the HTTP handler. +type HandlerService interface { + CreateRole(context.Context, *CreateRoleReq) (*RoleResp, error) + DeleteRole(context.Context, int) error + GetRole(context.Context, int) (*RoleDetailResp, error) + ListRoles(context.Context, *ListRoleReq) (*dto.ListResp[RoleResp], error) + UpdateRole(context.Context, *UpdateRoleReq, int) (*RoleResp, error) + AssignRolePermissions(context.Context, []int, int) error + RemoveRolePermissions(context.Context, []int, int) error + ListUsersFromRole(context.Context, int) ([]UserListItem, error) + GetPermission(context.Context, int) (*PermissionDetailResp, error) + ListPermissions(context.Context, *ListPermissionReq) (*dto.ListResp[PermissionResp], error) + ListRolesFromPermission(context.Context, int) ([]RoleResp, error) + GetResource(context.Context, int) (*ResourceResp, error) + ListResources(context.Context, *ListResourceReq) (*dto.ListResp[ResourceResp], error) + ListResourcePermissions(context.Context, int) ([]PermissionResp, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/rbac/module.go b/src/module/rbac/module.go new file mode 100644 index 00000000..5e29782f --- /dev/null +++ b/src/module/rbac/module.go @@ -0,0 +1,10 @@ +package rbac + +import "go.uber.org/fx" + +var Module = fx.Module("rbac", + fx.Provide(NewRepository), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/module/rbac/repository.go b/src/module/rbac/repository.go new file mode 100644 index 00000000..5544e1a5 --- /dev/null +++ b/src/module/rbac/repository.go @@ -0,0 +1,350 @@ +package rbac + +import ( + "aegis/consts" + "aegis/model" + "fmt" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) createRoleRecord(role *model.Role) error { + if err := r.db.Create(role).Error; err != nil { + return fmt.Errorf("failed to create role: %w", err) + } + return nil +} + +func (r *Repository) deleteRoleCascade(roleID int) (int64, error) { + role, err := r.loadRole(roleID) + if err != nil { + return 0, err + } + if role.IsSystem { + return 0, fmt.Errorf("%w: cannot delete system role", consts.ErrPermissionDenied) + } + + if err := r.db.Model(&model.UserContainer{}). + Where("role_id = ? AND status != ?", role.ID, consts.CommonDeleted). + Update("status", consts.CommonDeleted).Error; err != nil { + return 0, fmt.Errorf("failed to remove containers with role: %w", err) + } + if err := r.db.Model(&model.UserDataset{}). + Where("role_id = ? AND status != ?", role.ID, consts.CommonDeleted). + Update("status", consts.CommonDeleted).Error; err != nil { + return 0, fmt.Errorf("failed to remove datasets with role: %w", err) + } + if err := r.db.Model(&model.UserProject{}). + Where("role_id = ? AND status != ?", role.ID, consts.CommonDeleted). + Update("status", consts.CommonDeleted).Error; err != nil { + return 0, fmt.Errorf("failed to remove projects with role: %w", err) + } + if err := r.db.Where("role_id = ?", role.ID).Delete(&model.RolePermission{}).Error; err != nil { + return 0, fmt.Errorf("failed to remove permissions with role: %w", err) + } + if err := r.db.Where("role_id = ?", role.ID).Delete(&model.UserRole{}).Error; err != nil { + return 0, fmt.Errorf("failed to remove users with role: %w", err) + } + + result := r.db.Model(&model.Role{}). + Where("id = ? AND status != ?", role.ID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if result.Error != nil { + return 0, fmt.Errorf("failed to delete role %d: %w", role.ID, result.Error) + } + return result.RowsAffected, nil +} + +func (r *Repository) loadRoleDetail(roleID int) (*model.Role, int64, []model.Permission, error) { + role, err := r.loadRole(roleID) + if err != nil { + return nil, 0, nil, err + } + + var userCount int64 + if err := r.db.Table("users"). + Joins("JOIN user_roles ON users.id = user_roles.user_id"). + Where("user_roles.role_id = ? AND users.status = ?", role.ID, consts.CommonEnabled). + Count(&userCount).Error; err != nil { + return nil, 0, nil, fmt.Errorf("failed to get role user count: %w", err) + } + + var permissions []model.Permission + if err := r.db.Table("permissions"). + Joins("JOIN role_permissions ON permissions.id = role_permissions.permission_id"). + Where("role_permissions.role_id = ? AND permissions.status = ?", role.ID, consts.CommonEnabled). + Find(&permissions).Error; err != nil { + return nil, 0, nil, fmt.Errorf("failed to get role permissions: %w", err) + } + + return role, userCount, permissions, nil +} + +func (r *Repository) listRoleViews(limit, offset int, isSystem *bool, status *consts.StatusType) ([]model.Role, int64, error) { + var roles []model.Role + var total int64 + + query := r.db.Model(&model.Role{}) + if isSystem != nil { + query = query.Where("is_system = ?", *isSystem) + } + if status != nil { + query = query.Where("status = ?", *status) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count roles: %v", err) + } + if err := query.Limit(limit).Offset(offset).Order("updated_at DESC").Find(&roles).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list roles: %v", err) + } + return roles, total, nil +} + +func (r *Repository) updateMutableRole(roleID int, patch func(*model.Role)) (*model.Role, error) { + role, err := r.loadRole(roleID) + if err != nil { + return nil, err + } + if role.IsSystem { + return nil, fmt.Errorf("%w: cannot update system role", consts.ErrPermissionDenied) + } + + patch(role) + if err := r.db.Omit("ActiveName").Save(role).Error; err != nil { + return nil, fmt.Errorf("failed to update role: %w", err) + } + return role, nil +} + +func (r *Repository) assignRolePermissions(roleID int, permissionIDs []int) error { + role, err := r.loadRole(roleID) + if err != nil { + return err + } + if role.IsSystem { + return fmt.Errorf("%w: cannot assign permissions to system role", consts.ErrPermissionDenied) + } + + permissionMap, err := r.buildAssignablePermissionMap(permissionIDs) + if err != nil { + return err + } + + rolePermissions := make([]model.RolePermission, 0, len(permissionIDs)) + for _, permissionID := range permissionIDs { + if _, exists := permissionMap[permissionID]; !exists { + return fmt.Errorf("%w: permission id %d not found", consts.ErrNotFound, permissionID) + } + rolePermissions = append(rolePermissions, model.RolePermission{ + RoleID: role.ID, + PermissionID: permissionID, + }) + } + + if len(rolePermissions) == 0 { + return nil + } + if err := r.db.Create(&rolePermissions).Error; err != nil { + return fmt.Errorf("failed to batch create role permissions: %w", err) + } + return nil +} + +func (r *Repository) removeRolePermissions(roleID int, permissionIDs []int) error { + role, err := r.loadRole(roleID) + if err != nil { + return err + } + if role.IsSystem { + return fmt.Errorf("%w: cannot remove permissions of system role", consts.ErrPermissionDenied) + } + + permissionMap, err := r.buildAssignablePermissionMap(permissionIDs) + if err != nil { + return err + } + for _, permissionID := range permissionIDs { + if _, exists := permissionMap[permissionID]; !exists { + return fmt.Errorf("%w: permission id %d not found", consts.ErrNotFound, permissionID) + } + } + + if len(permissionIDs) == 0 { + return nil + } + if err := r.db.Where("role_id = ? AND permission_id IN (?)", role.ID, permissionIDs). + Delete(&model.RolePermission{}).Error; err != nil { + return fmt.Errorf("failed to batch delete role permissions: %w", err) + } + return nil +} + +func (r *Repository) listUsersFromRole(roleID int) (*model.Role, []model.User, error) { + role, err := r.loadRole(roleID) + if err != nil { + return nil, nil, err + } + + var users []model.User + if err := r.db.Table("users"). + Joins("JOIN user_roles ON users.id = user_roles.user_id"). + Where("user_roles.role_id = ? AND users.status = ?", role.ID, consts.CommonEnabled). + Find(&users).Error; err != nil { + return nil, nil, fmt.Errorf("failed to get role users: %w", err) + } + return role, users, nil +} + +func (r *Repository) getPermissionDetail(permissionID int) (*model.Permission, error) { + var permission model.Permission + if err := r.db.Preload("Resource"). + Where("id = ? and status != ?", permissionID, consts.CommonDeleted). + First(&permission).Error; err != nil { + return nil, fmt.Errorf("failed to find permission with id %d: %w", permissionID, err) + } + return &permission, nil +} + +func (r *Repository) listPermissionViews(limit, offset int, action consts.ActionName, isSystem *bool, status *consts.StatusType) ([]model.Permission, int64, error) { + var permissions []model.Permission + var total int64 + + query := r.db.Model(&model.Permission{}) + if action != "" { + query = query.Where("action = ?", action) + } + if isSystem != nil { + query = query.Where("is_system = ?", *isSystem) + } + if status != nil { + query = query.Where("status = ?", *status) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count permissions: %v", err) + } + if err := query.Limit(limit).Offset(offset).Order("updated_at DESC").Find(&permissions).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list permissions: %v", err) + } + return permissions, total, nil +} + +func (r *Repository) listRolesFromPermission(permissionID int) (*model.Permission, []model.Role, error) { + permission, err := r.getPermissionDetail(permissionID) + if err != nil { + return nil, nil, err + } + + var roles []model.Role + if err := r.db.Table("roles"). + Joins("JOIN role_permissions ON roles.id = role_permissions.role_id"). + Where("role_permissions.permission_id = ? AND roles.status != ?", permission.ID, consts.CommonDeleted). + Find(&roles).Error; err != nil { + return nil, nil, fmt.Errorf("failed to get permission roles: %w", err) + } + return permission, roles, nil +} + +func (r *Repository) getResourceDetail(resourceID int) (*model.Resource, error) { + var resource model.Resource + if err := r.db. + Where("id = ? and status != ?", resourceID, consts.CommonDeleted). + First(&resource).Error; err != nil { + return nil, fmt.Errorf("failed to find resource with id %d: %w", resourceID, err) + } + return &resource, nil +} + +func (r *Repository) listResourceViews(limit, offset int, resourceType *consts.ResourceType, category *consts.ResourceCategory) ([]model.Resource, int64, error) { + var resources []model.Resource + var total int64 + + query := r.db.Model(&model.Resource{}).Preload("Parent") + if resourceType != nil { + query = query.Where("type = ?", *resourceType) + } + if category != nil { + query = query.Where("category = ?", *category) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count resources: %v", err) + } + if err := query.Limit(limit).Offset(offset).Find(&resources).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list resources: %v", err) + } + return resources, total, nil +} + +func (r *Repository) listResourcePermissions(resourceID int) (*model.Resource, []model.Permission, error) { + resource, err := r.getResourceDetail(resourceID) + if err != nil { + return nil, nil, err + } + + var permissions []model.Permission + if err := r.db. + Where("resource_id = ? AND status = ?", resource.ID, consts.CommonEnabled). + Order("action"). + Find(&permissions).Error; err != nil { + return nil, nil, fmt.Errorf("failed to get permissions by resource: %w", err) + } + return resource, permissions, nil +} + +func (r *Repository) loadRole(roleID int) (*model.Role, error) { + var role model.Role + if err := r.db.Where("id = ? and status != ?", roleID, consts.CommonDeleted).First(&role).Error; err != nil { + return nil, fmt.Errorf("failed to find role with id %d: %w", roleID, err) + } + return &role, nil +} + +func (r *Repository) listPermissionsByIDs(permissionIDs []int) ([]model.Permission, error) { + if len(permissionIDs) == 0 { + return []model.Permission{}, nil + } + + var permissions []model.Permission + if err := r.db.Where("id IN (?) AND status = ?", permissionIDs, consts.CommonEnabled). + Find(&permissions).Error; err != nil { + return nil, fmt.Errorf("failed to query permissions: %w", err) + } + return permissions, nil +} + +func (r *Repository) buildAssignablePermissionMap(permissionIDs []int) (map[int]model.Permission, error) { + if len(permissionIDs) == 0 { + return map[int]model.Permission{}, nil + } + + unique := make(map[int]struct{}, len(permissionIDs)) + for _, id := range permissionIDs { + unique[id] = struct{}{} + } + + deduplicatedIDs := make([]int, 0, len(unique)) + for id := range unique { + deduplicatedIDs = append(deduplicatedIDs, id) + } + + permissions, err := r.listPermissionsByIDs(deduplicatedIDs) + if err != nil { + return nil, fmt.Errorf("failed to list permissions by ids: %w", err) + } + + result := make(map[int]model.Permission, len(permissions)) + for _, permission := range permissions { + result[permission.ID] = permission + } + return result, nil +} diff --git a/src/module/rbac/service.go b/src/module/rbac/service.go new file mode 100644 index 00000000..3ae88583 --- /dev/null +++ b/src/module/rbac/service.go @@ -0,0 +1,243 @@ +package rbac + +import ( + "context" + "errors" + "fmt" + + "aegis/consts" + "aegis/dto" + "aegis/model" + + "gorm.io/gorm" +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) CreateRole(_ context.Context, req *CreateRoleReq) (*RoleResp, error) { + role := req.ConvertToRole() + + if err := s.repo.db.Transaction(func(tx *gorm.DB) error { + if err := NewRepository(tx).createRoleRecord(role); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: role with name %s already exists", consts.ErrAlreadyExists, role.Name) + } + return err + } + return nil + }); err != nil { + return nil, err + } + + return NewRoleResp(role), nil +} + +func (s *Service) DeleteRole(_ context.Context, roleID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + rows, err := NewRepository(tx).deleteRoleCascade(roleID) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return fmt.Errorf("%w: role not found", consts.ErrNotFound) + } + return err + } + if rows == 0 { + return fmt.Errorf("%w: role id %d not found", consts.ErrNotFound, roleID) + } + return nil + }) +} + +func (s *Service) GetRole(_ context.Context, roleID int) (*RoleDetailResp, error) { + role, userCount, permissions, err := s.repo.loadRoleDetail(roleID) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return nil, fmt.Errorf("%w: role with ID %d not found", consts.ErrNotFound, roleID) + } + return nil, fmt.Errorf("failed to get role: %w", err) + } + + resp := NewRoleDetailResp(role) + resp.UserCount = userCount + + resp.Permissions = make([]PermissionResp, 0, len(permissions)) + for _, permission := range permissions { + resp.Permissions = append(resp.Permissions, *NewPermissionResp(&permission)) + } + + return resp, nil +} + +func (s *Service) ListRoles(_ context.Context, req *ListRoleReq) (*dto.ListResp[RoleResp], error) { + limit, offset := req.ToGormParams() + roles, total, err := s.repo.listRoleViews(limit, offset, req.IsSystem, req.Status) + if err != nil { + return nil, fmt.Errorf("failed to list roles: %w", err) + } + + items := make([]RoleResp, len(roles)) + for i, role := range roles { + items[i] = *NewRoleResp(&role) + } + + return &dto.ListResp[RoleResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) UpdateRole(_ context.Context, req *UpdateRoleReq, roleID int) (*RoleResp, error) { + var updatedRole *model.Role + + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + role, err := repo.updateMutableRole(roleID, func(existingRole *model.Role) { + req.PatchRoleModel(existingRole) + }) + if err != nil { + return err + } + updatedRole = role + return nil + }) + if err != nil { + return nil, err + } + + return NewRoleResp(updatedRole), nil +} + +func (s *Service) AssignRolePermissions(_ context.Context, permissionIDs []int, roleID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if err := repo.assignRolePermissions(roleID, permissionIDs); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: role already has one or more of these permissions", consts.ErrAlreadyExists) + } + return fmt.Errorf("failed to assign permissions to role: %w", err) + } + return nil + }) +} + +func (s *Service) RemoveRolePermissions(_ context.Context, permissionIDs []int, roleID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if err := repo.removeRolePermissions(roleID, permissionIDs); err != nil { + return fmt.Errorf("failed to remove permissions from role: %w", err) + } + return nil + }) +} + +func (s *Service) ListUsersFromRole(_ context.Context, roleID int) ([]UserListItem, error) { + _, users, err := s.repo.listUsersFromRole(roleID) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return nil, fmt.Errorf("%w: role not found", consts.ErrNotFound) + } + return nil, err + } + + userResps := make([]UserListItem, 0, len(users)) + for _, user := range users { + userResps = append(userResps, *NewUserListItem(&user)) + } + return userResps, nil +} + +func (s *Service) GetPermission(_ context.Context, permissionID int) (*PermissionDetailResp, error) { + permission, err := s.repo.getPermissionDetail(permissionID) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return nil, fmt.Errorf("%w: permission not found", consts.ErrNotFound) + } + return nil, fmt.Errorf("failed to get permission: %w", err) + } + return NewPermissionDetailResp(permission), nil +} + +func (s *Service) ListPermissions(_ context.Context, req *ListPermissionReq) (*dto.ListResp[PermissionResp], error) { + limit, offset := req.ToGormParams() + permissions, total, err := s.repo.listPermissionViews(limit, offset, req.Action, req.IsSystem, req.Status) + if err != nil { + return nil, fmt.Errorf("failed to list permissions: %w", err) + } + + items := make([]PermissionResp, len(permissions)) + for i, permission := range permissions { + items[i] = *NewPermissionResp(&permission) + } + + return &dto.ListResp[PermissionResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) ListRolesFromPermission(_ context.Context, permissionID int) ([]RoleResp, error) { + _, roles, err := s.repo.listRolesFromPermission(permissionID) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return nil, fmt.Errorf("%w: permission not found", consts.ErrNotFound) + } + return nil, err + } + + items := make([]RoleResp, 0, len(roles)) + for _, role := range roles { + items = append(items, *NewRoleResp(&role)) + } + return items, nil +} + +func (s *Service) GetResource(_ context.Context, resourceID int) (*ResourceResp, error) { + resource, err := s.repo.getResourceDetail(resourceID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, consts.ErrNotFound) { + return nil, fmt.Errorf("%w: resource with ID %d not found", consts.ErrNotFound, resourceID) + } + return nil, fmt.Errorf("failed to get resource: %w", err) + } + return NewResourceResp(resource), nil +} + +func (s *Service) ListResources(_ context.Context, req *ListResourceReq) (*dto.ListResp[ResourceResp], error) { + limit, offset := req.ToGormParams() + resources, total, err := s.repo.listResourceViews(limit, offset, req.Type, req.Category) + if err != nil { + return nil, fmt.Errorf("failed to list resources: %w", err) + } + + items := make([]ResourceResp, 0, len(resources)) + for i := range resources { + items = append(items, *NewResourceResp(&resources[i])) + } + + return &dto.ListResp[ResourceResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) ListResourcePermissions(_ context.Context, resourceID int) ([]PermissionResp, error) { + _, permissions, err := s.repo.listResourcePermissions(resourceID) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return nil, fmt.Errorf("%w: resource with ID %d not found", consts.ErrNotFound, resourceID) + } + return nil, err + } + + items := make([]PermissionResp, 0, len(permissions)) + for _, permission := range permissions { + items = append(items, *NewPermissionResp(&permission)) + } + return items, nil +} diff --git a/src/module/rbac/service_test.go b/src/module/rbac/service_test.go new file mode 100644 index 00000000..3d99a853 --- /dev/null +++ b/src/module/rbac/service_test.go @@ -0,0 +1,66 @@ +package rbac + +import ( + "regexp" + "testing" + "time" + + "aegis/consts" + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func newRBACService(t *testing.T) (*Service, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New() + require.NoError(t, err) + + db, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + require.NoError(t, err) + + return NewService(NewRepository(db)), mock, func() { + _ = sqlDB.Close() + } +} + +func TestServiceListRolesSuccess(t *testing.T) { + service, mock, cleanup := newRBACService(t) + defer cleanup() + + now := time.Now() + mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM `roles`")). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `roles` ORDER BY updated_at DESC LIMIT ?")). + WithArgs(20). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "display_name", "description", "is_system", "status", "created_at", "updated_at", + }).AddRow(1, "admin", "Admin", "system admin", false, consts.CommonEnabled, now, now)) + + resp, err := service.ListRoles(t.Context(), &ListRoleReq{}) + + require.NoError(t, err) + require.Len(t, resp.Items, 1) + require.Equal(t, "admin", resp.Items[0].Name) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestServiceGetRoleNotFound(t *testing.T) { + service, mock, cleanup := newRBACService(t) + defer cleanup() + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `roles` WHERE id = ? and status != ? ORDER BY `roles`.`id` LIMIT ?")). + WithArgs(99, consts.CommonDeleted, 1). + WillReturnError(gorm.ErrRecordNotFound) + + _, err := service.GetRole(t.Context(), 99) + + require.Error(t, err) + require.ErrorContains(t, err, "failed to get role") + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/src/dto/sdk_evaluation.go b/src/module/sdk/api_types.go similarity index 92% rename from src/dto/sdk_evaluation.go rename to src/module/sdk/api_types.go index 2e5ac672..6da3f4ad 100644 --- a/src/dto/sdk_evaluation.go +++ b/src/module/sdk/api_types.go @@ -1,10 +1,14 @@ -package dto +package sdk -import "fmt" +import ( + "fmt" + + "aegis/dto" +) // ListSDKEvaluationReq represents the request for listing SDK evaluation samples. type ListSDKEvaluationReq struct { - PaginationReq + dto.PaginationReq ExpID string `form:"exp_id"` Stage string `form:"stage"` // "init", "rollout", "judged" } @@ -30,7 +34,7 @@ type SDKExperimentListResp struct { // ListSDKDatasetSampleReq represents the request for listing SDK dataset samples. type ListSDKDatasetSampleReq struct { - PaginationReq + dto.PaginationReq Dataset string `form:"dataset"` } diff --git a/src/module/sdk/handler.go b/src/module/sdk/handler.go new file mode 100644 index 00000000..6ebcff6c --- /dev/null +++ b/src/module/sdk/handler.go @@ -0,0 +1,133 @@ +package sdk + +import ( + "aegis/httpx" + "net/http" + + "aegis/consts" + "aegis/dto" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +// ListSDKEvaluations handles listing SDK evaluation samples with pagination +// +// @Summary List SDK evaluation samples +// @Description Get a paginated list of SDK evaluation samples, optionally filtered by exp_id and stage +// @Tags Evaluations +// @ID list_sdk_evaluations +// @Produce json +// @Security BearerAuth +// @Param exp_id query string false "Experiment ID filter" +// @Param stage query string false "Stage filter (init, rollout, judged)" +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Success 200 {object} dto.GenericResponse[dto.ListResp[SDKEvaluationSample]] "SDK evaluations retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/sdk/evaluations [get] +// @x-api-type {"sdk":"true"} +func (h *Handler) ListEvaluations(c *gin.Context) { + var req ListSDKEvaluationReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + resp, err := h.service.ListEvaluations(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// GetSDKEvaluation handles getting a single SDK evaluation sample by ID +// +// @Summary Get SDK evaluation sample by ID +// @Description Get detailed information about a specific SDK evaluation sample +// @Tags Evaluations +// @ID get_sdk_evaluation +// @Produce json +// @Security BearerAuth +// @Param id path int true "SDK Evaluation Sample ID" +// @Success 200 {object} dto.GenericResponse[SDKEvaluationSample] "SDK evaluation sample retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid evaluation ID" +// @Failure 404 {object} dto.GenericResponse[any] "SDK evaluation sample not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/sdk/evaluations/{id} [get] +// @x-api-type {"sdk":"true"} +func (h *Handler) GetEvaluation(c *gin.Context) { + id, ok := httpx.ParsePositiveID(c, c.Param(consts.URLPathID), "SDK evaluation ID") + if !ok { + return + } + resp, err := h.service.GetEvaluation(c.Request.Context(), id) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// ListSDKExperiments handles listing all distinct experiment IDs +// +// @Summary List SDK experiment IDs +// @Description Get all distinct experiment IDs from SDK evaluation data +// @Tags Evaluations +// @ID list_sdk_experiments +// @Produce json +// @Security BearerAuth +// @Success 200 {object} dto.GenericResponse[SDKExperimentListResp] "SDK experiments retrieved successfully" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/sdk/evaluations/experiments [get] +// @x-api-type {"sdk":"true"} +func (h *Handler) ListExperiments(c *gin.Context) { + resp, err := h.service.ListExperiments(c.Request.Context()) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// ListSDKDatasetSamples handles listing SDK dataset samples with pagination +// +// @Summary List SDK dataset samples +// @Description Get a paginated list of SDK dataset samples, optionally filtered by dataset name +// @Tags Datasets +// @ID list_sdk_dataset_samples +// @Produce json +// @Security BearerAuth +// @Param dataset query string false "Dataset name filter" +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Success 200 {object} dto.GenericResponse[dto.ListResp[SDKDatasetSample]] "SDK dataset samples retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/sdk/datasets [get] +// @x-api-type {"sdk":"true"} +func (h *Handler) ListDatasetSamples(c *gin.Context) { + var req ListSDKDatasetSampleReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + resp, err := h.service.ListDatasetSamples(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} diff --git a/src/database/sdk_entities.go b/src/module/sdk/models.go similarity index 94% rename from src/database/sdk_entities.go rename to src/module/sdk/models.go index 826340e9..78ba51e5 100644 --- a/src/database/sdk_entities.go +++ b/src/module/sdk/models.go @@ -1,9 +1,9 @@ -package database +package sdk import "time" // SDKDatasetSample maps to the Python SDK's `data` table (read-only from AegisLab). -// Do NOT add this to AutoMigrate — the SDK creates and manages this table. +// Do NOT add this to AutoMigrate - the SDK creates and manages this table. type SDKDatasetSample struct { ID int `gorm:"primaryKey;column:id" json:"id"` Dataset string `gorm:"column:dataset" json:"dataset"` @@ -22,7 +22,7 @@ type SDKDatasetSample struct { func (SDKDatasetSample) TableName() string { return "data" } // SDKEvaluationSample maps to the Python SDK's `evaluation_data` table (read-only from AegisLab). -// Do NOT add this to AutoMigrate — the SDK creates and manages this table. +// Do NOT add this to AutoMigrate - the SDK creates and manages this table. type SDKEvaluationSample struct { ID int `gorm:"primaryKey;column:id" json:"id"` CreatedAt *time.Time `gorm:"column:created_at" json:"created_at"` diff --git a/src/module/sdk/module.go b/src/module/sdk/module.go new file mode 100644 index 00000000..57301321 --- /dev/null +++ b/src/module/sdk/module.go @@ -0,0 +1,9 @@ +package sdk + +import "go.uber.org/fx" + +var Module = fx.Module("sdk", + fx.Provide(NewRepository), + fx.Provide(NewService), + fx.Provide(NewHandler), +) diff --git a/src/repository/sdk_evaluation.go b/src/module/sdk/repository.go similarity index 54% rename from src/repository/sdk_evaluation.go rename to src/module/sdk/repository.go index 6fdd643d..9c77de2a 100644 --- a/src/repository/sdk_evaluation.go +++ b/src/module/sdk/repository.go @@ -1,33 +1,27 @@ -package repository +package sdk import ( "fmt" "strings" - "aegis/database" - "gorm.io/gorm" ) -// isTableNotExistError checks if the error indicates the table does not exist. -// This handles the case where the SDK tables have not been created yet. -func isTableNotExistError(err error) bool { - if err == nil { - return false - } - msg := err.Error() - return strings.Contains(msg, "doesn't exist") || - strings.Contains(msg, "does not exist") || - strings.Contains(msg, "no such table") +type Repository struct { + db *gorm.DB } -// ListSDKEvaluations returns paginated SDK evaluation samples filtered by exp_id and stage. -func ListSDKEvaluations(db *gorm.DB, expID string, stage string, limit, offset int) ([]database.SDKEvaluationSample, int64, error) { - var items []database.SDKEvaluationSample - var total int64 +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} - query := db.Model(&database.SDKEvaluationSample{}) +func (r *Repository) ListSDKEvaluations(expID, stage string, limit, offset int) ([]SDKEvaluationSample, int64, error) { + var ( + items []SDKEvaluationSample + total int64 + ) + query := r.db.Model(&SDKEvaluationSample{}) if expID != "" { query = query.Where("exp_id = ?", expID) } @@ -37,14 +31,13 @@ func ListSDKEvaluations(db *gorm.DB, expID string, stage string, limit, offset i if err := query.Count(&total).Error; err != nil { if isTableNotExistError(err) { - return []database.SDKEvaluationSample{}, 0, nil + return []SDKEvaluationSample{}, 0, nil } return nil, 0, fmt.Errorf("failed to count SDK evaluation samples: %w", err) } - if err := query.Limit(limit).Offset(offset).Order("id DESC").Find(&items).Error; err != nil { if isTableNotExistError(err) { - return []database.SDKEvaluationSample{}, 0, nil + return []SDKEvaluationSample{}, 0, nil } return nil, 0, fmt.Errorf("failed to list SDK evaluation samples: %w", err) } @@ -52,10 +45,9 @@ func ListSDKEvaluations(db *gorm.DB, expID string, stage string, limit, offset i return items, total, nil } -// GetSDKEvaluationByID returns a single SDK evaluation sample by its ID. -func GetSDKEvaluationByID(db *gorm.DB, id int) (*database.SDKEvaluationSample, error) { - var item database.SDKEvaluationSample - if err := db.Where("id = ?", id).First(&item).Error; err != nil { +func (r *Repository) GetSDKEvaluationByID(id int) (*SDKEvaluationSample, error) { + var item SDKEvaluationSample + if err := r.db.Where("id = ?", id).First(&item).Error; err != nil { if isTableNotExistError(err) { return nil, fmt.Errorf("SDK evaluation sample with id %d not found (table does not exist)", id) } @@ -67,10 +59,9 @@ func GetSDKEvaluationByID(db *gorm.DB, id int) (*database.SDKEvaluationSample, e return &item, nil } -// ListSDKExperiments returns all distinct exp_id values from the evaluation_data table. -func ListSDKExperiments(db *gorm.DB) ([]string, error) { +func (r *Repository) ListSDKExperiments() ([]string, error) { var expIDs []string - if err := db.Model(&database.SDKEvaluationSample{}).Distinct("exp_id").Pluck("exp_id", &expIDs).Error; err != nil { + if err := r.db.Model(&SDKEvaluationSample{}).Distinct("exp_id").Pluck("exp_id", &expIDs).Error; err != nil { if isTableNotExistError(err) { return []string{}, nil } @@ -79,30 +70,39 @@ func ListSDKExperiments(db *gorm.DB) ([]string, error) { return expIDs, nil } -// ListSDKDatasetSamples returns paginated SDK dataset samples filtered by dataset name. -func ListSDKDatasetSamples(db *gorm.DB, dataset string, limit, offset int) ([]database.SDKDatasetSample, int64, error) { - var items []database.SDKDatasetSample - var total int64 - - query := db.Model(&database.SDKDatasetSample{}) +func (r *Repository) ListSDKDatasetSamples(dataset string, limit, offset int) ([]SDKDatasetSample, int64, error) { + var ( + items []SDKDatasetSample + total int64 + ) + query := r.db.Model(&SDKDatasetSample{}) if dataset != "" { query = query.Where("dataset = ?", dataset) } if err := query.Count(&total).Error; err != nil { if isTableNotExistError(err) { - return []database.SDKDatasetSample{}, 0, nil + return []SDKDatasetSample{}, 0, nil } return nil, 0, fmt.Errorf("failed to count SDK dataset samples: %w", err) } - if err := query.Limit(limit).Offset(offset).Order("id DESC").Find(&items).Error; err != nil { if isTableNotExistError(err) { - return []database.SDKDatasetSample{}, 0, nil + return []SDKDatasetSample{}, 0, nil } return nil, 0, fmt.Errorf("failed to list SDK dataset samples: %w", err) } return items, total, nil } + +func isTableNotExistError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "doesn't exist") || + strings.Contains(msg, "does not exist") || + strings.Contains(msg, "no such table") +} diff --git a/src/module/sdk/service.go b/src/module/sdk/service.go new file mode 100644 index 00000000..97b56885 --- /dev/null +++ b/src/module/sdk/service.go @@ -0,0 +1,52 @@ +package sdk + +import ( + "context" + "fmt" + + "aegis/dto" +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) ListEvaluations(_ context.Context, req *ListSDKEvaluationReq) (*dto.ListResp[SDKEvaluationSample], error) { + limit, offset := req.ToGormParams() + items, total, err := s.repo.ListSDKEvaluations(req.ExpID, req.Stage, limit, offset) + if err != nil { + return nil, fmt.Errorf("failed to list SDK evaluations: %w", err) + } + return &dto.ListResp[SDKEvaluationSample]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) GetEvaluation(_ context.Context, id int) (*SDKEvaluationSample, error) { + return s.repo.GetSDKEvaluationByID(id) +} + +func (s *Service) ListExperiments(_ context.Context) (*SDKExperimentListResp, error) { + items, err := s.repo.ListSDKExperiments() + if err != nil { + return nil, fmt.Errorf("failed to list SDK experiments: %w", err) + } + return &SDKExperimentListResp{Experiments: items}, nil +} + +func (s *Service) ListDatasetSamples(_ context.Context, req *ListSDKDatasetSampleReq) (*dto.ListResp[SDKDatasetSample], error) { + limit, offset := req.ToGormParams() + items, total, err := s.repo.ListSDKDatasetSamples(req.Dataset, limit, offset) + if err != nil { + return nil, fmt.Errorf("failed to list SDK dataset samples: %w", err) + } + return &dto.ListResp[SDKDatasetSample]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} diff --git a/src/module/sdk/service_test.go b/src/module/sdk/service_test.go new file mode 100644 index 00000000..3a525136 --- /dev/null +++ b/src/module/sdk/service_test.go @@ -0,0 +1,117 @@ +package sdk + +import ( + "regexp" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func newSDKService(t *testing.T) (*Service, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New() + require.NoError(t, err) + + db, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + require.NoError(t, err) + + return NewService(NewRepository(db)), mock, func() { + _ = sqlDB.Close() + } +} + +func TestSDKServiceListEvaluationsSuccess(t *testing.T) { + service, mock, cleanup := newSDKService(t) + defer cleanup() + + now := time.Now() + mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM `evaluation_data` WHERE exp_id = ? AND stage = ?")). + WithArgs("exp-1", "judged"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `evaluation_data` WHERE exp_id = ? AND stage = ? ORDER BY id DESC LIMIT ?")). + WithArgs("exp-1", "judged", 20). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "created_at", "updated_at", "dataset", "dataset_index", "source", "raw_question", "level", + "augmented_question", "correct_answer", "file_name", "meta", "trace_id", "trace_url", "response", + "time_cost", "trajectories", "extracted_final_answer", "judged_response", "reasoning", "correct", + "confidence", "exp_id", "agent_type", "model_name", "stage", + }).AddRow(1, now, now, "demo", 0, "manual", "q", 1, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, true, 0.9, "exp-1", nil, nil, "judged")) + + resp, err := service.ListEvaluations(t.Context(), &ListSDKEvaluationReq{ + ExpID: "exp-1", + Stage: "judged", + }) + + require.NoError(t, err) + require.Len(t, resp.Items, 1) + require.Equal(t, "exp-1", resp.Items[0].ExpID) + require.Equal(t, "judged", resp.Items[0].Stage) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestSDKServiceGetEvaluationSuccess(t *testing.T) { + service, mock, cleanup := newSDKService(t) + defer cleanup() + + now := time.Now() + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `evaluation_data` WHERE id = ? ORDER BY `evaluation_data`.`id` LIMIT ?")). + WithArgs(3, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "created_at", "updated_at", "dataset", "dataset_index", "source", "raw_question", "level", + "augmented_question", "correct_answer", "file_name", "meta", "trace_id", "trace_url", "response", + "time_cost", "trajectories", "extracted_final_answer", "judged_response", "reasoning", "correct", + "confidence", "exp_id", "agent_type", "model_name", "stage", + }).AddRow(3, now, now, "demo", 0, "manual", "q", 1, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, false, 0.2, "exp-2", nil, nil, "rollout")) + + item, err := service.GetEvaluation(t.Context(), 3) + + require.NoError(t, err) + require.Equal(t, 3, item.ID) + require.Equal(t, "exp-2", item.ExpID) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestSDKServiceListExperimentsSuccess(t *testing.T) { + service, mock, cleanup := newSDKService(t) + defer cleanup() + + mock.ExpectQuery(regexp.QuoteMeta("SELECT DISTINCT `exp_id` FROM `evaluation_data`")). + WillReturnRows(sqlmock.NewRows([]string{"exp_id"}).AddRow("exp-1").AddRow("exp-2")) + + resp, err := service.ListExperiments(t.Context()) + + require.NoError(t, err) + require.Equal(t, []string{"exp-1", "exp-2"}, resp.Experiments) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestSDKServiceListDatasetSamplesSuccess(t *testing.T) { + service, mock, cleanup := newSDKService(t) + defer cleanup() + + mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM `data` WHERE dataset = ?")). + WithArgs("gsm8k"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `data` WHERE dataset = ? ORDER BY id DESC LIMIT ?")). + WithArgs("gsm8k", 20). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "dataset", "index", "source", "source_index", "question", "answer", "topic", "level", "file_name", "meta", "tags", + }).AddRow(2, "gsm8k", 1, "manual", 0, "question", "answer", "math", 2, "sample.json", nil, nil)) + + resp, err := service.ListDatasetSamples(t.Context(), &ListSDKDatasetSampleReq{ + Dataset: "gsm8k", + }) + + require.NoError(t, err) + require.Len(t, resp.Items, 1) + require.Equal(t, "gsm8k", resp.Items[0].Dataset) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/src/module/system/api_types.go b/src/module/system/api_types.go new file mode 100644 index 00000000..cd726509 --- /dev/null +++ b/src/module/system/api_types.go @@ -0,0 +1,431 @@ +package system + +import ( + "fmt" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/model" + systemmetric "aegis/module/systemmetric" + task "aegis/module/task" +) + +// HealthCheckResp represents system health check response. +type HealthCheckResp struct { + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` + Version string `json:"version"` + Uptime string `json:"uptime"` + Services map[string]ServiceInfo `json:"services" swaggertype:"object"` +} + +// ServiceInfo represents individual service health information. +type ServiceInfo struct { + Status string `json:"status"` + LastChecked time.Time `json:"last_checked"` + ResponseTime string `json:"response_time"` + Error string `json:"error,omitempty"` + Details any `json:"details,omitempty"` +} + +// SystemInfo represents system information. +type SystemInfo struct { + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + DiskUsage float64 `json:"disk_usage"` + LoadAverage string `json:"load_average"` +} + +// MonitoringQueryReq represents monitoring query request. +type MonitoringQueryReq struct { + Query string `json:"query" binding:"required"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Step string `json:"step,omitempty"` +} + +// MonitoringMetricsResp represents monitoring metrics response. +type MonitoringMetricsResp struct { + Timestamp time.Time `json:"timestamp"` + Metrics map[string]MetricValue `json:"metrics"` + Labels map[string]string `json:"labels,omitempty"` +} + +type MetricValue = systemmetric.MetricValue +type ListNamespaceLockResp = systemmetric.ListNamespaceLockResp +type QueuedTasksResp = task.QueuedTasksResp + +type ListAuditLogFilters struct { + Action string + IPAddress string + UserID int + ResourceID int + State *consts.AuditLogState + Status *consts.StatusType + StartTime *time.Time + EndTime *time.Time +} + +type ListAuditLogReq struct { + dto.PaginationReq + + Action string `form:"action" binding:"omitempty"` + IPAddress string `form:"ip_address" binding:"omitempty"` + UserID int `form:"user_id" binding:"omitempty"` + ResourceID int `form:"resource_id" binding:"omitempty"` + State *consts.AuditLogState `form:"state" binding:"omitempty"` + Status *consts.StatusType `form:"status" binding:"omitempty"` + StartDate string `form:"start_date" binding:"omitempty"` + EndDate string `form:"end_date" binding:"omitempty"` +} + +func (req *ListAuditLogReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + if err := validateDateField(req.StartDate); err != nil { + return fmt.Errorf("invalid start_time: %w", err) + } + if err := validateDateField(req.EndDate); err != nil { + return fmt.Errorf("invalid end_time: %w", err) + } + if req.State != nil { + if _, exists := consts.ValidAuditLogStates[*req.State]; !exists { + return fmt.Errorf("invalid state: %d", *req.State) + } + } + return validateStatusValue(req.Status, false) +} + +func (req *ListAuditLogReq) ToFilterOptions() *ListAuditLogFilters { + var startTimePtr, endTimePtr *time.Time + if req.StartDate != "" { + startTime, _ := time.Parse(time.DateOnly, req.StartDate) + startTimePtr = &startTime + } + if req.EndDate != "" { + endTime, _ := time.Parse(time.DateOnly, req.EndDate) + endTimePtr = &endTime + } + + return &ListAuditLogFilters{ + Action: req.Action, + IPAddress: req.IPAddress, + UserID: req.UserID, + ResourceID: req.ResourceID, + State: req.State, + Status: req.Status, + StartTime: startTimePtr, + EndTime: endTimePtr, + } +} + +type AuditLogResp struct { + ID int `json:"id"` + Action string `json:"action"` + IPAddress string `json:"ip_address"` + Duration int `json:"duration"` + UserAgent string `json:"user_agent"` + UserID int `json:"user_id,omitempty"` + Username string `json:"username,omitempty"` + ResourceID int `json:"resource_id,omitempty"` + Resource consts.ResourceName `json:"resource,omitempty"` + State string `json:"state"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +func NewAuditLogResp(log *model.AuditLog) *AuditLogResp { + resp := &AuditLogResp{ + ID: log.ID, + Action: log.Action, + IPAddress: log.IPAddress, + Duration: log.Duration, + UserAgent: log.UserAgent, + UserID: log.UserID, + ResourceID: log.ResourceID, + State: consts.GetAuditLogStateName(log.State), + Status: consts.GetStatusTypeName(log.Status), + CreatedAt: log.CreatedAt, + } + if log.User != nil { + resp.Username = log.User.Username + } + if log.Resource != nil { + resp.Resource = log.Resource.Name + } + return resp +} + +type AuditLogDetailResp struct { + AuditLogResp + Details string `json:"details"` + ErrorMsg string `json:"error_msg,omitempty"` +} + +func NewAuditLogDetailResp(log *model.AuditLog) *AuditLogDetailResp { + return &AuditLogDetailResp{ + AuditLogResp: *NewAuditLogResp(log), + Details: log.Details, + ErrorMsg: log.ErrorMsg, + } +} + +type ListConfigReq struct { + dto.PaginationReq + ValueType *consts.ConfigValueType `form:"value_type" binding:"omitempty"` + Category *string `form:"category" binding:"omitempty"` + IsSecret *bool `form:"is_secret" binding:"omitempty"` + UpdatedBy *int `form:"updated_by" binding:"omitempty,min_ptr=1"` +} + +func (req *ListConfigReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + return validateConfigValueType(req.ValueType) +} + +type RollbackConfigReq struct { + HistoryID int `json:"history_id" binding:"required,min=1"` + Reason string `json:"reason" binding:"required"` +} + +type UpdateConfigValueReq struct { + Value string `json:"value" binding:"required"` + Reason string `json:"reason" binding:"required"` +} + +type UpdateConfigMetadataReq struct { + DefaultValue *string `json:"default_value" binding:"omitempty"` + Description *string `json:"description" binding:"omitempty"` + MinValue *float64 `json:"min_value" binding:"omitempty"` + MaxValue *float64 `json:"max_value" binding:"omitempty"` + Pattern *string `json:"pattern" binding:"omitempty"` + Options *string `json:"options" binding:"omitempty"` + Reason string `json:"reason" binding:"required"` +} + +func (req *UpdateConfigMetadataReq) Validate() error { + fieldCount := 0 + if req.DefaultValue != nil { + fieldCount++ + } + if req.Description != nil { + fieldCount++ + } + if req.MinValue != nil { + fieldCount++ + } + if req.MaxValue != nil { + fieldCount++ + } + if req.Pattern != nil { + fieldCount++ + } + if req.Options != nil { + fieldCount++ + } + + if fieldCount == 0 { + return fmt.Errorf("at least one metadata field must be provided for update") + } + if fieldCount > 1 { + return fmt.Errorf("can only update one metadata field at a time") + } + return nil +} + +func (req *UpdateConfigMetadataReq) PatchConfigModel(target *model.DynamicConfig) (string, string) { + var oldValue string + var newValue string + + if req.DefaultValue != nil { + oldValue = target.DefaultValue + newValue = *req.DefaultValue + target.DefaultValue = *req.DefaultValue + } + if req.Description != nil { + oldValue = target.Description + newValue = *req.Description + target.Description = *req.Description + } + if req.MinValue != nil { + oldValue = fmt.Sprintf("%v", target.MinValue) + newValue = fmt.Sprintf("%v", req.MinValue) + target.MinValue = req.MinValue + } + if req.MaxValue != nil { + oldValue = fmt.Sprintf("%v", target.MaxValue) + newValue = fmt.Sprintf("%v", req.MaxValue) + target.MaxValue = req.MaxValue + } + if req.Pattern != nil { + oldValue = target.Pattern + newValue = *req.Pattern + target.Pattern = *req.Pattern + } + if req.Options != nil { + oldValue = target.Options + newValue = *req.Options + target.Options = *req.Options + } + + return oldValue, newValue +} + +func (req *UpdateConfigMetadataReq) GetChangeField() consts.ConfigHistoryChangeField { + if req.DefaultValue != nil { + return consts.ChangeFieldDefaultValue + } + if req.Description != nil { + return consts.ChangeFieldDescription + } + if req.MinValue != nil { + return consts.ChangeFieldMinValue + } + if req.MaxValue != nil { + return consts.ChangeFieldMaxValue + } + if req.Pattern != nil { + return consts.ChangeFieldPattern + } + if req.Options != nil { + return consts.ChangeFieldOptions + } + return consts.ChangeFieldValue +} + +type ListConfigHistoryReq struct { + dto.PaginationReq + ChangeType *consts.ConfigHistoryChangeType `form:"change_type" binding:"omitempty"` + OperatorID *int `form:"operator_id" binding:"omitempty,min_ptr=1"` +} + +func (req *ListConfigHistoryReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + if req.ChangeType != nil { + if _, ok := consts.ValidConfigHistoryChanteTypes[*req.ChangeType]; !ok { + return fmt.Errorf("invalid change type: %v", req.ChangeType) + } + } + return nil +} + +type ConfigResp struct { + ID int `json:"id"` + Key string `json:"key"` + ValueType string `json:"value_type"` + Category string `json:"category"` + UpdatedAt time.Time `json:"updated_at"` + UpdatedByID int `json:"updated_by_id"` + UpdatedByName string `json:"updated_by_name"` +} + +func NewConfigResp(config *model.DynamicConfig) *ConfigResp { + resp := &ConfigResp{ + ID: config.ID, + Key: config.Key, + ValueType: consts.GetDynamicConfigTypeName(config.ValueType), + Category: config.Category, + UpdatedAt: config.UpdatedAt, + } + if config.UpdatedByUser != nil { + resp.UpdatedByName = config.UpdatedByUser.Username + } + return resp +} + +type ConfigDetailResp struct { + ConfigResp + DefaultValue string `json:"default_value"` + Description string `json:"description"` + MinValue *float64 `json:"min_value,omitempty"` + MaxValue *float64 `json:"max_value,omitempty"` + Pattern string `json:"pattern,omitempty"` + Options string `json:"options,omitempty"` + Histories []ConfigHistoryResp `json:"histories,omitempty"` +} + +func NewConfigDetailResp(config *model.DynamicConfig) *ConfigDetailResp { + return &ConfigDetailResp{ + ConfigResp: *NewConfigResp(config), + DefaultValue: config.DefaultValue, + Description: config.Description, + MinValue: config.MinValue, + MaxValue: config.MaxValue, + Pattern: config.Pattern, + Options: config.Options, + } +} + +type ConfigHistoryResp struct { + ID int `json:"id"` + ChangeType string `json:"change_type"` + OldValue string `json:"old_value"` + NewValue string `json:"new_value"` + Reason string `json:"reason"` + ConfigID int `json:"config_id"` + OperatorID *int `json:"operator_id"` + OperatorName string `json:"operator_name,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + RolledBackFromID *int `json:"rolled_back_from_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func NewConfigHistoryResp(history *model.ConfigHistory) *ConfigHistoryResp { + resp := &ConfigHistoryResp{ + ID: history.ID, + ChangeType: consts.GetConfigHistoryChangeTypeName(history.ChangeType), + ConfigID: history.ConfigID, + OldValue: history.OldValue, + NewValue: history.NewValue, + Reason: history.Reason, + OperatorID: history.OperatorID, + IPAddress: history.IPAddress, + UserAgent: history.UserAgent, + RolledBackFromID: history.RolledBackFromID, + CreatedAt: history.CreatedAt, + } + if history.Operator != nil { + resp.OperatorName = history.Operator.Username + } + return resp +} + +func validateDateField(value string) error { + if value == "" { + return nil + } + if _, err := time.Parse(time.DateOnly, value); err != nil { + return fmt.Errorf("invalid time format: %s", value) + } + return nil +} + +func validateStatusValue(statusPtr *consts.StatusType, isMutation bool) error { + if statusPtr == nil { + return nil + } + status := *statusPtr + if _, exists := consts.ValidStatuses[status]; !exists { + return fmt.Errorf("invalid status value: %d", status) + } + if isMutation && status == consts.CommonDeleted { + return fmt.Errorf("status value cannot be set to deleted (%d) directly through this update/create operation", consts.CommonDeleted) + } + return nil +} + +func validateConfigValueType(valueType *consts.ConfigValueType) error { + if valueType != nil { + if _, ok := consts.ValidDynamicConfigTypes[*valueType]; !ok { + return fmt.Errorf("invalid value type: %v", valueType) + } + } + return nil +} diff --git a/src/module/system/handler.go b/src/module/system/handler.go new file mode 100644 index 00000000..50068112 --- /dev/null +++ b/src/module/system/handler.go @@ -0,0 +1,508 @@ +package system + +import ( + "aegis/httpx" + "net/http" + "strconv" + + "aegis/consts" + "aegis/dto" + "aegis/middleware" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + +// GetHealth handles system health check +// +// @Summary System health check +// @Description Get system health status and service information +// @Tags System +// @ID get_system_health +// @Produce json +// @Success 200 {object} dto.GenericResponse[HealthCheckResp] "Health check successful" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /system/health [get] +// @x-api-type {"admin":"true"} +func (h *Handler) GetHealth(c *gin.Context) { + resp, err := h.service.GetHealth(c.Request.Context()) + if err != nil { + dto.ErrorResponse(c, http.StatusInternalServerError, "Failed to get health status: "+err.Error()) + return + } + dto.SuccessResponse(c, resp) +} + +// GetMetrics handles monitoring metrics query +// +// @Summary Get monitoring metrics +// @Description Deprecated: This endpoint returns hardcoded/fabricated data. Use the v2 equivalent GET /api/v2/system/metrics which provides real system metrics via gopsutil. +// @Deprecated +// @Tags System +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body MonitoringQueryReq true "Metrics query request" +// @Success 200 {object} dto.GenericResponse[MonitoringMetricsResp] "Metrics retrieved successfully" +// @Success 400 {object} dto.GenericResponse[any] "Invalid request format" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /system/monitor/metrics [post] +// @x-api-type {"admin":"true"} +func (h *Handler) GetMetrics(c *gin.Context) { + var req MonitoringQueryReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + c.Header("Deprecation", "true") + c.Header("Link", `; rel="successor-version"`) + resp, err := h.service.GetMetrics(c.Request.Context()) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// GetSystemInfo handles basic system information +// +// @Summary Get system information +// @Description Deprecated: This endpoint returns partially hardcoded data. Use the v2 equivalent GET /api/v2/system/metrics which provides real system metrics via gopsutil. +// @Deprecated +// @Tags System +// @Produce json +// @Security BearerAuth +// @Success 200 {object} dto.GenericResponse[SystemInfo] "System info retrieved successfully" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /system/monitor/info [get] +// @x-api-type {"admin":"true"} +func (h *Handler) GetSystemInfo(c *gin.Context) { + c.Header("Deprecation", "true") + c.Header("Link", `; rel="successor-version"`) + resp, err := h.service.GetSystemInfo(c.Request.Context()) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// ListNamespaceLocks handles listing of namespace locks +// +// @Summary List namespace locks +// @Description Retrieve the list of currently locked namespaces +// @Tags System +// @Produce json +// @Security BearerAuth +// @Success 200 {object} dto.GenericResponse[ListNamespaceLockResp] "Successfully retrieved the list of locks" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal Server Error" +// @Router /system/monitor/namespaces/locks [get] +// @x-api-type {"admin":"true"} +func (h *Handler) ListNamespaceLocks(c *gin.Context) { + resp, err := h.service.ListNamespaceLocks(c.Request.Context()) + if httpx.HandleServiceError(c, err) { + return + } + dto.JSONResponse(c, http.StatusOK, "Successfully retrieved the list of locks", resp) +} + +// ListQueuedTasks handles listing of queued tasks +// +// @Summary List queued tasks +// @Description List tasks in queue (ready and delayed) +// @Tags System +// @Produce json +// @Security BearerAuth +// @Success 200 {object} dto.GenericResponse[QueuedTasksResp] "Queued tasks retrieved successfully" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "No queued tasks found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /system/monitor/tasks/queue [post] +// @x-api-type {"admin":"true"} +func (h *Handler) ListQueuedTasks(c *gin.Context) { + resp, err := h.service.ListQueuedTasks(c.Request.Context()) + if httpx.HandleServiceError(c, err) { + return + } + dto.JSONResponse(c, http.StatusOK, "Queued tasks retrieved successfully", resp) +} + +// GetAuditLog handles single audit log retrieval +// +// @Summary Get audit log by ID +// @Description Get a specific audit log entry by ID +// @Tags System +// @Produce json +// @Security BearerAuth +// @Param id path int true "Audit log ID" +// @Success 200 {object} dto.GenericResponse[AuditLogDetailResp] "Audit log retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Audit log not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /system/audit/{id} [get] +// @x-api-type {"admin":"true"} +func (h *Handler) GetAuditLog(c *gin.Context) { + id, ok := parseID(c, "id", "Invalid audit log ID") + if !ok { + return + } + + resp, err := h.service.GetAuditLog(c.Request.Context(), id) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// ListAuditLogs handles audit log listing +// +// @Summary List audit logs +// @Description Get paginated list of audit logs with optional filtering +// @Tags System +// @Produce json +// @Security BearerAuth +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Param action query string false "Filter by action" +// @Param user_id query int false "Filter by user ID" +// @Param resource_id query int false "Filter by resource ID" +// @Param state query int false "Filter by state" +// @Param status query int false "Filter by status" +// @Param start_date query string false "Filter from date (YYYY-MM-DD)" +// @Param end_date query string false "Filter to date (YYYY-MM-DD)" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[AuditLogResp]] "Audit logs retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format/parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /system/audit [get] +// @x-api-type {"admin":"true"} +func (h *Handler) ListAuditLogs(c *gin.Context) { + var req ListAuditLogReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid query format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid query parameters: "+err.Error()) + return + } + + resp, err := h.service.ListAuditLogs(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + dto.JSONResponse(c, http.StatusOK, "Audit logs retrieved successfully", resp) +} + +// GetConfig retrieves a configuration by ID +// +// @Summary Get configuration +// @Description Get detailed information about a specific configuration +// @Tags Configurations +// @ID get_config_by_id +// @Produce json +// @Security BearerAuth +// @Param config_id path int true "Configuration ID" +// @Success 200 {object} dto.GenericResponse[ConfigResp] "Configuration retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid config ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Config not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /system/configs/{config_id} [get] +// @x-api-type {"admin":"true"} +func (h *Handler) GetConfig(c *gin.Context) { + configID, ok := parseID(c, consts.URLPathConfigID, "Invalid config ID") + if !ok { + return + } + + resp, err := h.service.GetConfig(c.Request.Context(), configID) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// ListConfigs lists configurations with pagination and filtering +// +// @Summary List configurations +// @Description List configurations with pagination and optional filters +// @Tags Configurations +// @ID list_configs +// @Produce json +// @Security BearerAuth +// @Param page query int false "Page number" default(1) +// @Param page_size query int false "Page size" default(20) +// @Param category query string false "Filter by configuration category" +// @Param value_type query consts.ConfigValueType false "Filter by configuration value type" +// @Param is_secret query bool false "Filter by secret status" +// @Param updated_by query int false "Filter by ID of the user who last updated the config" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[ConfigResp]] "Configurations retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /system/configs [get] +// @x-api-type {"admin":"true"} +func (h *Handler) ListConfigs(c *gin.Context) { + var req ListConfigReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.ListConfigs(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { + return + } + dto.SuccessResponse(c, resp) +} + +// RollbackConfigValue rolls back a configuration value to previous value from history +// +// @Summary Rollback configuration value +// @Description Rollback a configuration value to a previous value from history +// @Tags Configurations +// @ID rollback_config_value +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param config_id path int true "Configuration ID" +// @Param rollback body RollbackConfigReq true "Rollback request with history_id and reason" +// @Success 202 {object} dto.GenericResponse[any] "Configuration value rolled back successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid config ID/request format/history is not a value change" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 404 {object} dto.GenericResponse[any] "Configuration or history not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /system/configs/{config_id}/value/rollback [post] +// @x-api-type {"admin":"true"} +func (h *Handler) RollbackConfigValue(c *gin.Context) { + userID, exists := middleware.GetCurrentUserID(c) + if !exists || userID <= 0 { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + + configID, ok := parseID(c, consts.URLPathConfigID, "Invalid config ID") + if !ok { + return + } + + var req RollbackConfigReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + err := h.service.RollbackConfigValue(c.Request.Context(), &req, configID, userID, c.ClientIP(), c.Request.UserAgent()) + if httpx.HandleServiceError(c, err) { + return + } + dto.JSONResponse[any](c, http.StatusAccepted, "Configuration value rolled back successfully", nil) +} + +// RollbackConfigMetadata rolls back a configuration metadata field to previous value from history +// +// @Summary Rollback configuration metadata +// @Description Rollback a configuration metadata field (e.g., min_value, max_value, pattern) to a previous value from history +// @Tags Configurations +// @ID rollback_config_metadata +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param config_id path int true "Configuration ID" +// @Param rollback body RollbackConfigReq true "Rollback request with history_id and reason" +// @Success 200 {object} dto.GenericResponse[ConfigResp] "Configuration metadata rolled back successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid config ID/request format/history is a value change" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied - admin only" +// @Failure 404 {object} dto.GenericResponse[any] "Configuration or history not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /system/configs/{config_id}/metadata/rollback [post] +// @x-api-type {"admin":"true"} +func (h *Handler) RollbackConfigMetadata(c *gin.Context) { + userID, exists := middleware.GetCurrentUserID(c) + if !exists || userID <= 0 { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + + configID, ok := parseID(c, consts.URLPathConfigID, "Invalid config ID") + if !ok { + return + } + + var req RollbackConfigReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + resp, err := h.service.RollbackConfigMetadata(c.Request.Context(), &req, configID, userID, c.ClientIP(), c.Request.UserAgent()) + if httpx.HandleServiceError(c, err) { + return + } + dto.JSONResponse(c, http.StatusOK, "Configuration metadata rolled back successfully", resp) +} + +// UpdateConfigValue updates a configuration value (runtime operational change) +// +// @Summary Update configuration value +// @Description Update a configuration value with validation and history tracking. This is for frequent operational adjustments. +// @Tags Configurations +// @ID update_config_value +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param config_id path int true "Configuration ID" +// @Param request body UpdateConfigValueReq true "Configuration value update request" +// @Success 202 {object} dto.GenericResponse[any] "Configuration value updated successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid config ID/request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 404 {object} dto.GenericResponse[any] "Configuration not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /system/configs/{config_id} [patch] +// @x-api-type {"admin":"true"} +func (h *Handler) UpdateConfigValue(c *gin.Context) { + userID, exists := middleware.GetCurrentUserID(c) + if !exists || userID <= 0 { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + + configID, ok := parseID(c, consts.URLPathConfigID, "Invalid config ID") + if !ok { + return + } + + var req UpdateConfigValueReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + err := h.service.UpdateConfigValue(c.Request.Context(), &req, configID, userID, c.ClientIP(), c.Request.UserAgent()) + if httpx.HandleServiceError(c, err) { + return + } + dto.JSONResponse[any](c, http.StatusAccepted, "Configuration value updated successfully", nil) +} + +// UpdateConfigMetadata updates configuration metadata (rare admin operation) +// +// @Summary Update configuration metadata +// @Description Update configuration metadata such as min/max values, validation rules, etc. This is a high-privilege operation. +// @Tags Configurations +// @ID update_config_metadata +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param config_id path int true "Configuration ID" +// @Param request body UpdateConfigMetadataReq true "Configuration metadata update request" +// @Success 200 {object} dto.GenericResponse[ConfigResp] "Configuration metadata updated successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid config ID/request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied - admin only" +// @Failure 404 {object} dto.GenericResponse[any] "Configuration not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /system/configs/{config_id}/metadata [put] +// @x-api-type {"admin":"true"} +func (h *Handler) UpdateConfigMetadata(c *gin.Context) { + userID, exists := middleware.GetCurrentUserID(c) + if !exists || userID <= 0 { + dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") + return + } + + configID, ok := parseID(c, consts.URLPathConfigID, "Invalid config ID") + if !ok { + return + } + + var req UpdateConfigMetadataReq + if err := c.ShouldBindJSON(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.UpdateConfigMetadata(c.Request.Context(), &req, configID, userID, c.ClientIP(), c.Request.UserAgent()) + if httpx.HandleServiceError(c, err) { + return + } + dto.JSONResponse(c, http.StatusOK, "Configuration metadata updated successfully", resp) +} + +// ListConfigHistories handles listing config histories with pagination and filtering +// +// @Summary List configuration histories +// @Description Get paginated list of config histories for a specific config +// @Tags Configurations +// @ID list_config_histories +// @Produce json +// @Security BearerAuth +// @Param config_id path int true "Configuration ID" +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Success 200 {object} dto.GenericResponse[dto.ListResp[ConfigHistoryResp]] "Config histories retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /system/configs/{config_id}/histories [get] +// @x-api-type {"admin":"true"} +func (h *Handler) ListConfigHistories(c *gin.Context) { + configID, ok := parseID(c, consts.URLPathConfigID, "Invalid config ID") + if !ok { + return + } + + var req ListConfigHistoryReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) + return + } + + resp, err := h.service.ListConfigHistories(c.Request.Context(), &req, configID) + if httpx.HandleServiceError(c, err) { + return + } + dto.JSONResponse(c, http.StatusOK, "Config historys retrieved successfully", resp) +} + +func parseID(c *gin.Context, param, message string) (int, bool) { + value := c.Param(param) + id, err := strconv.Atoi(value) + if err != nil || id <= 0 { + dto.ErrorResponse(c, http.StatusBadRequest, message) + return 0, false + } + return id, true +} diff --git a/src/module/system/handler_service.go b/src/module/system/handler_service.go new file mode 100644 index 00000000..21b3816c --- /dev/null +++ b/src/module/system/handler_service.go @@ -0,0 +1,29 @@ +package system + +import ( + "context" + + "aegis/dto" +) + +// HandlerService captures the system operations consumed by the HTTP handler. +type HandlerService interface { + GetHealth(context.Context) (*HealthCheckResp, error) + GetMetrics(context.Context) (*MonitoringMetricsResp, error) + GetSystemInfo(context.Context) (*SystemInfo, error) + ListNamespaceLocks(context.Context) (*ListNamespaceLockResp, error) + ListQueuedTasks(context.Context) (*QueuedTasksResp, error) + GetAuditLog(context.Context, int) (*AuditLogDetailResp, error) + ListAuditLogs(context.Context, *ListAuditLogReq) (*dto.ListResp[AuditLogResp], error) + GetConfig(context.Context, int) (*ConfigDetailResp, error) + ListConfigs(context.Context, *ListConfigReq) (*dto.ListResp[ConfigResp], error) + RollbackConfigValue(context.Context, *RollbackConfigReq, int, int, string, string) error + RollbackConfigMetadata(context.Context, *RollbackConfigReq, int, int, string, string) (*ConfigResp, error) + UpdateConfigValue(context.Context, *UpdateConfigValueReq, int, int, string, string) error + UpdateConfigMetadata(context.Context, *UpdateConfigMetadataReq, int, int, string, string) (*ConfigResp, error) + ListConfigHistories(context.Context, *ListConfigHistoryReq, int) (*dto.ListResp[ConfigHistoryResp], error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/system/handler_test.go b/src/module/system/handler_test.go new file mode 100644 index 00000000..5e0e1969 --- /dev/null +++ b/src/module/system/handler_test.go @@ -0,0 +1,115 @@ +package system + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "aegis/utils" + + "github.com/gin-gonic/gin" +) + +func init() { + utils.InitValidator() +} + +func TestGetConfigRejectsInvalidID(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewHandler(&Service{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodGet, "/system/configs/abc", nil) + c.Request = req + c.Params = gin.Params{{Key: "config_id", Value: "abc"}} + + h.GetConfig(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +func TestGetAuditLogRejectsInvalidID(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewHandler(&Service{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodGet, "/system/audit/abc", nil) + c.Request = req + c.Params = gin.Params{{Key: "id", Value: "abc"}} + + h.GetAuditLog(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +func TestListConfigsRejectsInvalidQuery(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewHandler(&Service{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/system/configs?size=999", nil) + + h.ListConfigs(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +func TestListAuditLogsRejectsInvalidQuery(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewHandler(&Service{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/system/audit?start_date=not-a-date", nil) + + h.ListAuditLogs(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +func TestRollbackConfigValueRequiresAuthentication(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewHandler(&Service{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/system/configs/1/rollback/value", bytes.NewBufferString(`{"history_id":1,"reason":"rollback"}`)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = gin.Params{{Key: "config_id", Value: "1"}} + + h.RollbackConfigValue(c) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", w.Code) + } +} + +func TestUpdateConfigMetadataRejectsInvalidPayload(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewHandler(&Service{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPatch, "/system/configs/1/metadata", bytes.NewBufferString(`{"reason":"update"}`)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("user_id", 1) + c.Params = gin.Params{{Key: "config_id", Value: "1"}} + + h.UpdateConfigMetadata(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} diff --git a/src/module/system/module.go b/src/module/system/module.go new file mode 100644 index 00000000..d1bc05c9 --- /dev/null +++ b/src/module/system/module.go @@ -0,0 +1,13 @@ +package system + +import "go.uber.org/fx" + +var Module = fx.Module("system", + fx.Provide( + NewRepository, + newRuntimeQuerySource, + NewService, + AsHandlerService, + NewHandler, + ), +) diff --git a/src/module/system/repository.go b/src/module/system/repository.go new file mode 100644 index 00000000..72def364 --- /dev/null +++ b/src/module/system/repository.go @@ -0,0 +1,163 @@ +package system + +import ( + "aegis/consts" + "aegis/model" + "fmt" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) getAuditLogByID(id int) (*model.AuditLog, error) { + var auditLog model.AuditLog + if err := r.db.Where("id = ?", id).First(&auditLog).Error; err != nil { + return nil, fmt.Errorf("failed to get audit log: %w", err) + } + return &auditLog, nil +} + +func (r *Repository) listAuditLogs(limit, offset int, filters *ListAuditLogFilters) ([]model.AuditLog, int64, error) { + var ( + logs []model.AuditLog + total int64 + ) + + query := r.db.Model(&model.AuditLog{}).Preload("User").Preload("Resource") + if filters != nil { + if filters.Action != "" { + query = query.Where("action = ?", filters.Action) + } + if filters.IPAddress != "" { + query = query.Where("ip_address = ?", filters.IPAddress) + } + if filters.UserID != 0 { + query = query.Where("user_id = ?", filters.UserID) + } + if filters.ResourceID != 0 { + query = query.Where("resource_id = ?", filters.ResourceID) + } + if filters.State != nil { + query = query.Where("state = ?", *filters.State) + } + if filters.Status != nil { + query = query.Where("status = ?", *filters.Status) + } + if filters.StartTime != nil { + query = query.Where("created_at >= ?", *filters.StartTime) + } + if filters.EndTime != nil { + query = query.Where("created_at <= ?", *filters.EndTime) + } + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count audit logs: %w", err) + } + if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&logs).Error; err != nil { + return nil, 0, fmt.Errorf("failed to get audit logs: %w", err) + } + return logs, total, nil +} + +func (r *Repository) getConfigByID(configID int, includeUser bool) (*model.DynamicConfig, error) { + query := r.db + if includeUser { + query = query.Preload("UpdatedByUser") + } + + var cfg model.DynamicConfig + if err := query.Where("id = ?", configID).First(&cfg).Error; err != nil { + return nil, fmt.Errorf("failed to find config with id %d: %w", configID, err) + } + return &cfg, nil +} + +func (r *Repository) listConfigs(limit, offset int, valueType *consts.ConfigValueType, category *string, isSecret *bool, updatedBy *int) ([]model.DynamicConfig, int64, error) { + var ( + configs []model.DynamicConfig + total int64 + ) + + query := r.db.Model(&model.DynamicConfig{}) + if valueType != nil { + query = query.Where("value_type = ?", *valueType) + } + if category != nil { + query = query.Where("category = ?", *category) + } + if isSecret != nil { + query = query.Where("is_secret = ?", *isSecret) + } + if updatedBy != nil { + query = query.Where("updated_by = ?", *updatedBy) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count configs: %w", err) + } + if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&configs).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list configs: %w", err) + } + return configs, total, nil +} + +func (r *Repository) updateConfig(config *model.DynamicConfig) error { + if err := r.db.Save(config).Error; err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + return nil +} + +func (r *Repository) getConfigHistory(historyID int) (*model.ConfigHistory, error) { + var history model.ConfigHistory + if err := r.db.Preload("Operator").Preload("Config").First(&history, historyID).Error; err != nil { + return nil, fmt.Errorf("failed to find config history with id %d: %w", historyID, err) + } + return &history, nil +} + +func (r *Repository) createConfigHistory(history *model.ConfigHistory) error { + if err := r.db.Create(history).Error; err != nil { + return fmt.Errorf("failed to create config history: %w", err) + } + return nil +} + +func (r *Repository) listConfigHistories(limit, offset int, configID int, changeType *consts.ConfigHistoryChangeType, operatorID *int) ([]model.ConfigHistory, int64, error) { + var ( + histories []model.ConfigHistory + total int64 + ) + + query := r.db.Model(&model.ConfigHistory{}).Where("config_id = ?", configID) + if changeType != nil { + query = query.Where("change_type = ?", *changeType) + } + if operatorID != nil { + query = query.Where("operator_id = ?", *operatorID) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count config histories: %w", err) + } + if err := query.Preload("Operator").Limit(limit).Offset(offset).Order("created_at DESC").Find(&histories).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list config histories: %w", err) + } + return histories, total, nil +} + +func (r *Repository) listConfigHistoriesByConfigID(configID int) ([]model.ConfigHistory, error) { + var histories []model.ConfigHistory + if err := r.db.Preload("Operator").Where("config_id = ?", configID).Order("created_at DESC").Find(&histories).Error; err != nil { + return nil, fmt.Errorf("failed to list config histories for config %d: %w", configID, err) + } + return histories, nil +} diff --git a/src/module/system/runtime_query.go b/src/module/system/runtime_query.go new file mode 100644 index 00000000..13fe579a --- /dev/null +++ b/src/module/system/runtime_query.go @@ -0,0 +1,71 @@ +package system + +import ( + "context" + "fmt" + + "aegis/internalclient/runtimeclient" + systemmetric "aegis/module/systemmetric" + task "aegis/module/task" + + "go.uber.org/fx" +) + +type runtimeQuerySource interface { + ListNamespaceLocks(context.Context) (*ListNamespaceLockResp, error) + ListQueuedTasks(context.Context) (*task.QueuedTasksResp, error) +} + +type runtimeQueryAdapter struct { + runtime *runtimeclient.Client + local *systemmetric.Service + requireRemote bool +} + +type runtimeQuerySourceParams struct { + fx.In + + Runtime *runtimeclient.Client `optional:"true"` + Local *systemmetric.Service +} + +func newRuntimeQuerySource(params runtimeQuerySourceParams) runtimeQuerySource { + return runtimeQueryAdapter{ + runtime: params.Runtime, + local: params.Local, + requireRemote: false, + } +} + +func newRemoteRuntimeQuerySource(params runtimeQuerySourceParams) runtimeQuerySource { + return runtimeQueryAdapter{ + runtime: params.Runtime, + local: params.Local, + requireRemote: true, + } +} + +func (a runtimeQueryAdapter) ListNamespaceLocks(ctx context.Context) (*ListNamespaceLockResp, error) { + if a.runtime != nil && a.runtime.Enabled() { + return a.runtime.GetNamespaceLocks(ctx) + } + if a.requireRemote { + return nil, fmt.Errorf("runtime-worker-service query source is not configured") + } + return a.local.ListNamespaceLocks(ctx) +} + +func (a runtimeQueryAdapter) ListQueuedTasks(ctx context.Context) (*task.QueuedTasksResp, error) { + if a.runtime != nil && a.runtime.Enabled() { + return a.runtime.GetQueuedTasks(ctx) + } + if a.requireRemote { + return nil, fmt.Errorf("runtime-worker-service query source is not configured") + } + return a.local.ListQueuedTasks(ctx) +} + +// RemoteRuntimeQueryOption forces the dedicated system-service path to use runtime RPC only. +func RemoteRuntimeQueryOption() fx.Option { + return fx.Decorate(newRemoteRuntimeQuerySource) +} diff --git a/src/module/system/service.go b/src/module/system/service.go new file mode 100644 index 00000000..d82b1214 --- /dev/null +++ b/src/module/system/service.go @@ -0,0 +1,745 @@ +package system + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "runtime" + "time" + + "aegis/config" + "aegis/consts" + "aegis/dto" + buildkit "aegis/infra/buildkit" + etcd "aegis/infra/etcd" + k8s "aegis/infra/k8s" + redis "aegis/infra/redis" + "aegis/model" + "aegis/service/common" + "aegis/utils" + + "github.com/sirupsen/logrus" + "go.uber.org/fx" + "gorm.io/gorm" +) + +type configUpdateContext struct { + ChangeField consts.ConfigHistoryChangeField + OldValue string + NewValue string + Reason string + OperatorID int + IpAddress string + UserAgent string +} + +type configHistoryParams struct { + ConfigID int + ChangeType consts.ConfigHistoryChangeType + RollbackFromID *int + + ConfigUpdateContext configUpdateContext +} + +type configHistoryWriter interface { + createConfigHistory(history *model.ConfigHistory) error +} + +type Service struct { + repo *Repository + buildkit *buildkit.Gateway + etcd *etcd.Gateway + k8s *k8s.Gateway + redis *redis.Gateway + runtimeQuery runtimeQuerySource +} + +type serviceParams struct { + fx.In + + Repo *Repository + Buildkit *buildkit.Gateway + Etcd *etcd.Gateway + K8s *k8s.Gateway + Redis *redis.Gateway + RuntimeQuery runtimeQuerySource +} + +func NewService(params serviceParams) *Service { + return &Service{ + repo: params.Repo, + buildkit: params.Buildkit, + etcd: params.Etcd, + k8s: params.K8s, + redis: params.Redis, + runtimeQuery: params.RuntimeQuery, + } +} + +func (s *Service) GetHealth(ctx context.Context) (*HealthCheckResp, error) { + start := time.Now() + services := make(map[string]ServiceInfo) + overallStatus := "healthy" + + buildkitInfo := s.checkBuildKitHealth(ctx) + services["buildkit"] = buildkitInfo + if buildkitInfo.Status != "healthy" { + overallStatus = "unhealthy" + } + + dbInfo := s.checkDatabaseHealth(ctx) + services["database"] = dbInfo + if dbInfo.Status != "healthy" { + overallStatus = "unhealthy" + } + + jaegerInfo := s.checkJaegerHealth(ctx) + services["jaeger"] = jaegerInfo + if jaegerInfo.Status != "healthy" { + overallStatus = "unhealthy" + } + + k8sInfo := s.checkKubernetesHealth(ctx) + services["kubernetes"] = k8sInfo + if k8sInfo.Status != "healthy" { + overallStatus = "unhealthy" + } + + redisInfo := s.checkRedisHealth(ctx) + services["redis"] = redisInfo + if redisInfo.Status != "healthy" { + overallStatus = "unhealthy" + } + + return &HealthCheckResp{ + Status: overallStatus, + Timestamp: time.Now(), + Version: config.GetString("version"), + Uptime: time.Since(start).String(), + Services: services, + }, nil +} + +func (s *Service) GetMetrics(_ context.Context) (*MonitoringMetricsResp, error) { + return &MonitoringMetricsResp{ + Timestamp: time.Now(), + Metrics: map[string]MetricValue{ + "cpu_usage": {Value: 25.5, Timestamp: time.Now(), Unit: "percent"}, + "memory_usage": {Value: 60.2, Timestamp: time.Now(), Unit: "percent"}, + "disk_usage": {Value: 45.8, Timestamp: time.Now(), Unit: "percent"}, + "active_connections": {Value: 142, Timestamp: time.Now(), Unit: "count"}, + }, + Labels: map[string]string{ + "instance": "rcabench-01", + "version": config.GetString("version"), + }, + }, nil +} + +func (s *Service) GetSystemInfo(_ context.Context) (*SystemInfo, error) { + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + return &SystemInfo{ + CPUUsage: 25.5, + MemoryUsage: float64(memStats.Alloc) / float64(memStats.Sys) * 100, + DiskUsage: 45.8, + LoadAverage: "1.2, 1.5, 1.8", + }, nil +} + +func (s *Service) ListNamespaceLocks(ctx context.Context) (*ListNamespaceLockResp, error) { + return s.runtimeQuery.ListNamespaceLocks(ctx) +} + +func (s *Service) ListQueuedTasks(ctx context.Context) (*QueuedTasksResp, error) { + return s.runtimeQuery.ListQueuedTasks(ctx) +} + +func (s *Service) GetAuditLog(_ context.Context, id int) (*AuditLogDetailResp, error) { + log, err := s.repo.getAuditLogByID(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: audit log with ID %d not found", consts.ErrNotFound, id) + } + return nil, fmt.Errorf("failed to get audit log: %w", err) + } + + return NewAuditLogDetailResp(log), nil +} + +func (s *Service) ListAuditLogs(_ context.Context, req *ListAuditLogReq) (*dto.ListResp[AuditLogResp], error) { + limit, offset := req.ToGormParams() + filterOptions := req.ToFilterOptions() + + logs, total, err := s.repo.listAuditLogs(limit, offset, filterOptions) + if err != nil { + return nil, fmt.Errorf("failed to list audit logs: %w", err) + } + + return buildAuditLogListResp(logs, req, total), nil +} + +func (s *Service) GetConfig(_ context.Context, configID int) (*ConfigDetailResp, error) { + cfg, err := s.repo.getConfigByID(configID, true) + if err != nil { + return nil, fmt.Errorf("failed to get config detail: %w", err) + } + + histories, err := s.repo.listConfigHistoriesByConfigID(cfg.ID) + if err != nil { + return nil, fmt.Errorf("failed to get config histories: %w", err) + } + + return buildConfigDetailResp(cfg, histories), nil +} + +func (s *Service) ListConfigs(_ context.Context, req *ListConfigReq) (*dto.ListResp[ConfigResp], error) { + limit, offset := req.ToGormParams() + + configs, total, err := s.repo.listConfigs(limit, offset, req.ValueType, req.Category, req.IsSecret, req.UpdatedBy) + if err != nil { + return nil, fmt.Errorf("failed to list configs: %w", err) + } + + return buildConfigListResp(configs, req, total), nil +} + +func (s *Service) RollbackConfigValue(ctx context.Context, req *RollbackConfigReq, configID, userID int, ipAddress, userAgent string) error { + history, err := s.repo.getConfigHistory(req.HistoryID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: history entry with id %d not found", consts.ErrNotFound, req.HistoryID) + } + return fmt.Errorf("failed to get config history: %w", err) + } + + if history.ChangeField != consts.ChangeFieldValue { + return fmt.Errorf("history entry %d is not a value change (field: %v)", req.HistoryID, history.ChangeField) + } + + existingConfig, err := s.repo.getConfigByID(configID, false) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: configuration with id %d not found", consts.ErrNotFound, configID) + } + return fmt.Errorf("failed to get config: %w", err) + } + + oldValue, err := s.etcd.Get(ctx, fmt.Sprintf("%s%s", etcdPrefixForScope(existingConfig.Scope), existingConfig.Key)) + if err != nil { + return fmt.Errorf("failed to get current config value from etcd: %w", err) + } + + newValue := history.OldValue + if err := common.ValidateConfig(existingConfig, newValue); err != nil { + return fmt.Errorf("invalid config after rollback: %w", err) + } + + if err := setViperIfNeeded(existingConfig, newValue); err != nil { + return fmt.Errorf("failed to set config value in viper: %w", err) + } + + if _, err := s.createConfigRollback(existingConfig, utils.IntPtr(history.ID), configUpdateContext{ + ChangeField: consts.ChangeFieldValue, + OldValue: oldValue, + NewValue: newValue, + Reason: req.Reason, + OperatorID: userID, + IpAddress: ipAddress, + UserAgent: userAgent, + }); err != nil { + return err + } + + return s.propagateValueChange(ctx, existingConfig, newValue, "rollback") +} + +func (s *Service) RollbackConfigMetadata(_ context.Context, req *RollbackConfigReq, configID, userID int, ipAddress, userAgent string) (*ConfigResp, error) { + history, err := s.repo.getConfigHistory(req.HistoryID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: history entry with id %d not found", consts.ErrNotFound, req.HistoryID) + } + return nil, fmt.Errorf("failed to get config history: %w", err) + } + + if history.ChangeField == consts.ChangeFieldValue { + return nil, fmt.Errorf("history entry %d is a value change, use RollbackConfigValue instead", req.HistoryID) + } + + existingConfig, err := s.repo.getConfigByID(configID, false) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: configuration with id %d not found", consts.ErrNotFound, configID) + } + return nil, fmt.Errorf("failed to get config: %w", err) + } + + oldValue, newValue, err := rollbackMetaFieldValue(existingConfig, history.ChangeField, history.OldValue) + if err != nil { + return nil, fmt.Errorf("failed to rollback metadata field: %w", err) + } + + if err := common.ValidateConfigMetadataConstraints(existingConfig); err != nil { + return nil, fmt.Errorf("invalid config after metadata rollback: %w", err) + } + + updatedConfig, err := s.createConfigRollback(existingConfig, utils.IntPtr(history.ID), configUpdateContext{ + ChangeField: history.ChangeField, + OldValue: oldValue, + NewValue: newValue, + Reason: req.Reason, + OperatorID: userID, + IpAddress: ipAddress, + UserAgent: userAgent, + }) + if err != nil { + return nil, err + } + + return NewConfigResp(updatedConfig), nil +} + +func (s *Service) UpdateConfigValue(ctx context.Context, req *UpdateConfigValueReq, configID, userID int, ipAddress, userAgent string) error { + existingConfig, err := s.repo.getConfigByID(configID, false) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: configuration with id %d not found", consts.ErrNotFound, configID) + } + return fmt.Errorf("failed to get config: %w", err) + } + + oldValue, err := s.etcd.Get(ctx, fmt.Sprintf("%s%s", etcdPrefixForScope(existingConfig.Scope), existingConfig.Key)) + if err != nil { + return fmt.Errorf("failed to get current config value from etcd: %w", err) + } + + newValue := req.Value + if err := common.ValidateConfig(existingConfig, newValue); err != nil { + return fmt.Errorf("invalid config value: %w", err) + } + + if err := setViperIfNeeded(existingConfig, newValue); err != nil { + return fmt.Errorf("failed to set config value in viper: %w", err) + } + + if err := s.createConfigHistory(s.repo, configHistoryParams{ + ConfigID: existingConfig.ID, + ChangeType: consts.ChangeTypeUpdate, + ConfigUpdateContext: configUpdateContext{ + ChangeField: consts.ChangeFieldValue, + OldValue: oldValue, + NewValue: newValue, + Reason: req.Reason, + OperatorID: userID, + IpAddress: ipAddress, + UserAgent: userAgent, + }, + }); err != nil { + return fmt.Errorf("failed to create config history: %w", err) + } + + return s.propagateValueChange(ctx, existingConfig, newValue, "update") +} + +func (s *Service) UpdateConfigMetadata(_ context.Context, req *UpdateConfigMetadataReq, configID, userID int, ipAddress, userAgent string) (*ConfigResp, error) { + existingConfig, err := s.repo.getConfigByID(configID, false) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: configuration with id %d not found", consts.ErrNotFound, configID) + } + return nil, fmt.Errorf("failed to get config: %w", err) + } + + oldValue, newValue := req.PatchConfigModel(existingConfig) + if err := common.ValidateConfigMetadataConstraints(existingConfig); err != nil { + return nil, fmt.Errorf("invalid config after metadata update: %w", err) + } + + var updatedConfig *model.DynamicConfig + err = s.repo.db.Transaction(func(tx *gorm.DB) error { + txRepo := NewRepository(tx) + existingConfig.UpdatedBy = utils.IntPtr(userID) + + if err := txRepo.updateConfig(existingConfig); err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + + updatedConfig = existingConfig + if err := s.createConfigHistory(txRepo, configHistoryParams{ + ConfigID: updatedConfig.ID, + ChangeType: consts.ChangeTypeUpdate, + ConfigUpdateContext: configUpdateContext{ + ChangeField: req.GetChangeField(), + OldValue: oldValue, + NewValue: newValue, + Reason: req.Reason, + OperatorID: userID, + IpAddress: ipAddress, + UserAgent: userAgent, + }, + }); err != nil { + return fmt.Errorf("failed to create config history: %w", err) + } + + return nil + }) + if err != nil { + return nil, err + } + + return NewConfigResp(updatedConfig), nil +} + +func (s *Service) ListConfigHistories(_ context.Context, req *ListConfigHistoryReq, configID int) (*dto.ListResp[ConfigHistoryResp], error) { + limit, offset := req.ToGormParams() + + histories, total, err := s.repo.listConfigHistories(limit, offset, configID, req.ChangeType, req.OperatorID) + if err != nil { + return nil, fmt.Errorf("failed to list config histories: %w", err) + } + + return buildConfigHistoryListResp(histories, req, total), nil +} + +func etcdPrefixForScope(scope consts.ConfigScope) string { + switch scope { + case consts.ConfigScopeProducer: + return consts.ConfigEtcdProducerPrefix + case consts.ConfigScopeConsumer: + return consts.ConfigEtcdConsumerPrefix + case consts.ConfigScopeGlobal: + return consts.ConfigEtcdGlobalPrefix + } + return "" +} + +func buildAuditLogListResp(logs []model.AuditLog, req *ListAuditLogReq, total int64) *dto.ListResp[AuditLogResp] { + logResps := make([]AuditLogResp, 0, len(logs)) + for i := range logs { + logResps = append(logResps, *NewAuditLogResp(&logs[i])) + } + + return &dto.ListResp[AuditLogResp]{ + Items: logResps, + Pagination: req.ConvertToPaginationInfo(total), + } +} + +func buildConfigDetailResp(cfg *model.DynamicConfig, histories []model.ConfigHistory) *ConfigDetailResp { + resp := NewConfigDetailResp(cfg) + for _, history := range histories { + resp.Histories = append(resp.Histories, *NewConfigHistoryResp(&history)) + } + return resp +} + +func buildConfigListResp(configs []model.DynamicConfig, req *ListConfigReq, total int64) *dto.ListResp[ConfigResp] { + configResps := make([]ConfigResp, 0, len(configs)) + for _, cfg := range configs { + configResps = append(configResps, *NewConfigResp(&cfg)) + } + + return &dto.ListResp[ConfigResp]{ + Items: configResps, + Pagination: req.ConvertToPaginationInfo(total), + } +} + +func buildConfigHistoryListResp(histories []model.ConfigHistory, req *ListConfigHistoryReq, total int64) *dto.ListResp[ConfigHistoryResp] { + historyResps := make([]ConfigHistoryResp, 0, len(histories)) + for _, history := range histories { + historyResps = append(historyResps, *NewConfigHistoryResp(&history)) + } + + return &dto.ListResp[ConfigHistoryResp]{ + Items: historyResps, + Pagination: req.ConvertToPaginationInfo(total), + } +} + +func (s *Service) createConfigHistory(repo configHistoryWriter, params configHistoryParams) error { + entry := &model.ConfigHistory{ + ChangeType: params.ChangeType, + OldValue: params.ConfigUpdateContext.OldValue, + NewValue: params.ConfigUpdateContext.NewValue, + Reason: params.ConfigUpdateContext.Reason, + ConfigID: params.ConfigID, + OperatorID: utils.IntPtr(params.ConfigUpdateContext.OperatorID), + IPAddress: params.ConfigUpdateContext.IpAddress, + UserAgent: params.ConfigUpdateContext.UserAgent, + RolledBackFromID: params.RollbackFromID, + ChangeField: params.ConfigUpdateContext.ChangeField, + } + if err := repo.createConfigHistory(entry); err != nil { + return fmt.Errorf("failed to create config history: %w", err) + } + return nil +} + +func (s *Service) createConfigRollback(cfg *model.DynamicConfig, historyID *int, updateContext configUpdateContext) (*model.DynamicConfig, error) { + var updatedConfig *model.DynamicConfig + + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + txRepo := NewRepository(tx) + if err := txRepo.updateConfig(cfg); err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + + updatedConfig = cfg + if err := s.createConfigHistory(txRepo, configHistoryParams{ + ConfigID: cfg.ID, + ChangeType: consts.ChangeTypeRollback, + ConfigUpdateContext: updateContext, + RollbackFromID: historyID, + }); err != nil { + return fmt.Errorf("failed to create rollback history: %w", err) + } + + return nil + }) + if err != nil { + return nil, err + } + + return updatedConfig, nil +} + +func rollbackMetaFieldValue(cfg *model.DynamicConfig, changeField consts.ConfigHistoryChangeField, targetValue string) (string, string, error) { + newValue := targetValue + oldValue := "" + + switch changeField { + case consts.ChangeFieldDefaultValue: + oldValue = cfg.DefaultValue + cfg.DefaultValue = newValue + case consts.ChangeFieldDescription: + oldValue = cfg.Description + cfg.Description = newValue + case consts.ChangeFieldMinValue: + if cfg.MinValue != nil { + oldValue = fmt.Sprintf("%f", *cfg.MinValue) + } + if newValue == "" { + cfg.MinValue = nil + } else { + var minVal float64 + if _, err := fmt.Sscanf(newValue, "%f", &minVal); err != nil { + return "", "", fmt.Errorf("failed to parse min value: %w", err) + } + cfg.MinValue = &minVal + } + case consts.ChangeFieldMaxValue: + if cfg.MaxValue != nil { + oldValue = fmt.Sprintf("%f", *cfg.MaxValue) + } + if newValue == "" { + cfg.MaxValue = nil + } else { + var maxVal float64 + if _, err := fmt.Sscanf(newValue, "%f", &maxVal); err != nil { + return "", "", fmt.Errorf("failed to parse max value: %w", err) + } + cfg.MaxValue = &maxVal + } + case consts.ChangeFieldPattern: + oldValue = cfg.Pattern + cfg.Pattern = newValue + case consts.ChangeFieldOptions: + oldValue = cfg.Options + cfg.Options = newValue + default: + return "", "", fmt.Errorf("unknown change field: %d", changeField) + } + + return oldValue, newValue, nil +} + +func setViperIfNeeded(cfg *model.DynamicConfig, newValue string) error { + if cfg.Scope == consts.ConfigScopeConsumer { + return nil + } + return config.SetViperValue(cfg.Key, newValue, cfg.ValueType) +} + +func (s *Service) propagateValueChange(ctx context.Context, cfg *model.DynamicConfig, newValue, opDesc string) error { + if cfg.Scope != consts.ConfigScopeGlobal && cfg.Scope != consts.ConfigScopeConsumer { + return nil + } + + etcdKey := fmt.Sprintf("%s%s", etcdPrefixForScope(cfg.Scope), cfg.Key) + if err := s.publishConfigToEtcdWithRetry(ctx, etcdKey, newValue, 3); err != nil { + return fmt.Errorf("config saved to database but failed to publish to etcd: %w", err) + } + + if cfg.Scope == consts.ConfigScopeConsumer { + logrus.Infof("Waiting for consumer config %s response...", opDesc) + resp, err := s.waitForConfigUpdateResponse(ctx, 10*time.Second) + if err != nil { + return fmt.Errorf("config %s but consumer did not respond: %w", opDesc, err) + } + if !resp.Success { + return fmt.Errorf("consumer failed to process config %s: %s", opDesc, resp.Error) + } + logrus.Infof("Config %s successfully processed by consumer", opDesc) + } + + return nil +} + +func (s *Service) publishConfigToEtcdWithRetry(ctx context.Context, key, value string, maxRetries int) error { + var lastErr error + baseDelay := 500 * time.Millisecond + + for attempt := range maxRetries { + if attempt > 0 { + delay := baseDelay * time.Duration(1< 0 { + logrus.Infof("Successfully published config to etcd after %d retries", attempt) + } + return nil + } + + lastErr = err + logrus.Warnf("Failed to publish config to etcd (attempt %d/%d): %v", attempt+1, maxRetries, err) + } + + return fmt.Errorf("failed to publish config to etcd after %d attempts: %w", maxRetries, lastErr) +} + +func (s *Service) waitForConfigUpdateResponse(parent context.Context, timeout time.Duration) (*dto.ConfigUpdateResponse, error) { + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + + pubsub, err := s.redis.Subscribe(ctx, consts.ConfigUpdateResponseChannel) + if err != nil { + return nil, fmt.Errorf("failed to confirm subscription: %w", err) + } + defer func() { _ = pubsub.Close() }() + + msgChan := pubsub.Channel() + for { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("timeout waiting for config update response after %v", timeout) + case msg, ok := <-msgChan: + if !ok { + return nil, fmt.Errorf("subscription channel closed unexpectedly") + } + + var response dto.ConfigUpdateResponse + if err := json.Unmarshal([]byte(msg.Payload), &response); err != nil { + logrus.Warnf("failed to parse response message: %v", err) + continue + } + + logrus.WithFields(logrus.Fields{ + "response_id": response.ID, + "success": response.Success, + }).Info("Received matching config update response") + return &response, nil + } + } +} + +func (s *Service) checkBuildKitHealth(parent context.Context) ServiceInfo { + start := time.Now() + ctx, cancel := context.WithTimeout(parent, 5*time.Second) + defer cancel() + + err := s.buildkit.CheckHealth(ctx, 5*time.Second) + responseTime := time.Since(start) + if err != nil { + return ServiceInfo{ + Status: "unhealthy", + LastChecked: time.Now(), + ResponseTime: responseTime.String(), + Error: "BuildKit daemon unreachable", + Details: err.Error(), + } + } + return ServiceInfo{Status: "healthy", LastChecked: time.Now(), ResponseTime: responseTime.String()} +} + +func (s *Service) checkDatabaseHealth(parent context.Context) ServiceInfo { + start := time.Now() + db := s.repo.db + if db == nil { + return ServiceInfo{Status: "unhealthy", LastChecked: time.Now(), ResponseTime: "N/A", Error: "Database connection not available"} + } + + ctx, cancel := context.WithTimeout(parent, 5*time.Second) + defer cancel() + var result int + err := db.WithContext(ctx).Raw("SELECT 1").Scan(&result).Error + responseTime := time.Since(start) + if err != nil { + return ServiceInfo{Status: "unhealthy", LastChecked: time.Now(), ResponseTime: responseTime.String(), Error: "Database query failed", Details: err.Error()} + } + return ServiceInfo{Status: "healthy", LastChecked: time.Now(), ResponseTime: responseTime.String()} +} + +func (s *Service) checkJaegerHealth(parent context.Context) ServiceInfo { + start := time.Now() + jaegerURL := fmt.Sprintf("http://%s/v1/traces", config.GetString("jaeger.endpoint")) + ctx, cancel := context.WithTimeout(parent, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodHead, jaegerURL, nil) + if err != nil { + return ServiceInfo{Status: "unhealthy", LastChecked: time.Now(), ResponseTime: time.Since(start).String(), Error: "Failed to create Jaeger OTLP request", Details: err.Error()} + } + + httpClient := &http.Client{Timeout: 5 * time.Second} + resp, err := httpClient.Do(req) + responseTime := time.Since(start) + if err != nil { + return ServiceInfo{Status: "unhealthy", LastChecked: time.Now(), ResponseTime: responseTime.String(), Error: "Jaeger OTLP endpoint unreachable", Details: err.Error()} + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusMethodNotAllowed && resp.StatusCode != http.StatusOK { + return ServiceInfo{Status: "unhealthy", LastChecked: time.Now(), ResponseTime: responseTime.String(), Error: fmt.Sprintf("Jaeger OTLP returned unexpected status %d", resp.StatusCode)} + } + return ServiceInfo{Status: "healthy", LastChecked: time.Now(), ResponseTime: responseTime.String(), Details: "Jaeger OTLP endpoint responding"} +} + +func (s *Service) checkKubernetesHealth(parent context.Context) ServiceInfo { + start := time.Now() + if s.k8s == nil { + return ServiceInfo{Status: "unavailable", LastChecked: time.Now(), ResponseTime: time.Since(start).String(), Error: "Kubernetes gateway not configured"} + } + ctx, cancel := context.WithTimeout(parent, 5*time.Second) + defer cancel() + if err := s.k8s.CheckHealth(ctx); err != nil { + return ServiceInfo{Status: "unhealthy", LastChecked: time.Now(), ResponseTime: time.Since(start).String(), Error: "Kubernetes health check failed", Details: err.Error()} + } + return ServiceInfo{Status: "healthy", LastChecked: time.Now(), ResponseTime: time.Since(start).String()} +} + +func (s *Service) checkRedisHealth(parent context.Context) ServiceInfo { + start := time.Now() + if s.redis == nil { + return ServiceInfo{Status: "unhealthy", LastChecked: time.Now(), ResponseTime: "N/A", Error: "Redis connection not available"} + } + + ctx, cancel := context.WithTimeout(parent, 5*time.Second) + defer cancel() + err := s.redis.Ping(ctx) + responseTime := time.Since(start) + if err != nil { + return ServiceInfo{Status: "unhealthy", LastChecked: time.Now(), ResponseTime: responseTime.String(), Error: "Redis ping failed", Details: err.Error()} + } + return ServiceInfo{Status: "healthy", LastChecked: time.Now(), ResponseTime: responseTime.String()} +} diff --git a/src/module/system/service_test.go b/src/module/system/service_test.go new file mode 100644 index 00000000..e1871391 --- /dev/null +++ b/src/module/system/service_test.go @@ -0,0 +1,270 @@ +package system + +import ( + "context" + "testing" + "time" + + "aegis/config" + "aegis/consts" + "aegis/dto" + "aegis/model" +) + +type fakeConfigHistoryWriter struct { + history *model.ConfigHistory + err error +} + +func (f *fakeConfigHistoryWriter) createConfigHistory(history *model.ConfigHistory) error { + f.history = history + return f.err +} + +func TestGetMetricsReturnsExpectedLabels(t *testing.T) { + svc := &Service{} + + resp, err := svc.GetMetrics(context.Background()) + if err != nil { + t.Fatalf("GetMetrics() error = %v", err) + } + if resp == nil { + t.Fatal("expected metrics response") + } + if _, ok := resp.Metrics["cpu_usage"]; !ok { + t.Fatal("expected cpu_usage metric") + } + if _, ok := resp.Labels["instance"]; !ok { + t.Fatal("expected instance label") + } +} + +func TestGetSystemInfoReturnsLoadAverage(t *testing.T) { + svc := &Service{} + + resp, err := svc.GetSystemInfo(context.Background()) + if err != nil { + t.Fatalf("GetSystemInfo() error = %v", err) + } + if resp == nil { + t.Fatal("expected system info response") + } + if resp.LoadAverage == "" { + t.Fatal("expected load average") + } +} + +func TestRollbackMetaFieldValueUpdatesDescription(t *testing.T) { + cfg := &model.DynamicConfig{Description: "current"} + + oldValue, newValue, err := rollbackMetaFieldValue(cfg, consts.ChangeFieldDescription, "restored") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if oldValue != "current" { + t.Fatalf("expected old value to be current, got %q", oldValue) + } + if newValue != "restored" { + t.Fatalf("expected new value to be restored, got %q", newValue) + } + if cfg.Description != "restored" { + t.Fatalf("expected config description to be restored, got %q", cfg.Description) + } +} + +func TestRollbackMetaFieldValueClearsMinValue(t *testing.T) { + minValue := 10.5 + cfg := &model.DynamicConfig{MinValue: &minValue} + + oldValue, newValue, err := rollbackMetaFieldValue(cfg, consts.ChangeFieldMinValue, "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if oldValue == "" { + t.Fatal("expected old value to be populated") + } + if newValue != "" { + t.Fatalf("expected new value to be empty, got %q", newValue) + } + if cfg.MinValue != nil { + t.Fatal("expected min value to be cleared") + } +} + +func TestSetViperIfNeededSetsProducerScopeValue(t *testing.T) { + cfg := &model.DynamicConfig{ + Key: "system.test.int", + Scope: consts.ConfigScopeProducer, + ValueType: consts.ConfigValueTypeInt, + } + + if err := setViperIfNeeded(cfg, "42"); err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got := config.GetInt(cfg.Key); got != 42 { + t.Fatalf("expected viper value 42, got %d", got) + } +} + +func TestSetViperIfNeededSkipsConsumerScope(t *testing.T) { + cfg := &model.DynamicConfig{ + Key: "system.test.consumer", + Scope: consts.ConfigScopeConsumer, + ValueType: consts.ConfigValueTypeString, + } + + if err := setViperIfNeeded(cfg, "remote-only"); err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got := config.GetString(cfg.Key); got != "" { + t.Fatalf("expected consumer scope to skip local viper update, got %q", got) + } +} + +func TestCreateConfigHistoryBuildsExpectedEntry(t *testing.T) { + writer := &fakeConfigHistoryWriter{} + svc := &Service{} + rollbackFromID := 9 + + err := svc.createConfigHistory(writer, configHistoryParams{ + ConfigID: 12, + ChangeType: consts.ChangeTypeRollback, + RollbackFromID: &rollbackFromID, + ConfigUpdateContext: configUpdateContext{ + ChangeField: consts.ChangeFieldPattern, + OldValue: "old", + NewValue: "new", + Reason: "test reason", + OperatorID: 3, + IpAddress: "127.0.0.1", + UserAgent: "unit-test", + }, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if writer.history == nil { + t.Fatal("expected history to be written") + } + if writer.history.ConfigID != 12 { + t.Fatalf("expected config id 12, got %d", writer.history.ConfigID) + } + if writer.history.ChangeType != consts.ChangeTypeRollback { + t.Fatalf("expected rollback change type, got %v", writer.history.ChangeType) + } + if writer.history.ChangeField != consts.ChangeFieldPattern { + t.Fatalf("expected pattern change field, got %v", writer.history.ChangeField) + } + if writer.history.OperatorID == nil || *writer.history.OperatorID != 3 { + t.Fatalf("expected operator id 3, got %+v", writer.history.OperatorID) + } + if writer.history.RolledBackFromID == nil || *writer.history.RolledBackFromID != rollbackFromID { + t.Fatalf("expected rollback from id %d, got %+v", rollbackFromID, writer.history.RolledBackFromID) + } +} + +func TestBuildConfigDetailRespIncludesHistories(t *testing.T) { + operatorID := 7 + cfg := &model.DynamicConfig{ + ID: 1, + Key: "feature.flag", + ValueType: consts.ConfigValueTypeString, + Category: "system", + } + histories := []model.ConfigHistory{ + { + ID: 11, + ConfigID: 1, + ChangeType: consts.ChangeTypeUpdate, + ChangeField: consts.ChangeFieldValue, + OldValue: "off", + NewValue: "on", + OperatorID: &operatorID, + }, + } + + resp := buildConfigDetailResp(cfg, histories) + if resp == nil { + t.Fatal("expected response") + } + if resp.ID != cfg.ID { + t.Fatalf("expected config id %d, got %d", cfg.ID, resp.ID) + } + if len(resp.Histories) != 1 { + t.Fatalf("expected 1 history, got %d", len(resp.Histories)) + } + if resp.Histories[0].ID != 11 { + t.Fatalf("expected history id 11, got %d", resp.Histories[0].ID) + } +} + +func TestBuildAuditLogListRespIncludesPaginationAndItems(t *testing.T) { + state := consts.AuditLogStateSuccess + status := consts.CommonEnabled + req := &ListAuditLogReq{ + PaginationReq: dto.PaginationReq{Page: 2, Size: consts.PageSizeSmall}, + State: &state, + Status: &status, + } + logs := []model.AuditLog{ + {ID: 1, Action: "deploy", IPAddress: "127.0.0.1", State: consts.AuditLogStateSuccess, Status: consts.CommonEnabled, CreatedAt: time.Now()}, + } + + resp := buildAuditLogListResp(logs, req, 21) + if resp == nil { + t.Fatal("expected response") + } + if len(resp.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(resp.Items)) + } + if resp.Items[0].ID != 1 { + t.Fatalf("expected item id 1, got %d", resp.Items[0].ID) + } + if resp.Pagination == nil || resp.Pagination.Page != 2 { + t.Fatalf("expected page 2, got %+v", resp.Pagination) + } +} + +func TestBuildConfigHistoryListRespIncludesOperatorName(t *testing.T) { + req := &ListConfigHistoryReq{ + PaginationReq: dto.PaginationReq{Page: 1, Size: consts.PageSizeMedium}, + } + operatorID := 5 + histories := []model.ConfigHistory{ + { + ID: 2, + ConfigID: 10, + ChangeType: consts.ChangeTypeUpdate, + ChangeField: consts.ChangeFieldDescription, + OldValue: "old desc", + NewValue: "new desc", + OperatorID: &operatorID, + Operator: &model.User{Username: "tester"}, + }, + } + + resp := buildConfigHistoryListResp(histories, req, 1) + if resp == nil { + t.Fatal("expected response") + } + if len(resp.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(resp.Items)) + } + if resp.Items[0].OperatorName != "tester" { + t.Fatalf("expected operator name tester, got %q", resp.Items[0].OperatorName) + } +} + +func TestEtcdPrefixForScopeReturnsExpectedPrefix(t *testing.T) { + cases := map[consts.ConfigScope]string{ + consts.ConfigScopeProducer: consts.ConfigEtcdProducerPrefix, + consts.ConfigScopeConsumer: consts.ConfigEtcdConsumerPrefix, + consts.ConfigScopeGlobal: consts.ConfigEtcdGlobalPrefix, + } + + for scope, want := range cases { + if got := etcdPrefixForScope(scope); got != want { + t.Fatalf("expected prefix %q for scope %v, got %q", want, scope, got) + } + } +} diff --git a/src/module/systemmetric/api_types.go b/src/module/systemmetric/api_types.go new file mode 100644 index 00000000..de1eca3d --- /dev/null +++ b/src/module/systemmetric/api_types.go @@ -0,0 +1,33 @@ +package systemmetric + +import "time" + +type NsMonitorItem struct { + LockedBy string `json:"locked_by"` + EndTime time.Time `json:"end_time"` + Status string `json:"status"` +} + +type ListNamespaceLockResp struct { + Items map[string]NsMonitorItem `json:"items" swaggertype:"object"` +} + +// MetricValue represents a single metric value. +type MetricValue struct { + Value float64 `json:"value"` + Timestamp time.Time `json:"timestamp"` + Unit string `json:"unit,omitempty"` +} + +// SystemMetricsResp represents current system metrics. +type SystemMetricsResp struct { + CPU MetricValue `json:"cpu"` + Memory MetricValue `json:"memory"` + Disk MetricValue `json:"disk"` +} + +// SystemMetricsHistoryResp represents historical system metrics. +type SystemMetricsHistoryResp struct { + CPU []MetricValue `json:"cpu"` + Memory []MetricValue `json:"memory"` +} diff --git a/src/module/systemmetric/collector.go b/src/module/systemmetric/collector.go new file mode 100644 index 00000000..2ec13354 --- /dev/null +++ b/src/module/systemmetric/collector.go @@ -0,0 +1,44 @@ +package systemmetric + +import ( + "context" + "runtime" + "time" + + "go.uber.org/fx" +) + +func RegisterMetricsCollector(lifecycle fx.Lifecycle, service *Service) { + var cancel context.CancelFunc + + lifecycle.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + collectorCtx, collectorCancel := context.WithCancel(context.WithoutCancel(ctx)) + cancel = collectorCancel + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + + for { + select { + case <-collectorCtx.Done(): + return + case <-ticker.C: + } + + if err := service.StoreSystemMetrics(collectorCtx); err != nil { + // Keep the collector alive even if a single write fails. + runtime.Gosched() + } + } + }() + return nil + }, + OnStop: func(context.Context) error { + if cancel != nil { + cancel() + } + return nil + }, + }) +} diff --git a/src/module/systemmetric/handler.go b/src/module/systemmetric/handler.go new file mode 100644 index 00000000..45bc7c1a --- /dev/null +++ b/src/module/systemmetric/handler.go @@ -0,0 +1,66 @@ +package systemmetric + +import ( + "net/http" + + "aegis/dto" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + +// GetSystemMetrics retrieves current system metrics +// +// @Summary Get current system metrics +// @Description Get current CPU, memory, and disk usage metrics +// @Tags System +// @ID get_system_metrics +// @Produce json +// @Security BearerAuth +// @Success 200 {object} dto.GenericResponse[SystemMetricsResp] "System metrics retrieved successfully" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/system/metrics [get] +// @x-api-type {"admin":"true"} +func (h *Handler) GetSystemMetrics(c *gin.Context) { + resp, err := h.service.GetSystemMetrics(c.Request.Context()) + if err != nil { + logrus.WithError(err).Error("Failed to get system metrics") + dto.ErrorResponse(c, http.StatusInternalServerError, "Internal server error") + return + } + + dto.JSONResponse(c, http.StatusOK, "System metrics retrieved successfully", resp) +} + +// GetSystemMetricsHistory retrieves historical system metrics (24 hours) +// +// @Summary Get historical system metrics +// @Description Get 24-hour historical CPU and memory usage metrics +// @Tags System +// @ID get_system_metrics_history +// @Produce json +// @Security BearerAuth +// @Success 200 {object} dto.GenericResponse[SystemMetricsHistoryResp] "System metrics history retrieved successfully" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/system/metrics/history [get] +// @x-api-type {"admin":"true"} +func (h *Handler) GetSystemMetricsHistory(c *gin.Context) { + resp, err := h.service.GetSystemMetricsHistory(c.Request.Context()) + if err != nil { + logrus.WithError(err).Error("Failed to get system metrics history") + dto.ErrorResponse(c, http.StatusInternalServerError, "Internal server error") + return + } + + dto.JSONResponse(c, http.StatusOK, "System metrics history retrieved successfully", resp) +} diff --git a/src/module/systemmetric/handler_service.go b/src/module/systemmetric/handler_service.go new file mode 100644 index 00000000..13e338c6 --- /dev/null +++ b/src/module/systemmetric/handler_service.go @@ -0,0 +1,13 @@ +package systemmetric + +import "context" + +// HandlerService captures the system metric operations consumed by the HTTP handler. +type HandlerService interface { + GetSystemMetrics(context.Context) (*SystemMetricsResp, error) + GetSystemMetricsHistory(context.Context) (*SystemMetricsHistoryResp, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/systemmetric/module.go b/src/module/systemmetric/module.go new file mode 100644 index 00000000..827e81b5 --- /dev/null +++ b/src/module/systemmetric/module.go @@ -0,0 +1,13 @@ +package systemmetric + +import "go.uber.org/fx" + +var Module = fx.Module("system_metric", + fx.Provide( + NewRepository, + NewService, + AsHandlerService, + NewHandler, + ), + fx.Invoke(RegisterMetricsCollector), +) diff --git a/src/module/systemmetric/repository.go b/src/module/systemmetric/repository.go new file mode 100644 index 00000000..65fc8d53 --- /dev/null +++ b/src/module/systemmetric/repository.go @@ -0,0 +1,11 @@ +package systemmetric + +import "gorm.io/gorm" + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} diff --git a/src/module/systemmetric/service.go b/src/module/systemmetric/service.go new file mode 100644 index 00000000..80cff2f3 --- /dev/null +++ b/src/module/systemmetric/service.go @@ -0,0 +1,247 @@ +package systemmetric + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "aegis/consts" + "aegis/dto" + redisinfra "aegis/infra/redis" + task "aegis/module/task" + + goredis "github.com/redis/go-redis/v9" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/mem" +) + +type Service struct { + repo *Repository + redis *redisinfra.Gateway +} + +func NewService(repo *Repository, redis *redisinfra.Gateway) *Service { + return &Service{repo: repo, redis: redis} +} + +func (s *Service) GetSystemMetrics(ctx context.Context) (*SystemMetricsResp, error) { + now := time.Now() + + cpuPercent, err := cpu.PercentWithContext(ctx, time.Second, false) + if err != nil { + return nil, fmt.Errorf("failed to get CPU usage: %v", err) + } + cpuUsage := 0.0 + if len(cpuPercent) > 0 { + cpuUsage = cpuPercent[0] + } + + memInfo, err := mem.VirtualMemoryWithContext(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get memory usage: %v", err) + } + + diskInfo, err := disk.UsageWithContext(ctx, "/") + if err != nil { + return nil, fmt.Errorf("failed to get disk usage: %v", err) + } + + return &SystemMetricsResp{ + CPU: MetricValue{ + Value: cpuUsage, + Timestamp: now, + Unit: "%", + }, + Memory: MetricValue{ + Value: memInfo.UsedPercent, + Timestamp: now, + Unit: "%", + }, + Disk: MetricValue{ + Value: diskInfo.UsedPercent, + Timestamp: now, + Unit: "%", + }, + }, nil +} + +func (s *Service) GetSystemMetricsHistory(ctx context.Context) (*SystemMetricsHistoryResp, error) { + now := time.Now() + startTime := now.Add(-24 * time.Hour).Unix() + endTime := now.Unix() + + cpuData, err := s.redis.ZRangeByScore(ctx, "system:metrics:cpu", fmt.Sprintf("%d", startTime), fmt.Sprintf("%d", endTime)) + if err != nil && !errors.Is(err, goredis.Nil) { + return nil, fmt.Errorf("failed to get CPU history: %v", err) + } + + memData, err := s.redis.ZRangeByScore(ctx, "system:metrics:memory", fmt.Sprintf("%d", startTime), fmt.Sprintf("%d", endTime)) + if err != nil && !errors.Is(err, goredis.Nil) { + return nil, fmt.Errorf("failed to get memory history: %v", err) + } + + cpuMetrics := parseMetricValues(cpuData) + memMetrics := parseMetricValues(memData) + + if len(cpuMetrics) == 0 || len(memMetrics) == 0 { + current, err := s.GetSystemMetrics(ctx) + if err != nil { + return nil, err + } + if len(cpuMetrics) == 0 { + cpuMetrics = []MetricValue{current.CPU} + } + if len(memMetrics) == 0 { + memMetrics = []MetricValue{current.Memory} + } + } + + return &SystemMetricsHistoryResp{ + CPU: cpuMetrics, + Memory: memMetrics, + }, nil +} + +func (s *Service) ListNamespaceLocks(ctx context.Context) (*ListNamespaceLockResp, error) { + namespaces, err := s.redis.SetMembers(ctx, consts.NamespacesKey) + if err != nil { + return nil, fmt.Errorf("failed to get namespaces from Redis: %v", err) + } + + items := make(map[string]NsMonitorItem, len(namespaces)) + for _, namespace := range namespaces { + nsKey := fmt.Sprintf(consts.NamespaceKeyPattern, namespace) + values, err := s.redis.HashGetAll(ctx, nsKey) + if err != nil { + return nil, fmt.Errorf("failed to get data for namespace %s: %v", namespace, err) + } + + endTimeUnix, err := strconv.ParseInt(values["end_time"], 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid end_time format for namespace %s: %v", namespace, err) + } + + status := consts.CommonEnabled + if statusStr, ok := values["status"]; ok { + statusInt, err := strconv.Atoi(statusStr) + if err == nil { + status = consts.StatusType(statusInt) + } + } + + items[namespace] = NsMonitorItem{ + LockedBy: values["trace_id"], + EndTime: time.Unix(endTimeUnix, 0), + Status: consts.GetStatusTypeName(status), + } + } + + return &ListNamespaceLockResp{Items: items}, nil +} + +func (s *Service) ListQueuedTasks(ctx context.Context) (*task.QueuedTasksResp, error) { + readyTaskDatas, err := s.redis.ListReadyTasks(ctx) + if err != nil { + if errors.Is(err, goredis.Nil) { + return nil, fmt.Errorf("%w: no ready tasks found", consts.ErrNotFound) + } + return nil, err + } + + readyTasks := make([]task.TaskResp, 0, len(readyTaskDatas)) + for _, taskData := range readyTaskDatas { + taskResp, err := decodeQueuedTask(taskData) + if err != nil { + return nil, err + } + readyTasks = append(readyTasks, taskResp) + } + + delayedTaskDatas, err := s.redis.ListDelayedTasks(ctx, 1000) + if err != nil { + if errors.Is(err, goredis.Nil) { + return nil, fmt.Errorf("%w: no delayed tasks found", consts.ErrNotFound) + } + return nil, err + } + + delayedTasks := make([]task.TaskResp, 0, len(delayedTaskDatas)) + for _, taskData := range delayedTaskDatas { + taskResp, err := decodeQueuedTask(taskData) + if err != nil { + return nil, err + } + delayedTasks = append(delayedTasks, taskResp) + } + + return &task.QueuedTasksResp{ + ReadyTasks: readyTasks, + DelayedTasks: delayedTasks, + }, nil +} + +func decodeQueuedTask(taskData string) (task.TaskResp, error) { + var queuedTask dto.UnifiedTask + if err := json.Unmarshal([]byte(taskData), &queuedTask); err != nil { + return task.TaskResp{}, err + } + + return task.TaskResp{ + ID: queuedTask.TaskID, + Type: consts.GetTaskTypeName(queuedTask.Type), + Immediate: queuedTask.Immediate, + ExecuteTime: queuedTask.ExecuteTime, + CronExpr: queuedTask.CronExpr, + TraceID: queuedTask.TraceID, + GroupID: queuedTask.GroupID, + State: consts.GetTaskStateName(queuedTask.State), + Status: consts.GetStatusTypeName(consts.CommonEnabled), + ProjectID: queuedTask.ProjectID, + }, nil +} + +func parseMetricValues(items []string) []MetricValue { + metrics := make([]MetricValue, 0, len(items)) + for _, item := range items { + var metric MetricValue + if err := json.Unmarshal([]byte(item), &metric); err == nil { + metrics = append(metrics, metric) + } + } + return metrics +} + +func (s *Service) StoreSystemMetrics(ctx context.Context) error { + metrics, err := s.GetSystemMetrics(ctx) + if err != nil { + return err + } + + now := time.Now().Unix() + + cpuData, _ := json.Marshal(metrics.CPU) + if err := s.redis.ZAdd(ctx, "system:metrics:cpu", goredis.Z{ + Score: float64(now), + Member: cpuData, + }); err != nil { + return fmt.Errorf("failed to store CPU metric: %v", err) + } + + memData, _ := json.Marshal(metrics.Memory) + if err := s.redis.ZAdd(ctx, "system:metrics:memory", goredis.Z{ + Score: float64(now), + Member: memData, + }); err != nil { + return fmt.Errorf("failed to store memory metric: %v", err) + } + + oldTime := time.Now().Add(-24 * time.Hour).Unix() + _ = s.redis.ZRemRangeByScore(ctx, "system:metrics:cpu", "0", fmt.Sprintf("%d", oldTime)) + _ = s.redis.ZRemRangeByScore(ctx, "system:metrics:memory", "0", fmt.Sprintf("%d", oldTime)) + + return nil +} diff --git a/src/module/task/api_types.go b/src/module/task/api_types.go new file mode 100644 index 00000000..cdcbe1fd --- /dev/null +++ b/src/module/task/api_types.go @@ -0,0 +1,186 @@ +package task + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/model" + "aegis/utils" +) + +// BatchDeleteTaskReq represents the request to batch delete tasks. +type BatchDeleteTaskReq struct { + IDs []string `json:"ids" binding:"required"` +} + +func (req *BatchDeleteTaskReq) Validate() error { + for i, id := range req.IDs { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("empty id at index %d", i) + } + if !utils.IsValidUUID(id) { + return fmt.Errorf("invalid UUID format for id at index %d: %s", i, id) + } + } + return nil +} + +// ListTaskFilters represents the filters for listing tasks. +type ListTaskFilters struct { + TaskType *consts.TaskType + Immediate *bool + TraceID string + GroupID string + ProjectID int + State *consts.TaskState + Status *consts.StatusType +} + +// ListTaskReq represents the request to list tasks. +type ListTaskReq struct { + dto.PaginationReq + TaskType *consts.TaskType `form:"task_type" binding:"omitempty"` + Immediate *bool `form:"immediate" binding:"omitempty"` + TraceID string `form:"trace_id" binding:"omitempty"` + GroupID string `form:"group_id" binding:"omitempty"` + ProjectID int `form:"project_id" binding:"omitempty"` + State *consts.TaskState `form:"state" binding:"omitempty"` + Status *consts.StatusType `form:"status" binding:"omitempty"` +} + +func (req *ListTaskReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + if err := validateTaskType(req.TaskType); err != nil { + return err + } + if err := validateUUID(req.TraceID); err != nil { + return err + } + if err := validateUUID(req.GroupID); err != nil { + return err + } + if req.ProjectID < 0 { + return fmt.Errorf("invalid project ID: %d", req.ProjectID) + } + if err := validateState(req.State); err != nil { + return err + } + return validateStatus(req.Status) +} + +func (req *ListTaskReq) ToFilterOptions() *ListTaskFilters { + return &ListTaskFilters{ + Immediate: req.Immediate, + TaskType: req.TaskType, + TraceID: req.TraceID, + GroupID: req.GroupID, + ProjectID: req.ProjectID, + State: req.State, + Status: req.Status, + } +} + +// TaskResp represents the response for a task. +type TaskResp struct { + ID string `json:"id"` + Type string `json:"type"` + Immediate bool `json:"immediate"` + ExecuteTime int64 `json:"execute_time"` + CronExpr string `json:"cron_expr,omitempty"` + TraceID string `json:"trace_id"` + GroupID string `json:"group_id"` + + State string `json:"state"` + Status string `json:"status"` + ProjectID int `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func NewTaskResp(task *model.Task) *TaskResp { + return &TaskResp{ + ID: task.ID, + Type: consts.GetTaskTypeName(task.Type), + Immediate: task.Immediate, + ExecuteTime: task.ExecuteTime, + CronExpr: task.CronExpr, + TraceID: task.TraceID, + State: consts.GetTaskStateName(task.State), + Status: consts.GetStatusTypeName(task.Status), + CreatedAt: task.CreatedAt, + UpdatedAt: task.UpdatedAt, + } +} + +// TaskDetailResp represents a task with payload and logs. +type TaskDetailResp struct { + TaskResp + + Payload map[string]any `json:"payload,omitempty" swaggertype:"object"` + Logs []string `json:"logs"` +} + +func NewTaskDetailResp(task *model.Task, logs []string) *TaskDetailResp { + resp := &TaskDetailResp{ + TaskResp: *NewTaskResp(task), + Logs: logs, + } + + if task.Payload != "" { + var payload map[string]any + if err := json.Unmarshal([]byte(task.Payload), &payload); err == nil { + resp.Payload = payload + } + } + return resp +} + +// QueuedTasksResp represents ready and delayed queued tasks. +type QueuedTasksResp struct { + ReadyTasks []TaskResp `json:"ready_tasks"` + DelayedTasks []TaskResp `json:"delayed_tasks"` +} + +func validateState(state *consts.TaskState) error { + if state != nil { + if _, exists := consts.ValidTaskStates[*state]; !exists { + return fmt.Errorf("invalid task state: %d", *state) + } + } + return nil +} + +func validateTaskType(taskType *consts.TaskType) error { + if taskType != nil { + if _, exists := consts.ValidTaskTypes[*taskType]; !exists { + return fmt.Errorf("invalid task type: %d", *taskType) + } + } + return nil +} + +func validateUUID(id string) error { + if id == "" { + return nil + } + if !utils.IsValidUUID(id) { + return fmt.Errorf("invalid UUID format: %s", id) + } + return nil +} + +func validateStatus(status *consts.StatusType) error { + if status != nil { + if _, exists := consts.ValidStatuses[*status]; !exists { + return fmt.Errorf("invalid status value: %d", *status) + } + } + return nil +} diff --git a/src/handlers/v2/tasks.go b/src/module/task/handler.go similarity index 60% rename from src/handlers/v2/tasks.go rename to src/module/task/handler.go index ea38f974..f6d6c681 100644 --- a/src/handlers/v2/tasks.go +++ b/src/module/task/handler.go @@ -1,30 +1,35 @@ -package v2 +package task import ( + "aegis/httpx" + "errors" "net/http" "aegis/consts" - "aegis/database" "aegis/dto" - "aegis/handlers" - "aegis/repository" - producer "aegis/service/producer" "aegis/utils" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/sirupsen/logrus" - "gorm.io/gorm" ) var wsUpgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 4096, CheckOrigin: func(r *http.Request) bool { - return true // Allow all origins (JWT already handles auth) + return true }, } +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + // BatchDeleteTasks // // @Summary Batch delete tasks @@ -34,15 +39,16 @@ var wsUpgrader = websocket.Upgrader{ // @Accept json // @Produce json // @Security BearerAuth -// @Param batch_delete body dto.BatchDeleteTaskReq true "Batch delete request" +// @Param batch_delete body BatchDeleteTaskReq true "Batch delete request" // @Success 200 {object} dto.GenericResponse[any] "Tasks deleted successfully" // @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" // @Failure 401 {object} dto.GenericResponse[any] "Authentication required" // @Failure 403 {object} dto.GenericResponse[any] "Permission denied" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/tasks/batch-delete [post] -func BatchDeleteTasks(c *gin.Context) { - var req dto.BatchDeleteTaskReq +// @x-api-type {"portal":"true"} +func (h *Handler) BatchDelete(c *gin.Context) { + var req BatchDeleteTaskReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -53,8 +59,8 @@ func BatchDeleteTasks(c *gin.Context) { return } - err := producer.BatchDeleteTasks(req.IDs) - if handlers.HandleServiceError(c, err) { + err := h.service.BatchDelete(c.Request.Context(), req.IDs) + if httpx.HandleServiceError(c, err) { return } @@ -69,102 +75,103 @@ func BatchDeleteTasks(c *gin.Context) { // @ID get_task_by_id // @Produce json // @Security BearerAuth -// @Param task_id path string true "Task ID" -// @Success 200 {object} dto.GenericResponse[dto.TaskDetailResp] "Task retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid task ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Task not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param task_id path string true "Task ID" +// @Success 200 {object} dto.GenericResponse[TaskDetailResp] "Task retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid task ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Task not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/tasks/{task_id} [get] -// @x-api-type {"sdk":"true"} -func GetTask(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) GetTask(c *gin.Context) { taskID := c.Param(consts.URLPathTaskID) if !utils.IsValidUUID(taskID) { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid task ID") return } - resp, err := producer.GetTaskDetail(taskID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.GetDetail(c.Request.Context(), taskID) + if httpx.HandleServiceError(c, err) { return } dto.SuccessResponse(c, resp) } -// ListTasks handles simple task listing +// ExpediteTask handles expediting a Pending task to execute immediately. // -// @Summary List tasks -// @Description Get a simple list of tasks with basic filtering via query parameters +// @Summary Expedite a pending task +// @Description Moves a Pending task's execute_time to now, rescoring it in the +// @Description Redis delayed queue so the scheduler picks it up on its next tick. +// @Description Rejects the call with 400 if the task is in any state other than +// @Description Pending. Idempotent: expediting an already-due task succeeds. // @Tags Tasks -// @ID list_tasks +// @ID expedite_task // @Produce json // @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param task_type query consts.TaskType false "Filter by task type" -// @Param immediate query bool false "Filter by immediate execution" -// @Param trace_id query string false "Filter by trace ID (uuid format)" -// @Param group_id query string false "Filter by group ID (uuid format)" -// @Param project_id query int false "Filter by project ID" -// @Param state query consts.TaskState false "Filter by state" -// @Param status query consts.StatusType false "Filter by status" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.TaskResp]] "Tasks retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/tasks [get] -func ListTasks(c *gin.Context) { - var req dto.ListTaskReq - if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) - return - } - - if err := req.Validate(); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) +// @Param task_id path string true "Task ID" +// @Success 200 {object} dto.GenericResponse[TaskResp] "Task expedited" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid task ID or task not in Pending state" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Task not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/tasks/{task_id}/expedite [post] +// @x-api-type {"sdk":"true"} +func (h *Handler) ExpediteTask(c *gin.Context) { + taskID := c.Param(consts.URLPathTaskID) + if !utils.IsValidUUID(taskID) { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid task ID") return } - resp, err := producer.ListTasks(&req) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.Expedite(c.Request.Context(), taskID) + if httpx.HandleServiceError(c, err) { return } dto.SuccessResponse(c, resp) } -// ExpediteTask handles expediting a Pending task to execute immediately. +// ListTasks handles simple task listing // -// @Summary Expedite a pending task -// @Description Moves a Pending task's execute_time to now, rescoring it in the -// @Description Redis delayed queue so the scheduler picks it up on its next tick. -// @Description Rejects the call with 400 if the task is in any state other than -// @Description Pending. Idempotent: expediting an already-due task succeeds. +// @Summary List tasks +// @Description Get a simple list of tasks with basic filtering via query parameters // @Tags Tasks -// @ID expedite_task +// @ID list_tasks // @Produce json // @Security BearerAuth -// @Param task_id path string true "Task ID" -// @Success 200 {object} dto.GenericResponse[dto.TaskResp] "Task expedited" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid task ID or task not in Pending state" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Task not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/tasks/{task_id}/expedite [post] -// @x-api-type {"sdk":"true"} -func ExpediteTask(c *gin.Context) { - taskID := c.Param(consts.URLPathTaskID) - if !utils.IsValidUUID(taskID) { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid task ID") +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Param task_type query consts.TaskType false "Filter by task type" +// @Param immediate query bool false "Filter by immediate execution" +// @Param trace_id query string false "Filter by trace ID (uuid format)" +// @Param group_id query string false "Filter by group ID (uuid format)" +// @Param project_id query int false "Filter by project ID" +// @Param state query consts.TaskState false "Filter by state" +// @Param status query consts.StatusType false "Filter by status" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[TaskResp]] "Tasks retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Router /api/v2/tasks [get] +// @x-api-type {"portal":"true"} +func (h *Handler) ListTasks(c *gin.Context) { + var req ListTaskReq + if err := c.ShouldBindQuery(&req); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) + return + } + + if err := req.Validate(); err != nil { + dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - resp, err := producer.ExpediteTask(taskID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.List(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { return } @@ -178,21 +185,21 @@ func ExpediteTask(c *gin.Context) { // @Description Process: 1. Validate Token -> 2. Push historical logs from Loki -> 3. Subscribe to Redis for real-time updates -> 4. Close on task completion. // @Tags Tasks // @ID get_task_logs_ws -// @Param task_id path string true "Task ID" -// @Param token query string true "JWT authentication token" -// @Success 101 {object} dto.WSLogMessage "WebSocket connection established" +// @Param task_id path string true "Task ID" +// @Param token query string true "JWT authentication token" +// @Success 101 {object} WSLogMessage "WebSocket connection established" // @Failure 400 "Invalid task ID" // @Failure 401 "Authentication failed" // @Failure 404 "Task not found" // @Router /api/v2/tasks/{task_id}/logs/ws [get] -func GetTaskLogsWS(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) GetTaskLogsWS(c *gin.Context) { taskID := c.Param(consts.URLPathTaskID) if taskID == "" { dto.ErrorResponse(c, http.StatusBadRequest, "Task ID is required") return } - // Authenticate via query parameter (WebSocket doesn't support custom headers) token := c.Query("token") if token == "" { dto.ErrorResponse(c, http.StatusUnauthorized, "Token query parameter is required") @@ -204,10 +211,9 @@ func GetTaskLogsWS(c *gin.Context) { return } - // Verify task exists - task, err := repository.GetTaskByID(database.DB, taskID) + task, err := h.service.GetForLogStream(c.Request.Context(), taskID) if err != nil { - if err == gorm.ErrRecordNotFound { + if errors.Is(err, consts.ErrNotFound) { dto.ErrorResponse(c, http.StatusNotFound, "Task not found") return } @@ -216,7 +222,6 @@ func GetTaskLogsWS(c *gin.Context) { return } - // Upgrade to WebSocket conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { logrus.Errorf("WebSocket upgrade failed for task %s: %v", taskID, err) @@ -224,7 +229,5 @@ func GetTaskLogsWS(c *gin.Context) { } defer func() { _ = conn.Close() }() - // Delegate all streaming logic to the service layer - streamer := producer.NewTaskLogStreamer(conn, taskID) - streamer.StreamLogs(c.Request.Context(), task) + h.service.StreamLogs(c.Request.Context(), conn, task) } diff --git a/src/module/task/handler_service.go b/src/module/task/handler_service.go new file mode 100644 index 00000000..3a04010c --- /dev/null +++ b/src/module/task/handler_service.go @@ -0,0 +1,24 @@ +package task + +import ( + "context" + + "aegis/dto" + "aegis/model" + + "github.com/gorilla/websocket" +) + +// HandlerService captures task operations consumed by HTTP handlers and gateway adapters. +type HandlerService interface { + BatchDelete(context.Context, []string) error + GetDetail(context.Context, string) (*TaskDetailResp, error) + List(context.Context, *ListTaskReq) (*dto.ListResp[TaskResp], error) + GetForLogStream(context.Context, string) (*model.Task, error) + StreamLogs(context.Context, *websocket.Conn, *model.Task) + Expedite(context.Context, string) (*TaskResp, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/task/log_service.go b/src/module/task/log_service.go new file mode 100644 index 00000000..94c86334 --- /dev/null +++ b/src/module/task/log_service.go @@ -0,0 +1,272 @@ +package task + +import ( + "context" + "encoding/json" + "sync" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/model" + + "github.com/gorilla/websocket" + "github.com/redis/go-redis/v9" + "github.com/sirupsen/logrus" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = 54 * time.Second + maxMsgSize = 512 + taskPollInterval = 5 * time.Second + completionFlushDelay = 5 * time.Second +) + +type TaskLogService struct { + repository *Repository + queueStore *TaskQueueStore + loki *LokiGateway +} + +func NewTaskLogService(repository *Repository, queueStore *TaskQueueStore, loki *LokiGateway) *TaskLogService { + return &TaskLogService{ + repository: repository, + queueStore: queueStore, + loki: loki, + } +} + +func (s *TaskLogService) StreamLogs(ctx context.Context, conn *websocket.Conn, task *model.Task) { + streamer := &taskLogStreamer{ + ctx: ctx, + conn: conn, + task: task, + taskID: task.ID, + service: s, + log: logrus.WithField("task_id", task.ID), + } + streamer.StreamLogs(ctx) +} + +type taskLogStreamer struct { + ctx context.Context + conn *websocket.Conn + mu sync.Mutex + log *logrus.Entry + taskID string + task *model.Task + service *TaskLogService +} + +func (s *taskLogStreamer) StreamLogs(ctx context.Context) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + s.conn.SetReadLimit(maxMsgSize) + _ = s.conn.SetReadDeadline(time.Now().Add(pongWait)) + s.conn.SetPongHandler(func(string) error { + _ = s.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + go s.runReadLoop(cancel) + go s.runPingLoop(ctx, cancel) + + pubsub, err := s.service.queueStore.SubscribeJobLogs(ctx, s.taskID) + if err != nil { + s.log.Errorf("Failed to subscribe to Redis Pub/Sub for task logs: %v", err) + s.WriteMessage(WSLogMessage{ + Type: consts.WSLogTypeError, + Message: "failed to subscribe to log stream", + }) + return + } + defer func() { _ = pubsub.Close() }() + s.log.Info("Subscribed to Redis Pub/Sub for real-time logs") + + lastHistoricalTime := s.sendHistoricalLogs() + + if isTaskTerminal(s.task.State) { + s.WriteMessage(WSLogMessage{ + Type: consts.WSLogTypeEnd, + Message: "task already completed", + }) + s.closeNormal("task completed") + return + } + + s.streamRealtime(ctx, pubsub.Channel(), lastHistoricalTime) +} + +func (s *taskLogStreamer) runReadLoop(cancel context.CancelFunc) { + defer cancel() + for { + _, _, err := s.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + s.log.Warnf("WebSocket unexpected close: %v", err) + } + return + } + } +} + +func (s *taskLogStreamer) WriteMessage(msg WSLogMessage) { + s.mu.Lock() + defer s.mu.Unlock() + + _ = s.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := s.conn.WriteJSON(msg); err != nil { + s.log.Warnf("WebSocket write error: %v", err) + } +} + +func (s *taskLogStreamer) ForwardRedisLog(payload string, lastHistoricalTime time.Time) { + var entry dto.LogEntry + if err := json.Unmarshal([]byte(payload), &entry); err != nil { + s.log.Warnf("Failed to unmarshal Redis log message: %v", err) + return + } + + if !lastHistoricalTime.IsZero() && !entry.Timestamp.After(lastHistoricalTime) { + return + } + + s.WriteMessage(WSLogMessage{ + Type: consts.WSLogTypeRealtime, + Logs: []dto.LogEntry{entry}, + }) +} + +func (s *taskLogStreamer) runPingLoop(ctx context.Context, cancel context.CancelFunc) { + ticker := time.NewTicker(pingPeriod) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.mu.Lock() + _ = s.conn.SetWriteDeadline(time.Now().Add(writeWait)) + err := s.conn.WriteMessage(websocket.PingMessage, nil) + s.mu.Unlock() + if err != nil { + cancel() + return + } + } + } +} + +func (s *taskLogStreamer) sendHistoricalLogs() time.Time { + lokiCtx, lokiCancel := context.WithTimeout(s.ctx, 15*time.Second) + defer lokiCancel() + + historicalLogs, err := s.service.loki.QueryJobLogs(lokiCtx, s.taskID, s.task.CreatedAt) + if err != nil { + s.log.Warnf("Failed to query Loki for historical logs: %v", err) + return time.Time{} + } + + if len(historicalLogs) > 0 { + s.WriteMessage(WSLogMessage{ + Type: consts.WSLogTypeHistory, + Logs: historicalLogs, + Total: len(historicalLogs), + }) + s.log.Infof("Sent %d historical log entries", len(historicalLogs)) + return historicalLogs[len(historicalLogs)-1].Timestamp + } + + return time.Time{} +} + +func (s *taskLogStreamer) streamRealtime(ctx context.Context, redisCh <-chan *redis.Message, lastHistoricalTime time.Time) { + taskDoneCh := make(chan struct{}) + go s.pollTaskCompletion(ctx, taskDoneCh) + + for { + select { + case <-ctx.Done(): + s.log.Info("Context cancelled, closing WebSocket") + return + + case <-taskDoneCh: + s.flushAndClose(redisCh, lastHistoricalTime) + return + + case msg, ok := <-redisCh: + if !ok { + s.log.Warn("Redis Pub/Sub channel closed") + s.WriteMessage(WSLogMessage{ + Type: consts.WSLogTypeError, + Message: "log stream interrupted", + }) + return + } + s.ForwardRedisLog(msg.Payload, lastHistoricalTime) + } + } +} + +func (s *taskLogStreamer) pollTaskCompletion(ctx context.Context, taskDoneCh chan<- struct{}) { + ticker := time.NewTicker(taskPollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + task, err := s.service.repository.GetByID(s.taskID) + if err != nil { + s.log.Warnf("Failed to poll task state: %v", err) + continue + } + if isTaskTerminal(task.State) { + s.log.Info("Task detected as terminal, initiating close") + close(taskDoneCh) + return + } + } + } +} + +func (s *taskLogStreamer) flushAndClose(redisCh <-chan *redis.Message, lastHistoricalTime time.Time) { + s.log.Info("Task completed, flushing remaining logs...") + flushTimer := time.NewTimer(completionFlushDelay) + defer flushTimer.Stop() + +flushLoop: + for { + select { + case msg, ok := <-redisCh: + if !ok { + break flushLoop + } + s.ForwardRedisLog(msg.Payload, lastHistoricalTime) + case <-flushTimer.C: + break flushLoop + } + } + + s.WriteMessage(WSLogMessage{ + Type: consts.WSLogTypeEnd, + Message: "task completed", + }) + s.closeNormal("task completed") +} + +func (s *taskLogStreamer) closeNormal(reason string) { + s.mu.Lock() + defer s.mu.Unlock() + + _ = s.conn.SetWriteDeadline(time.Now().Add(writeWait)) + _ = s.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, reason)) +} + +func isTaskTerminal(state consts.TaskState) bool { + return state == consts.TaskCompleted || state == consts.TaskError || state == consts.TaskCancelled +} diff --git a/src/module/task/log_types.go b/src/module/task/log_types.go new file mode 100644 index 00000000..6a72ab59 --- /dev/null +++ b/src/module/task/log_types.go @@ -0,0 +1,23 @@ +package task + +import ( + "aegis/consts" + "aegis/dto" + "time" +) + +// WSLogMessage is the WebSocket payload for task log streaming. +type WSLogMessage struct { + Type consts.WSLogType `json:"type"` + Logs []dto.LogEntry `json:"logs,omitempty"` + Message string `json:"message,omitempty"` + Total int `json:"total,omitempty"` +} + +// TaskLogPollResp represents one task log poll batch for remote websocket forwarding. +type TaskLogPollResp struct { + Logs []dto.LogEntry `json:"logs"` + Terminal bool `json:"terminal"` + State string `json:"state"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/src/module/task/loki_gateway.go b/src/module/task/loki_gateway.go new file mode 100644 index 00000000..86914fa5 --- /dev/null +++ b/src/module/task/loki_gateway.go @@ -0,0 +1,24 @@ +package task + +import ( + "context" + "time" + + "aegis/dto" + loki "aegis/infra/loki" +) + +type LokiGateway struct { + client *loki.Client +} + +func NewLokiGateway(client *loki.Client) *LokiGateway { + return &LokiGateway{client: client} +} + +func (g *LokiGateway) QueryJobLogs(ctx context.Context, taskID string, start time.Time) ([]dto.LogEntry, error) { + return g.client.QueryJobLogs(ctx, taskID, loki.QueryOpts{ + Start: start, + Direction: "forward", + }) +} diff --git a/src/module/task/module.go b/src/module/task/module.go new file mode 100644 index 00000000..bc7dd39c --- /dev/null +++ b/src/module/task/module.go @@ -0,0 +1,13 @@ +package task + +import "go.uber.org/fx" + +var Module = fx.Module("task", + fx.Provide(NewRepository), + fx.Provide(NewTaskQueueStore), + fx.Provide(NewLokiGateway), + fx.Provide(NewTaskLogService), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/module/task/queue_store.go b/src/module/task/queue_store.go new file mode 100644 index 00000000..373f3a9d --- /dev/null +++ b/src/module/task/queue_store.go @@ -0,0 +1,24 @@ +package task + +import ( + "context" + "fmt" + + redisinfra "aegis/infra/redis" + goredis "github.com/redis/go-redis/v9" +) + +const jobLogsChannelPrefix = "joblogs" + +type TaskQueueStore struct { + redis *redisinfra.Gateway +} + +func NewTaskQueueStore(redis *redisinfra.Gateway) *TaskQueueStore { + return &TaskQueueStore{redis: redis} +} + +func (s *TaskQueueStore) SubscribeJobLogs(ctx context.Context, taskID string) (*goredis.PubSub, error) { + channel := fmt.Sprintf("%s:%s", jobLogsChannelPrefix, taskID) + return s.redis.Subscribe(ctx, channel) +} diff --git a/src/module/task/repository.go b/src/module/task/repository.go new file mode 100644 index 00000000..db72d3d3 --- /dev/null +++ b/src/module/task/repository.go @@ -0,0 +1,90 @@ +package task + +import ( + "aegis/consts" + "aegis/model" + "fmt" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) BatchDelete(taskIDs []string) error { + if len(taskIDs) == 0 { + return nil + } + + if err := r.db.Model(&model.Task{}). + Where("id IN (?) AND status != ?", taskIDs, consts.CommonDeleted). + Update("status", consts.CommonDeleted).Error; err != nil { + return fmt.Errorf("failed to batch delete tasks: %w", err) + } + return nil +} + +func (r *Repository) GetByID(taskID string) (*model.Task, error) { + var task model.Task + if err := r.db. + Preload("FaultInjection.Benchmark.Container"). + Preload("FaultInjection.Pedestal.Container"). + Preload("Execution.AlgorithmVersion.Container"). + Preload("Execution.Datapack"). + Preload("Execution.DatasetVersion"). + Where("id = ? AND status != ?", taskID, consts.CommonDeleted). + First(&task).Error; err != nil { + return nil, fmt.Errorf("failed to find task with id %s: %w", taskID, err) + } + return &task, nil +} + +// UpdateExecuteTime updates the execute_time column of a task row. +func (r *Repository) UpdateExecuteTime(taskID string, executeTime int64) error { + return r.db.Model(&model.Task{}). + Where("id = ?", taskID). + Update("execute_time", executeTime).Error +} + +func (r *Repository) List(limit, offset int, filters *ListTaskFilters) ([]model.Task, int64, error) { + var ( + tasks []model.Task + total int64 + ) + + query := r.db.Model(&model.Task{}) + if filters.Immediate != nil { + query = query.Where("immediate = ?", *filters.Immediate) + } + if filters.TaskType != nil { + query = query.Where("type = ?", *filters.TaskType) + } + if filters.TraceID != "" { + query = query.Where("trace_id = ?", filters.TraceID) + } + if filters.GroupID != "" { + query = query.Where("group_id = ?", filters.GroupID) + } + if filters.ProjectID > 0 { + query = query.Where("project_id = ?", filters.ProjectID) + } + if filters.State != nil { + query = query.Where("state = ?", *filters.State) + } + if filters.Status != nil { + query = query.Where("status = ?", *filters.Status) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count tasks: %w", err) + } + if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&tasks).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list tasks: %w", err) + } + return tasks, total, nil +} diff --git a/src/module/task/service.go b/src/module/task/service.go new file mode 100644 index 00000000..210f58c5 --- /dev/null +++ b/src/module/task/service.go @@ -0,0 +1,204 @@ +package task + +import ( + "context" + "errors" + "fmt" + "time" + + "aegis/consts" + "aegis/dto" + redisinfra "aegis/infra/redis" + "aegis/model" + + "github.com/gorilla/websocket" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type Service struct { + repository *Repository + logService *TaskLogService + loki *LokiGateway + redis *redisinfra.Gateway +} + +func NewService(repository *Repository, logService *TaskLogService, loki *LokiGateway, redis *redisinfra.Gateway) *Service { + return &Service{ + repository: repository, + logService: logService, + loki: loki, + redis: redis, + } +} + +func (s *Service) BatchDelete(ctx context.Context, taskIDs []string) error { + if len(taskIDs) == 0 { + return nil + } + + return s.repository.BatchDelete(taskIDs) +} + +func (s *Service) GetDetail(ctx context.Context, taskID string) (*TaskDetailResp, error) { + task, err := s.repository.GetByID(taskID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: task id: %s", consts.ErrNotFound, taskID) + } + return nil, fmt.Errorf("failed to get task: %w", err) + } + + logs := s.queryHistoricalLogs(ctx, task) + return NewTaskDetailResp(task, logs), nil +} + +func (s *Service) List(ctx context.Context, req *ListTaskReq) (*dto.ListResp[TaskResp], error) { + if req == nil { + return nil, fmt.Errorf("list tasks request is nil") + } + + limit, offset := req.ToGormParams() + filterOptions := req.ToFilterOptions() + + tasks, total, err := s.repository.List(limit, offset, filterOptions) + if err != nil { + return nil, fmt.Errorf("failed to list tasks: %w", err) + } + + taskResps := make([]TaskResp, 0, len(tasks)) + for _, task := range tasks { + taskResps = append(taskResps, *NewTaskResp(&task)) + } + + return &dto.ListResp[TaskResp]{ + Items: taskResps, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +// Expedite moves a Pending task's execute_time to now. +// Contract: +// - task not found → wrapped consts.ErrNotFound +// - task not Pending → wrapped consts.ErrBadRequest +// - already due → no-op, returns task resp (idempotent) +// +// DB update is authoritative; Redis rescore is best-effort — if the entry +// is already promoted by the scheduler, the call still succeeds. +func (s *Service) Expedite(ctx context.Context, taskID string) (*TaskResp, error) { + task, err := s.repository.GetByID(taskID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: task id: %s", consts.ErrNotFound, taskID) + } + return nil, fmt.Errorf("failed to load task: %w", err) + } + + if task.State != consts.TaskPending { + return nil, fmt.Errorf("%w: state=%s, cannot expedite", + consts.ErrBadRequest, consts.GetTaskStateName(task.State)) + } + + now := time.Now().Unix() + if task.ExecuteTime <= now { + return NewTaskResp(task), nil + } + + if err := s.repository.UpdateExecuteTime(taskID, now); err != nil { + return nil, fmt.Errorf("failed to update execute_time: %w", err) + } + + if _, err := s.redis.ExpediteDelayedTask(ctx, taskID, now); err != nil { + logrus.WithField("task_id", taskID). + Warnf("DB updated but Redis rescore failed: %v", err) + } + + s.emitExpediteScheduledEvent(ctx, task, now) + + task.ExecuteTime = now + return NewTaskResp(task), nil +} + +// emitExpediteScheduledEvent publishes a task.scheduled event for a manually +// expedited task. Best-effort — failures are logged only. +func (s *Service) emitExpediteScheduledEvent(ctx context.Context, task *model.Task, executeTime int64) { + if task == nil || task.TraceID == "" || s.redis == nil { + return + } + event := dto.TraceStreamEvent{ + TaskID: task.ID, + TaskType: task.Type, + EventName: consts.EventTaskScheduled, + Payload: dto.TaskScheduledPayload{ + ExecuteTime: executeTime, + Reason: dto.TaskScheduledReasonExpedite, + }, + } + stream := fmt.Sprintf(consts.StreamTraceLogKey, task.TraceID) + if err := s.redis.XAdd(ctx, stream, event.ToRedisStream()); err != nil { + logrus.WithField("task_id", task.ID). + Warnf("failed to emit expedite task.scheduled event: %v", err) + } +} + +func (s *Service) GetForLogStream(ctx context.Context, taskID string) (*model.Task, error) { + task, err := s.repository.GetByID(taskID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: task id: %s", consts.ErrNotFound, taskID) + } + return nil, fmt.Errorf("failed to get task: %w", err) + } + return task, nil +} + +func (s *Service) StreamLogs(ctx context.Context, conn *websocket.Conn, task *model.Task) { + s.logService.StreamLogs(ctx, conn, task) +} + +func (s *Service) PollLogs(ctx context.Context, taskID string, after time.Time) (*TaskLogPollResp, error) { + task, err := s.repository.GetByID(taskID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: task id: %s", consts.ErrNotFound, taskID) + } + return nil, fmt.Errorf("failed to get task: %w", err) + } + + start := task.CreatedAt + if !after.IsZero() && after.After(start) { + start = after.Add(time.Nanosecond) + } + + lokiCtx, lokiCancel := context.WithTimeout(ctx, 10*time.Second) + defer lokiCancel() + + logEntries, err := s.loki.QueryJobLogs(lokiCtx, task.ID, start) + if err != nil { + return nil, fmt.Errorf("failed to query task logs: %w", err) + } + + return &TaskLogPollResp{ + Logs: logEntries, + Terminal: isTaskTerminal(task.State), + State: consts.GetTaskStateName(task.State), + CreatedAt: task.CreatedAt, + }, nil +} + +func (s *Service) queryHistoricalLogs(ctx context.Context, task *model.Task) []string { + lokiCtx, lokiCancel := context.WithTimeout(ctx, 10*time.Second) + defer lokiCancel() + + logEntries, err := s.loki.QueryJobLogs(lokiCtx, task.ID, task.CreatedAt) + if err != nil { + logrus.Warnf("Failed to query Loki for task %s logs: %v", task.ID, err) + return []string{} + } + + logs := make([]string, 0, len(logEntries)) + for _, entry := range logEntries { + logs = append(logs, entry.Line) + } + return logs +} diff --git a/src/module/task/service_test.go b/src/module/task/service_test.go new file mode 100644 index 00000000..792f0f9b --- /dev/null +++ b/src/module/task/service_test.go @@ -0,0 +1,105 @@ +package task + +import ( + "context" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + "time" + + "aegis/consts" + lokiinfra "aegis/infra/loki" + "aegis/model" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func newTaskService(t *testing.T, gateway *LokiGateway) (*Service, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New() + require.NoError(t, err) + + db, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + require.NoError(t, err) + + if gateway == nil { + gateway = NewLokiGateway(&lokiinfra.Client{}) + } + + service := NewService(NewRepository(db), NewTaskLogService(NewRepository(db), nil, gateway), gateway, nil) + return service, mock, func() { + _ = sqlDB.Close() + } +} + +func TestTaskServiceListSuccess(t *testing.T) { + service, mock, cleanup := newTaskService(t, nil) + defer cleanup() + + now := time.Now() + state := consts.TaskPending + req := &ListTaskReq{State: &state} + + mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM `tasks` WHERE state = ?")). + WithArgs(state). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `tasks` WHERE state = ? ORDER BY created_at DESC LIMIT ?")). + WithArgs(state, 20). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "type", "immediate", "execute_time", "cron_expr", "payload", "trace_id", "parent_task_id", + "level", "sequence", "state", "status", "created_at", "updated_at", + }).AddRow("task-1", consts.TaskTypeRunAlgorithm, true, 0, "", "{}", "trace-1", nil, 0, 0, consts.TaskPending, consts.CommonEnabled, now, now)) + + resp, err := service.List(t.Context(), req) + + require.NoError(t, err) + require.Len(t, resp.Items, 1) + require.Equal(t, "task-1", resp.Items[0].ID) + require.Equal(t, consts.GetTaskStateName(consts.TaskPending), resp.Items[0].State) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestTaskServiceQueryHistoricalLogsSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/loki/api/v1/query_range", r.URL.Path) + require.True(t, strings.Contains(r.URL.Query().Get("query"), `task_id="task-1"`)) + _, _ = w.Write([]byte(`{ + "status":"success", + "data":{ + "resultType":"streams", + "result":[{ + "stream":{"trace_id":"trace-1","job_id":"job-1"}, + "values":[ + ["1710000000000000000","first log"], + ["1710000001000000000","second log"] + ] + }] + } + }`)) + })) + defer server.Close() + + viper.Set("loki.address", server.URL) + viper.Set("loki.max_entries", 100) + + gateway := NewLokiGateway(lokiinfra.NewClient()) + service, _, cleanup := newTaskService(t, gateway) + defer cleanup() + + logs := service.queryHistoricalLogs(context.Background(), &model.Task{ + ID: "task-1", + CreatedAt: time.Unix(1710000000, 0), + }) + + require.Equal(t, []string{"first log", "second log"}, logs) +} diff --git a/src/dto/team.go b/src/module/team/api_types.go similarity index 67% rename from src/dto/team.go rename to src/module/team/api_types.go index 6676c68f..644ca405 100644 --- a/src/dto/team.go +++ b/src/module/team/api_types.go @@ -1,4 +1,4 @@ -package dto +package team import ( "fmt" @@ -6,12 +6,15 @@ import ( "time" "aegis/consts" - "aegis/database" + "aegis/dto" + "aegis/model" + project "aegis/module/project" ) -// ===================== Team CRUD DTOs ===================== +type TeamProjectListReq = project.ListProjectReq +type TeamProjectItem = project.ProjectResp -// CreateTeamReq represents team creation request +// CreateTeamReq represents team creation request. type CreateTeamReq struct { Name string `json:"name" binding:"required"` Description string `json:"description" binding:"omitempty"` @@ -30,8 +33,8 @@ func (req *CreateTeamReq) Validate() error { return nil } -func (req *CreateTeamReq) ConvertToTeam() *database.Team { - return &database.Team{ +func (req *CreateTeamReq) ConvertToTeam() *model.Team { + return &model.Team{ Name: req.Name, Description: req.Description, IsPublic: *req.IsPublic, @@ -39,9 +42,9 @@ func (req *CreateTeamReq) ConvertToTeam() *database.Team { } } -// ListTeamReq represents team list query parameters +// ListTeamReq represents team list query parameters. type ListTeamReq struct { - PaginationReq + dto.PaginationReq IsPublic *bool `form:"is_public" binding:"omitempty"` Status *consts.StatusType `form:"status" binding:"omitempty"` } @@ -50,10 +53,10 @@ func (req *ListTeamReq) Validate() error { if err := req.PaginationReq.Validate(); err != nil { return err } - return validateStatusField(req.Status, false) + return validateStatus(req.Status, false) } -// UpdateTeamReq represents team update request +// UpdateTeamReq represents team update request. type UpdateTeamReq struct { Description *string `json:"description,omitempty"` IsPublic *bool `json:"is_public,omitempty"` @@ -61,10 +64,10 @@ type UpdateTeamReq struct { } func (req *UpdateTeamReq) Validate() error { - return validateStatusField(req.Status, true) + return validateStatus(req.Status, true) } -func (req *UpdateTeamReq) PatchTeamModel(target *database.Team) { +func (req *UpdateTeamReq) PatchTeamModel(target *model.Team) { if req.Description != nil { target.Description = *req.Description } @@ -76,7 +79,7 @@ func (req *UpdateTeamReq) PatchTeamModel(target *database.Team) { } } -// TeamResp represents basic team response +// TeamResp represents basic team response. type TeamResp struct { ID int `json:"id"` Name string `json:"name"` @@ -87,7 +90,7 @@ type TeamResp struct { UpdatedAt time.Time `json:"updated_at"` } -func NewTeamResp(team *database.Team) *TeamResp { +func NewTeamResp(team *model.Team) *TeamResp { return &TeamResp{ ID: team.ID, Name: team.Name, @@ -99,33 +102,31 @@ func NewTeamResp(team *database.Team) *TeamResp { } } -// TeamDetailResp represents detailed team response +// TeamDetailResp represents detailed team response. type TeamDetailResp struct { TeamResp - UserCount int `json:"user_count"` - ProjectCount int `json:"project_count"` - Projects []ProjectResp `json:"projects,omitempty"` + UserCount int `json:"user_count"` + ProjectCount int `json:"project_count"` + Projects []TeamProjectItem `json:"projects,omitempty"` } -func NewTeamDetailResp(team *database.Team) *TeamDetailResp { +func NewTeamDetailResp(team *model.Team) *TeamDetailResp { return &TeamDetailResp{ TeamResp: *NewTeamResp(team), } } -// ===================== Team-User DTOs ===================== - -// ListTeamMemberReq represents team member list query parameters +// ListTeamMemberReq represents team member list query parameters. type ListTeamMemberReq struct { - PaginationReq + dto.PaginationReq } func (req *ListTeamMemberReq) Validate() error { return req.PaginationReq.Validate() } -// AddTeamMemberReq represents request to add a user to team +// AddTeamMemberReq represents request to add a user to team. type AddTeamMemberReq struct { Username string `json:"username" binding:"required"` RoleID int `json:"role_id" binding:"required"` @@ -142,7 +143,7 @@ func (req *AddTeamMemberReq) Validate() error { return nil } -// UpdateTeamMemberRoleReq represents request to update team member's role +// UpdateTeamMemberRoleReq represents request to update team member's role. type UpdateTeamMemberRoleReq struct { RoleID int `json:"role_id" binding:"required"` } @@ -154,7 +155,7 @@ func (req *UpdateTeamMemberRoleReq) Validate() error { return nil } -// TeamMemberResp represents team member information +// TeamMemberResp represents team member information. type TeamMemberResp struct { UserID int `json:"user_id"` Username string `json:"username"` @@ -164,3 +165,18 @@ type TeamMemberResp struct { RoleName string `json:"role_name"` JoinedAt time.Time `json:"joined_at"` } + +func validateStatus(statusPtr *consts.StatusType, isMutation bool) error { + if statusPtr == nil { + return nil + } + + status := *statusPtr + if _, exists := consts.ValidStatuses[status]; !exists { + return fmt.Errorf("invalid status value: %d", status) + } + if isMutation && status == consts.CommonDeleted { + return fmt.Errorf("status value cannot be set to deleted (%d) directly through this update/create operation", consts.CommonDeleted) + } + return nil +} diff --git a/src/handlers/v2/teams.go b/src/module/team/handler.go similarity index 60% rename from src/handlers/v2/teams.go rename to src/module/team/handler.go index 3f7f245b..699c2aeb 100644 --- a/src/handlers/v2/teams.go +++ b/src/module/team/handler.go @@ -1,18 +1,25 @@ -package v2 +package team import ( + "aegis/httpx" "net/http" "strconv" "aegis/consts" "aegis/dto" - "aegis/handlers" "aegis/middleware" - producer "aegis/service/producer" "github.com/gin-gonic/gin" ) +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + // CreateTeam handles team creation // // @Summary Create a new team @@ -22,38 +29,34 @@ import ( // @Accept json // @Produce json // @Security BearerAuth -// @Param request body dto.CreateTeamReq true "Team creation request" -// @Success 201 {object} dto.GenericResponse[dto.TeamResp] "Team created successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 409 {object} dto.GenericResponse[any] "Team already exists" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param request body CreateTeamReq true "Team creation request" +// @Success 201 {object} dto.GenericResponse[TeamResp] "Team created successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 409 {object} dto.GenericResponse[any] "Team already exists" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/teams [post] -// @x-api-type {"sdk":"true"} -func CreateTeam(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) CreateTeam(c *gin.Context) { userID, exists := middleware.GetCurrentUserID(c) if !exists { dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") return } - - var req dto.CreateTeamReq + var req CreateTeamReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - resp, err := producer.CreateTeam(&req, userID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.CreateTeam(c.Request.Context(), &req, userID) + if httpx.HandleServiceError(c, err) { return } - dto.JSONResponse(c, http.StatusCreated, "Team created successfully", resp) } @@ -73,19 +76,15 @@ func CreateTeam(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "Team not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/teams/{team_id} [delete] -func DeleteTeam(c *gin.Context) { - teamIDStr := c.Param(consts.URLPathTeamID) - teamID, err := strconv.Atoi(teamIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid team ID") +// @x-api-type {"portal":"true"} +func (h *Handler) DeleteTeam(c *gin.Context) { + teamID, ok := parseTeamID(c) + if !ok { return } - - err = producer.DeleteTeam(teamID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.DeleteTeam(c.Request.Context(), teamID)) { return } - dto.JSONResponse[any](c, http.StatusNoContent, "Team deleted successfully", nil) } @@ -97,28 +96,24 @@ func DeleteTeam(c *gin.Context) { // @ID get_team_by_id // @Produce json // @Security BearerAuth -// @Param team_id path int true "Team ID" -// @Success 200 {object} dto.GenericResponse[dto.TeamDetailResp] "Team retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid team ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Team not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param team_id path int true "Team ID" +// @Success 200 {object} dto.GenericResponse[TeamDetailResp] "Team retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid team ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Team not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/teams/{team_id} [get] -// @x-api-type {"sdk":"true"} -func GetTeamDetail(c *gin.Context) { - teamIDStr := c.Param(consts.URLPathTeamID) - teamID, err := strconv.Atoi(teamIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid team ID") +// @x-api-type {"portal":"true"} +func (h *Handler) GetTeamDetail(c *gin.Context) { + teamID, ok := parseTeamID(c) + if !ok { return } - - resp, err := producer.GetTeamDetail(teamID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.GetTeamDetail(c.Request.Context(), teamID) + if httpx.HandleServiceError(c, err) { return } - dto.SuccessResponse(c, resp) } @@ -130,42 +125,36 @@ func GetTeamDetail(c *gin.Context) { // @ID list_teams // @Produce json // @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param is_public query bool false "Filter by public status" -// @Param status query consts.StatusType false "Filter by status" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.TeamResp]] "Teams retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Param is_public query bool false "Filter by public status" +// @Param status query consts.StatusType false "Filter by status" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[TeamResp]] "Teams retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/teams [get] -// @x-api-type {"sdk":"true"} -func ListTeams(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) ListTeams(c *gin.Context) { userID, exists := middleware.GetCurrentUserID(c) if !exists { dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") return } - - isAdmin := middleware.IsCurrentUserAdmin(c) - - var req dto.ListTeamReq + var req ListTeamReq if err := c.ShouldBindQuery(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - resp, err := producer.ListTeams(&req, userID, isAdmin) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ListTeams(c.Request.Context(), &req, userID, middleware.IsCurrentUserAdmin(c)) + if httpx.HandleServiceError(c, err) { return } - dto.SuccessResponse(c, resp) } @@ -178,44 +167,37 @@ func ListTeams(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param team_id path int true "Team ID" -// @Param request body dto.UpdateTeamReq true "Team update request" -// @Success 202 {object} dto.GenericResponse[dto.TeamResp] "Team updated successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid team ID or invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Team not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param team_id path int true "Team ID" +// @Param request body UpdateTeamReq true "Team update request" +// @Success 202 {object} dto.GenericResponse[TeamResp] "Team updated successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid team ID or invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Team not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/teams/{team_id} [patch] -func UpdateTeam(c *gin.Context) { - teamIDStr := c.Param(consts.URLPathTeamID) - teamID, err := strconv.Atoi(teamIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid team ID") +// @x-api-type {"portal":"true"} +func (h *Handler) UpdateTeam(c *gin.Context) { + teamID, ok := parseTeamID(c) + if !ok { return } - - var req dto.UpdateTeamReq + var req UpdateTeamReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - resp, err := producer.UpdateTeam(&req, teamID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.UpdateTeam(c.Request.Context(), &req, teamID) + if httpx.HandleServiceError(c, err) { return } - dto.JSONResponse(c, http.StatusAccepted, "Team updated successfully", resp) } -// ===================== Team-Project API ===================== - // ListTeamProjects lists all projects belonging to a team // // @Summary List team projects @@ -229,43 +211,35 @@ func UpdateTeam(c *gin.Context) { // @Param size query int false "Page size" default(20) // @Param is_public query bool false "Filter by public status" // @Param status query consts.StatusType false "Filter by status" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.ProjectResp]] "Projects retrieved successfully" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[TeamProjectItem]] "Projects retrieved successfully" // @Failure 400 {object} dto.GenericResponse[any] "Invalid team ID or request parameters" // @Failure 401 {object} dto.GenericResponse[any] "Authentication required" // @Failure 403 {object} dto.GenericResponse[any] "Permission denied" // @Failure 404 {object} dto.GenericResponse[any] "Team not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/teams/{team_id}/projects [get] -// @x-api-type {"sdk":"true"} -func ListTeamProjects(c *gin.Context) { - teamIDStr := c.Param(consts.URLPathTeamID) - teamID, err := strconv.Atoi(teamIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid team ID") +// @x-api-type {"portal":"true"} +func (h *Handler) ListTeamProjects(c *gin.Context) { + teamID, ok := parseTeamID(c) + if !ok { return } - - var req dto.ListProjectReq + var req TeamProjectListReq if err := c.ShouldBindQuery(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - resp, err := producer.ListTeamProjects(&req, teamID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ListTeamProjects(c.Request.Context(), &req, teamID) + if httpx.HandleServiceError(c, err) { return } - dto.SuccessResponse(c, resp) } -// ===================== Team-User API ===================== - // AddTeamMember adds a user to team // // @Summary Add member to team @@ -276,7 +250,7 @@ func ListTeamProjects(c *gin.Context) { // @Produce json // @Security BearerAuth // @Param team_id path int true "Team ID" -// @Param request body dto.AddTeamMemberReq true "Add member request" +// @Param request body AddTeamMemberReq true "Add member request" // @Success 201 {object} dto.GenericResponse[any] "Member added successfully" // @Failure 400 {object} dto.GenericResponse[any] "Invalid team ID or request format/parameters" // @Failure 401 {object} dto.GenericResponse[any] "Authentication required" @@ -285,30 +259,24 @@ func ListTeamProjects(c *gin.Context) { // @Failure 409 {object} dto.GenericResponse[any] "User already in team" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/teams/{team_id}/members [post] -func AddTeamMember(c *gin.Context) { - teamIDStr := c.Param(consts.URLPathTeamID) - teamID, err := strconv.Atoi(teamIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid team ID") +// @x-api-type {"portal":"true"} +func (h *Handler) AddTeamMember(c *gin.Context) { + teamID, ok := parseTeamID(c) + if !ok { return } - - var req dto.AddTeamMemberReq + var req AddTeamMemberReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - err = producer.AddTeamMember(&req, teamID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.AddMember(c.Request.Context(), &req, teamID)) { return } - dto.JSONResponse[any](c, http.StatusCreated, "Member added successfully", nil) } @@ -329,37 +297,28 @@ func AddTeamMember(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "Team or user not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/teams/{team_id}/members/{user_id} [delete] -func RemoveTeamMember(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) RemoveTeamMember(c *gin.Context) { currentUserID, exists := middleware.GetCurrentUserID(c) if !exists { dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") return } - - teamIDStr := c.Param(consts.URLPathTeamID) - teamID, err := strconv.Atoi(teamIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid team ID") + teamID, ok := parseTeamID(c) + if !ok { return } - - userIDStr := c.Param("user_id") - userID, err := strconv.Atoi(userIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") + userID, ok := parseIntParam(c, "user_id", "Invalid user ID") + if !ok { return } - if currentUserID == userID { dto.ErrorResponse(c, http.StatusBadRequest, "Cannot remove yourself from the team") return } - - err = producer.RemoveTeamMember(teamID, currentUserID, userID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.RemoveMember(c.Request.Context(), teamID, currentUserID, userID)) { return } - dto.JSONResponse[any](c, http.StatusNoContent, "Member removed successfully", nil) } @@ -374,7 +333,7 @@ func RemoveTeamMember(c *gin.Context) { // @Security BearerAuth // @Param team_id path int true "Team ID" // @Param user_id path int true "User ID" -// @Param request body dto.UpdateTeamMemberRoleReq true "Update role request" +// @Param request body UpdateTeamMemberRoleReq true "Update role request" // @Success 200 {object} dto.GenericResponse[any] "Role updated successfully" // @Failure 400 {object} dto.GenericResponse[any] "Invalid team ID, user ID, or request format/parameters" // @Failure 401 {object} dto.GenericResponse[any] "Authentication required" @@ -382,43 +341,33 @@ func RemoveTeamMember(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "Team, user, or role not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/teams/{team_id}/members/{user_id}/role [patch] -func UpdateTeamMemberRole(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) UpdateTeamMemberRole(c *gin.Context) { currentUserID, exists := middleware.GetCurrentUserID(c) if !exists { dto.ErrorResponse(c, http.StatusUnauthorized, "Authentication required") return } - - teamIDStr := c.Param(consts.URLPathTeamID) - teamID, err := strconv.Atoi(teamIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid team ID") + teamID, ok := parseTeamID(c) + if !ok { return } - - userIDStr := c.Param("user_id") - userID, err := strconv.Atoi(userIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") + userID, ok := parseIntParam(c, "user_id", "Invalid user ID") + if !ok { return } - - var req dto.UpdateTeamMemberRoleReq + var req UpdateTeamMemberRoleReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - err = producer.UpdateTeamMemberRole(&req, teamID, userID, currentUserID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.UpdateMemberRole(c.Request.Context(), &req, teamID, userID, currentUserID)) { return } - dto.JSONResponse[any](c, http.StatusOK, "Role updated successfully", nil) } @@ -430,40 +379,48 @@ func UpdateTeamMemberRole(c *gin.Context) { // @ID list_team_members // @Produce json // @Security BearerAuth -// @Param team_id path int true "Team ID" -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.TeamMemberResp]] "Members retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid team ID or request parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Team not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param team_id path int true "Team ID" +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Success 200 {object} dto.GenericResponse[dto.ListResp[TeamMemberResp]] "Members retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid team ID or request parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Team not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/teams/{team_id}/members [get] -// @x-api-type {"sdk":"true"} -func ListTeamMembers(c *gin.Context) { - teamIDStr := c.Param(consts.URLPathTeamID) - teamID, err := strconv.Atoi(teamIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid team ID") +// @x-api-type {"portal":"true"} +func (h *Handler) ListTeamMembers(c *gin.Context) { + teamID, ok := parseTeamID(c) + if !ok { return } - - var req dto.ListTeamMemberReq + var req ListTeamMemberReq if err := c.ShouldBindQuery(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - resp, err := producer.ListTeamMembers(&req, teamID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ListMembers(c.Request.Context(), &req, teamID) + if httpx.HandleServiceError(c, err) { return } - dto.SuccessResponse(c, resp) } + +func parseTeamID(c *gin.Context) (int, bool) { + return parseIntParam(c, consts.URLPathTeamID, "Invalid team ID") +} + +func parseIntParam(c *gin.Context, key, msg string) (int, bool) { + v := c.Param(key) + id, err := strconv.Atoi(v) + if err != nil || id <= 0 { + dto.ErrorResponse(c, http.StatusBadRequest, msg) + return 0, false + } + return id, true +} diff --git a/src/module/team/handler_service.go b/src/module/team/handler_service.go new file mode 100644 index 00000000..cbfc3d22 --- /dev/null +++ b/src/module/team/handler_service.go @@ -0,0 +1,25 @@ +package team + +import ( + "context" + + "aegis/dto" +) + +// HandlerService captures the team operations consumed by the HTTP handler. +type HandlerService interface { + CreateTeam(context.Context, *CreateTeamReq, int) (*TeamResp, error) + DeleteTeam(context.Context, int) error + GetTeamDetail(context.Context, int) (*TeamDetailResp, error) + ListTeams(context.Context, *ListTeamReq, int, bool) (*dto.ListResp[TeamResp], error) + UpdateTeam(context.Context, *UpdateTeamReq, int) (*TeamResp, error) + ListTeamProjects(context.Context, *TeamProjectListReq, int) (*dto.ListResp[TeamProjectItem], error) + AddMember(context.Context, *AddTeamMemberReq, int) error + RemoveMember(context.Context, int, int, int) error + UpdateMemberRole(context.Context, *UpdateTeamMemberRoleReq, int, int, int) error + ListMembers(context.Context, *ListTeamMemberReq, int) (*dto.ListResp[TeamMemberResp], error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/team/module.go b/src/module/team/module.go new file mode 100644 index 00000000..af60d319 --- /dev/null +++ b/src/module/team/module.go @@ -0,0 +1,11 @@ +package team + +import "go.uber.org/fx" + +var Module = fx.Module("team", + fx.Provide(NewRepository), + fx.Provide(newProjectReader), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/module/team/project_reader.go b/src/module/team/project_reader.go new file mode 100644 index 00000000..7d822749 --- /dev/null +++ b/src/module/team/project_reader.go @@ -0,0 +1,127 @@ +package team + +import ( + "context" + "fmt" + + "aegis/consts" + "aegis/dto" + "aegis/internalclient/resourceclient" + "aegis/model" + project "aegis/module/project" + + "go.uber.org/fx" +) + +type projectReader interface { + CountProjects(context.Context, int) (int, error) + ListProjects(context.Context, *TeamProjectListReq, int) (*dto.ListResp[TeamProjectItem], error) +} + +type projectReaderParams struct { + fx.In + + Repository *Repository + Resource *resourceclient.Client `optional:"true"` +} + +type projectReaderAdapter struct { + repo *Repository + resource *resourceclient.Client + requireRemote bool +} + +func newProjectReader(params projectReaderParams) projectReader { + return projectReaderAdapter{ + repo: params.Repository, + resource: params.Resource, + } +} + +func newRemoteProjectReader(params projectReaderParams) projectReader { + return projectReaderAdapter{ + repo: params.Repository, + resource: params.Resource, + requireRemote: true, + } +} + +func (r projectReaderAdapter) CountProjects(ctx context.Context, teamID int) (int, error) { + if r.resource != nil && r.resource.Enabled() { + includeStatistics := false + resp, err := r.resource.ListProjects(ctx, &project.ListProjectReq{ + PaginationReq: dto.PaginationReq{Page: 1, Size: 10}, + TeamID: &teamID, + IncludeStatistics: &includeStatistics, + }) + if err != nil { + return 0, fmt.Errorf("list team projects via resource-service: %w", err) + } + if resp.Pagination == nil { + return len(resp.Items), nil + } + return int(resp.Pagination.Total), nil + } + if r.requireRemote { + return 0, fmt.Errorf("resource-service project reader is not configured") + } + + var projectCount int64 + if err := r.repo.db.Model(&model.Project{}). + Where("team_id = ? AND status != ?", teamID, consts.CommonDeleted). + Count(&projectCount).Error; err != nil { + return 0, fmt.Errorf("failed to get team project count: %w", err) + } + return int(projectCount), nil +} + +func (r projectReaderAdapter) ListProjects(ctx context.Context, req *TeamProjectListReq, teamID int) (*dto.ListResp[TeamProjectItem], error) { + if r.resource != nil && r.resource.Enabled() { + if req == nil { + req = &TeamProjectListReq{} + } + + resourceReq := *req + resourceReq.TeamID = &teamID + resp, err := r.resource.ListProjects(ctx, &resourceReq) + if err != nil { + return nil, fmt.Errorf("list team projects via resource-service: %w", err) + } + + items := make([]TeamProjectItem, len(resp.Items)) + copy(items, resp.Items) + return &dto.ListResp[TeamProjectItem]{ + Items: items, + Pagination: resp.Pagination, + }, nil + } + if r.requireRemote { + return nil, fmt.Errorf("resource-service project reader is not configured") + } + + if req == nil { + req = &TeamProjectListReq{} + } + limit, offset := req.ToGormParams() + projects, statsMap, total, err := r.repo.listTeamProjectViews(teamID, limit, offset, req.IsPublic, req.Status) + if err != nil { + return nil, err + } + + items := make([]TeamProjectItem, 0, len(projects)) + for i := range projects { + items = append(items, *project.NewProjectResp(&projects[i], statsMap[projects[i].ID])) + } + + return &dto.ListResp[TeamProjectItem]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +// RemoteProjectReaderOption forces the dedicated iam-service path to use resource RPC only. +func RemoteProjectReaderOption() fx.Option { + return fx.Decorate(newRemoteProjectReader) +} + +var _ projectReader = (*projectReaderAdapter)(nil) diff --git a/src/module/team/repository.go b/src/module/team/repository.go new file mode 100644 index 00000000..2ad0a1bf --- /dev/null +++ b/src/module/team/repository.go @@ -0,0 +1,276 @@ +package team + +import ( + "aegis/consts" + "aegis/dto" + "aegis/model" + project "aegis/module/project" + "errors" + "fmt" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) createTeamWithCreator(team *model.Team, userID int) error { + var superAdminRole model.Role + if err := r.db.Where("name = ? AND status != ?", consts.RoleSuperAdmin.String(), consts.CommonDeleted). + First(&superAdminRole).Error; err != nil { + return fmt.Errorf("failed to get super_admin role: %w", err) + } + + if err := r.db.Omit("ActiveName").Create(team).Error; err != nil { + return fmt.Errorf("failed to create team: %w", err) + } + + if err := r.db.Omit("active_user_team").Create(&model.UserTeam{ + UserID: userID, + TeamID: team.ID, + RoleID: superAdminRole.ID, + Status: consts.CommonEnabled, + }).Error; err != nil { + return fmt.Errorf("failed to create user-team association: %w", err) + } + return nil +} + +func (r *Repository) loadTeamDetailBase(teamID int) (*model.Team, int, error) { + team, err := r.loadTeam(teamID) + if err != nil { + return nil, 0, err + } + + var userCount int64 + if err := r.db.Model(&model.UserTeam{}). + Where("team_id = ? AND status = ?", teamID, consts.CommonEnabled). + Count(&userCount).Error; err != nil { + return nil, 0, err + } + + return team, int(userCount), nil +} + +func (r *Repository) listVisibleTeams(limit, offset int, req *ListTeamReq, userID int, isAdmin bool) ([]model.Team, int64, error) { + var teamIDs []int + if !isAdmin { + if err := r.db.Model(&model.UserTeam{}). + Where("user_id = ? AND status = ?", userID, consts.CommonEnabled). + Pluck("team_id", &teamIDs).Error; err != nil { + return nil, 0, err + } + if len(teamIDs) == 0 { + return []model.Team{}, 0, nil + } + } + + var teams []model.Team + var total int64 + + query := r.db.Model(&model.Team{}) + if req.IsPublic != nil { + query = query.Where("is_public = ?", *req.IsPublic) + } + if req.Status != nil { + query = query.Where("status = ?", *req.Status) + } + if len(teamIDs) > 0 { + query = query.Where("id IN ?", teamIDs) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count teams: %w", err) + } + if err := query.Limit(limit).Offset(offset).Find(&teams).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list teams: %w", err) + } + return teams, total, nil +} + +func (r *Repository) updateMutableTeam(teamID int, patch func(*model.Team)) (*model.Team, error) { + team, err := r.loadTeam(teamID) + if err != nil { + return nil, err + } + patch(team) + if err := r.db.Omit("ActiveName").Save(team).Error; err != nil { + return nil, fmt.Errorf("failed to update team: %w", err) + } + return team, nil +} + +func (r *Repository) listTeamProjectViews(teamID, limit, offset int, isPublic *bool, status *consts.StatusType) ([]model.Project, map[int]*dto.ProjectStatistics, int64, error) { + var ( + projects []model.Project + total int64 + ) + + query := r.db.Model(&model.Project{}).Where("team_id = ? AND status != ?", teamID, consts.CommonDeleted) + if isPublic != nil { + query = query.Where("is_public = ?", *isPublic) + } + if status != nil { + query = query.Where("status = ?", *status) + } + + if err := query.Count(&total).Error; err != nil { + return nil, nil, 0, fmt.Errorf("failed to count projects for team %d: %w", teamID, err) + } + if err := query.Limit(limit).Offset(offset).Find(&projects).Error; err != nil { + return nil, nil, 0, fmt.Errorf("failed to list projects for team %d: %w", teamID, err) + } + + projectIDs := make([]int, 0, len(projects)) + for _, project := range projects { + projectIDs = append(projectIDs, project.ID) + } + + statsMap, err := project.NewRepository(r.db).ListProjectStatistics(projectIDs) + if err != nil { + return nil, nil, 0, err + } + return projects, statsMap, total, nil +} + +func (r *Repository) addMember(teamID int, username string, roleID int) error { + if _, err := r.loadTeam(teamID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return consts.ErrNotFound + } + return err + } + + var user model.User + if err := r.db.Where("username = ?", username).First(&user).Error; err != nil { + return fmt.Errorf("failed to find user with username %s: %w", username, err) + } + var role model.Role + if err := r.db.Where("id = ? AND status != ?", roleID, consts.CommonDeleted).First(&role).Error; err != nil { + return fmt.Errorf("failed to find role with id %d: %w", roleID, err) + } + + if err := r.db.Omit("active_user_team").Create(&model.UserTeam{ + UserID: user.ID, + TeamID: teamID, + RoleID: roleID, + Status: consts.CommonEnabled, + }).Error; err != nil { + return fmt.Errorf("failed to create user-team association: %w", err) + } + return nil +} + +func (r *Repository) removeMember(teamID, userID int) (int64, error) { + if _, err := r.loadTeam(teamID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, consts.ErrNotFound + } + return 0, err + } + + result := r.db.Model(&model.UserTeam{}). + Where("user_id = ? AND team_id = ? AND status != ?", userID, teamID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if result.Error != nil { + return 0, fmt.Errorf("failed to delete user-team association: %w", result.Error) + } + return result.RowsAffected, nil +} + +func (r *Repository) updateMemberRole(teamID, targetUserID, roleID int) error { + if _, err := r.loadTeam(teamID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return consts.ErrNotFound + } + return err + } + var role model.Role + if err := r.db.Where("id = ? AND status != ?", roleID, consts.CommonDeleted).First(&role).Error; err != nil { + return fmt.Errorf("failed to find role with id %d: %w", roleID, err) + } + + var userTeam model.UserTeam + if err := r.db.Preload("Role"). + Where("user_id = ? AND team_id = ? AND status = ?", targetUserID, teamID, consts.CommonEnabled). + First(&userTeam).Error; err != nil { + return err + } + userTeam.RoleID = roleID + return r.db.Save(&userTeam).Error +} + +func (r *Repository) listTeamMembers(teamID, limit, offset int) ([]TeamMemberResp, int64, error) { + if _, err := r.loadTeam(teamID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, 0, consts.ErrNotFound + } + return nil, 0, err + } + + var members []TeamMemberResp + var total int64 + + query := r.db.Table("users"). + Joins("JOIN user_teams ON users.id = user_teams.user_id"). + Joins("LEFT JOIN roles ON roles.id = user_teams.role_id"). + Where("user_teams.team_id = ? AND user_teams.status = ? AND users.status != ?", teamID, consts.CommonEnabled, consts.CommonDeleted) + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count team members for team %d: %w", teamID, err) + } + if err := query.Select( + "users.id AS user_id", + "users.username", + "users.full_name", + "users.email", + "user_teams.role_id", + "roles.display_name AS role_name", + "user_teams.created_at AS joined_at", + ).Limit(limit).Offset(offset).Scan(&members).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list team members for team %d: %w", teamID, err) + } + return members, total, nil +} + +func (r *Repository) loadUserTeamMembership(userID, teamID int) (*model.UserTeam, error) { + var userTeam model.UserTeam + if err := r.db. + Preload("Role"). + Where("user_id = ? AND team_id = ? AND status = ?", userID, teamID, consts.CommonEnabled). + First(&userTeam).Error; err != nil { + return nil, err + } + return &userTeam, nil +} + +func (r *Repository) deleteTeam(teamID int) (int64, error) { + result := r.db.Model(&model.Team{}). + Where("id = ? AND status != ?", teamID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if result.Error != nil { + return 0, fmt.Errorf("failed to soft delete team %d: %w", teamID, result.Error) + } + return result.RowsAffected, nil +} + +func (r *Repository) isTeamPublic(teamID int) (bool, error) { + team, err := r.loadTeam(teamID) + if err != nil { + return false, err + } + return team.IsPublic, nil +} + +func (r *Repository) loadTeam(teamID int) (*model.Team, error) { + var team model.Team + if err := r.db.Where("id = ?", teamID).First(&team).Error; err != nil { + return nil, fmt.Errorf("failed to find team with id %d: %w", teamID, err) + } + return &team, nil +} diff --git a/src/module/team/service.go b/src/module/team/service.go new file mode 100644 index 00000000..27ae902d --- /dev/null +++ b/src/module/team/service.go @@ -0,0 +1,206 @@ +package team + +import ( + "context" + "errors" + "fmt" + + "aegis/consts" + "aegis/dto" + "aegis/model" + + "gorm.io/gorm" +) + +type Service struct { + repo *Repository + projects projectReader +} + +func NewService(repo *Repository, projects projectReader) *Service { + return &Service{ + repo: repo, + projects: projects, + } +} + +func (s *Service) CreateTeam(_ context.Context, req *CreateTeamReq, userID int) (*TeamResp, error) { + team := req.ConvertToTeam() + + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + if err := NewRepository(tx).createTeamWithCreator(team, userID); err != nil { + if errors.Is(err, consts.ErrAlreadyExists) { + return consts.ErrAlreadyExists + } + return err + } + return nil + }) + if err != nil { + return nil, err + } + + return NewTeamResp(team), nil +} + +func (s *Service) DeleteTeam(_ context.Context, teamID int) error { + rowsAffected, err := s.repo.deleteTeam(teamID) + if err != nil { + return err + } + if rowsAffected == 0 { + return consts.ErrNotFound + } + return nil +} + +func (s *Service) GetTeamDetail(ctx context.Context, teamID int) (*TeamDetailResp, error) { + team, userCount, err := s.repo.loadTeamDetailBase(teamID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, consts.ErrNotFound + } + return nil, err + } + projectCount, err := s.projects.CountProjects(ctx, teamID) + if err != nil { + return nil, err + } + + resp := NewTeamDetailResp(team) + resp.UserCount = userCount + resp.ProjectCount = projectCount + + return resp, nil +} + +func (s *Service) ListTeams(_ context.Context, req *ListTeamReq, userID int, isAdmin bool) (*dto.ListResp[TeamResp], error) { + limit, offset := req.ToGormParams() + teams, total, err := s.repo.listVisibleTeams(limit, offset, req, userID, isAdmin) + if err != nil { + return nil, err + } + + items := make([]TeamResp, len(teams)) + for i, team := range teams { + items[i] = *NewTeamResp(&team) + } + + return &dto.ListResp[TeamResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) UpdateTeam(_ context.Context, req *UpdateTeamReq, teamID int) (*TeamResp, error) { + team, err := s.repo.updateMutableTeam(teamID, func(team *model.Team) { + req.PatchTeamModel(team) + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, consts.ErrNotFound + } + return nil, err + } + + return NewTeamResp(team), nil +} + +func (s *Service) ListTeamProjects(ctx context.Context, req *TeamProjectListReq, teamID int) (*dto.ListResp[TeamProjectItem], error) { + return s.projects.ListProjects(ctx, req, teamID) +} + +func (s *Service) AddMember(_ context.Context, req *AddTeamMemberReq, teamID int) error { + if err := s.repo.addMember(teamID, req.Username, req.RoleID); err != nil { + if errors.Is(err, consts.ErrNotFound) { + return consts.ErrNotFound + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("user or role not found") + } + if errors.Is(err, consts.ErrAlreadyExists) { + return consts.ErrAlreadyExists + } + return err + } + return nil +} + +func (s *Service) RemoveMember(_ context.Context, teamID, currentUserID, targetUserID int) error { + if targetUserID == currentUserID { + return fmt.Errorf("cannot remove yourself from the team") + } + + rowsAffected, err := s.repo.removeMember(teamID, targetUserID) + if err != nil { + if errors.Is(err, consts.ErrNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + return consts.ErrNotFound + } + return err + } + if rowsAffected == 0 { + return fmt.Errorf("user is not a member of this team") + } + return nil +} + +func (s *Service) UpdateMemberRole(_ context.Context, req *UpdateTeamMemberRoleReq, teamID, targetUserID, currentUserID int) error { + _ = currentUserID + + if err := s.repo.updateMemberRole(teamID, targetUserID, req.RoleID); err != nil { + if errors.Is(err, consts.ErrNotFound) { + return consts.ErrNotFound + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("role not found") + } + return err + } + return nil +} + +func (s *Service) ListMembers(_ context.Context, req *ListTeamMemberReq, teamID int) (*dto.ListResp[TeamMemberResp], error) { + limit, offset := req.ToGormParams() + members, total, err := s.repo.listTeamMembers(teamID, limit, offset) + if err != nil { + return nil, err + } + + return &dto.ListResp[TeamMemberResp]{ + Items: members, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) IsUserInTeam(userID, teamID int) (bool, error) { + ut, err := s.repo.loadUserTeamMembership(userID, teamID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + return ut != nil, nil +} + +func (s *Service) IsUserTeamAdmin(userID, teamID int) (bool, error) { + ut, err := s.repo.loadUserTeamMembership(userID, teamID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + return ut != nil && ut.Role != nil && ut.Role.Name == consts.RoleTeamAdmin.String(), nil +} + +func (s *Service) IsTeamPublic(teamID int) (bool, error) { + isPublic, err := s.repo.isTeamPublic(teamID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + return isPublic, nil +} diff --git a/src/module/team/service_test.go b/src/module/team/service_test.go new file mode 100644 index 00000000..d7dbadf3 --- /dev/null +++ b/src/module/team/service_test.go @@ -0,0 +1,75 @@ +package team + +import ( + "context" + "regexp" + "testing" + "time" + + "aegis/consts" + "aegis/dto" + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +type stubProjectReader struct{} + +func (stubProjectReader) CountProjects(context.Context, int) (int, error) { + return 0, nil +} + +func (stubProjectReader) ListProjects(context.Context, *TeamProjectListReq, int) (*dto.ListResp[TeamProjectItem], error) { + return &dto.ListResp[TeamProjectItem]{Items: []TeamProjectItem{}}, nil +} + +func newTeamService(t *testing.T) (*Service, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New() + require.NoError(t, err) + + db, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + require.NoError(t, err) + + return NewService(NewRepository(db), stubProjectReader{}), mock, func() { + _ = sqlDB.Close() + } +} + +func TestTeamServiceListTeamsSuccess(t *testing.T) { + service, mock, cleanup := newTeamService(t) + defer cleanup() + + now := time.Now() + status := consts.CommonEnabled + + mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM `teams` WHERE status = ?")). + WithArgs(status). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `teams` WHERE status = ? LIMIT ?")). + WithArgs(status, 20). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "description", "is_public", "status", "created_at", "updated_at", + }).AddRow(1, "platform", "platform team", true, consts.CommonEnabled, now, now)) + + resp, err := service.ListTeams(t.Context(), &ListTeamReq{Status: &status}, 1, true) + + require.NoError(t, err) + require.Len(t, resp.Items, 1) + require.Equal(t, "platform", resp.Items[0].Name) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestTeamServiceRemoveMemberSelfRejected(t *testing.T) { + service := NewService(nil, stubProjectReader{}) + + err := service.RemoveMember(t.Context(), 1, 7, 7) + + require.Error(t, err) + require.ErrorContains(t, err, "cannot remove yourself from the team") +} diff --git a/src/module/trace/api_types.go b/src/module/trace/api_types.go new file mode 100644 index 00000000..3069f466 --- /dev/null +++ b/src/module/trace/api_types.go @@ -0,0 +1,161 @@ +package trace + +import ( + "fmt" + "time" + + "aegis/consts" + "aegis/dto" + "aegis/model" + task "aegis/module/task" + "aegis/utils" +) + +type GetTraceStreamReq struct { + LastID string `form:"last_id" binding:"omitempty"` +} + +func (req *GetTraceStreamReq) Validate() error { + if req.LastID == "" { + req.LastID = "0" + } + if req.LastID == "0" { + return nil + } + if len(req.LastID) < 3 || req.LastID[0] == '-' || req.LastID[len(req.LastID)-1] == '-' { + return fmt.Errorf("invalid last_id format: must be '0' or a valid stream ID (e.g., 1678886400000-0)") + } + dashCount := 0 + for _, ch := range req.LastID { + if ch == '-' { + dashCount++ + } + } + if dashCount != 1 { + return fmt.Errorf("invalid last_id format: must be '0' or a valid stream ID (e.g., 1678886400000-0)") + } + return nil +} + +type ListTraceFilters struct { + TraceType *consts.TraceType + GroupID string + ProjectID int + State *consts.TraceState + Status *consts.StatusType +} + +type ListTraceReq struct { + dto.PaginationReq + TraceType *consts.TraceType `form:"trace_type" binding:"omitempty"` + GroupID string `form:"group_id" binding:"omitempty"` + ProjectID int `form:"project_id" binding:"omitempty"` + State *consts.TraceState `form:"state" binding:"omitempty"` + Status *consts.StatusType `form:"status" binding:"omitempty"` +} + +func (req *ListTraceReq) Validate() error { + if err := req.PaginationReq.Validate(); err != nil { + return err + } + if req.TraceType != nil { + if _, exists := consts.ValidTraceTypes[*req.TraceType]; !exists { + return fmt.Errorf("invalid trace type: %d", *req.TraceType) + } + } + if err := validateUUID(req.GroupID); err != nil { + return err + } + if req.ProjectID < 0 { + return fmt.Errorf("invalid project ID: %d", req.ProjectID) + } + if req.State != nil { + if _, exists := consts.ValidTraceStates[*req.State]; !exists { + return fmt.Errorf("invalid trace state: %d", *req.State) + } + } + return validateStatus(req.Status) +} + +func (req *ListTraceReq) ToFilterOptions() *ListTraceFilters { + return &ListTraceFilters{ + TraceType: req.TraceType, + GroupID: req.GroupID, + ProjectID: req.ProjectID, + State: req.State, + Status: req.Status, + } +} + +type TraceResp struct { + ID string `json:"id"` + Type string `json:"type"` + LastEvent string `json:"last_event"` + StartTime time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time,omitempty"` + GroupID string `json:"group_id"` + ProjectID int `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + LeafNum int `json:"leaf_num"` + State string `json:"state"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func NewTraceResp(trace *model.Trace) *TraceResp { + resp := &TraceResp{ + ID: trace.ID, + Type: consts.GetTraceTypeName(trace.Type), + LastEvent: trace.LastEvent.String(), + StartTime: trace.StartTime, + EndTime: trace.EndTime, + GroupID: trace.GroupID, + ProjectID: trace.ProjectID, + LeafNum: trace.LeafNum, + State: consts.GetTraceStateName(trace.State), + Status: consts.GetStatusTypeName(trace.Status), + CreatedAt: trace.CreatedAt, + UpdatedAt: trace.UpdatedAt, + } + if trace.Project != nil { + resp.ProjectName = trace.Project.Name + } + return resp +} + +type TraceDetailResp struct { + TraceResp + + Tasks []task.TaskResp `json:"tasks"` +} + +func NewTraceDetailResp(trace *model.Trace) *TraceDetailResp { + resp := &TraceDetailResp{ + TraceResp: *NewTraceResp(trace), + Tasks: make([]task.TaskResp, 0, len(trace.Tasks)), + } + for i := range trace.Tasks { + resp.Tasks = append(resp.Tasks, *task.NewTaskResp(&trace.Tasks[i])) + } + return resp +} + +func validateUUID(id string) error { + if id == "" { + return nil + } + if !utils.IsValidUUID(id) { + return fmt.Errorf("invalid UUID format: %s", id) + } + return nil +} + +func validateStatus(status *consts.StatusType) error { + if status != nil { + if _, exists := consts.ValidStatuses[*status]; !exists { + return fmt.Errorf("invalid status value: %d", *status) + } + } + return nil +} diff --git a/src/handlers/v2/traces.go b/src/module/trace/handler.go similarity index 60% rename from src/handlers/v2/traces.go rename to src/module/trace/handler.go index d05e4062..728763a6 100644 --- a/src/handlers/v2/traces.go +++ b/src/module/trace/handler.go @@ -1,23 +1,31 @@ -package v2 +package trace import ( - "aegis/consts" - "aegis/dto" - "aegis/handlers" - producer "aegis/service/producer" - "aegis/utils" + "aegis/httpx" "context" "errors" "fmt" "net/http" "time" + "aegis/consts" + "aegis/dto" + "aegis/utils" + "github.com/gin-contrib/sse" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" "github.com/sirupsen/logrus" ) +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + // GetTrace handles getting a single trace by ID // // @Summary Get trace by ID @@ -26,24 +34,24 @@ import ( // @ID get_trace_by_id // @Produce json // @Security BearerAuth -// @Param trace_id path string true "Trace ID" -// @Success 200 {object} dto.GenericResponse[dto.TraceDetailResp] "Trace retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid trace ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Trace not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param trace_id path string true "Trace ID" +// @Success 200 {object} dto.GenericResponse[TraceDetailResp] "Trace retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid trace ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "Trace not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/traces/{trace_id} [get] -// @x-api-type {"sdk":"true"} -func GetTrace(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) GetTrace(c *gin.Context) { traceID := c.Param(consts.URLPathTraceID) if !utils.IsValidUUID(traceID) { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid trace ID") return } - resp, err := producer.GetTraceDetail(traceID) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.GetTrace(c.Request.Context(), traceID) + if httpx.HandleServiceError(c, err) { return } @@ -58,22 +66,22 @@ func GetTrace(c *gin.Context) { // @ID list_traces // @Produce json // @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param trace_type query consts.TraceType false "Filter by trace type" -// @Param group_id query string false "Filter by group ID (uuid format)" -// @Param project_id query int false "Filter by project ID" -// @Param state query consts.TraceState false "Filter by state" -// @Param status query consts.StatusType false "Filter by status" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.TraceResp]] "Traces retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Param trace_type query consts.TraceType false "Filter by trace type" +// @Param group_id query string false "Filter by group ID (uuid format)" +// @Param project_id query int false "Filter by project ID" +// @Param state query consts.TraceState false "Filter by state" +// @Param status query consts.StatusType false "Filter by status" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[TraceResp]] "Traces retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/traces [get] -// @x-api-type {"sdk":"true"} -func ListTraces(c *gin.Context) { - var req dto.ListTraceReq +// @x-api-type {"portal":"true"} +func (h *Handler) ListTraces(c *gin.Context) { + var req ListTraceReq if err := c.ShouldBindQuery(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -84,8 +92,8 @@ func ListTraces(c *gin.Context) { return } - resp, err := producer.ListTraces(&req) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ListTraces(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { return } @@ -108,18 +116,18 @@ func ListTraces(c *gin.Context) { // @Failure 403 {object} dto.GenericResponse[any] "Permission denied" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/traces/{trace_id}/stream [get] -// @x-api-type {"sdk":"true"} // @x-request-type {"stream":"true"} -func GetTraceStream(c *gin.Context) { +// @x-api-type {"portal":"true"} +func (h *Handler) GetTraceStream(c *gin.Context) { traceID := c.Param(consts.URLPathTraceID) if !utils.IsValidUUID(traceID) { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid trace ID") return } - var req dto.GetTraceStreamReq + var req GetTraceStreamReq if err := c.ShouldBindQuery(&req); err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format") + dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } @@ -141,15 +149,14 @@ func GetTraceStream(c *gin.Context) { "stream_key": streamKey, }) - processor, err := producer.GetTraceStreamProcessor(ctx, traceID) + processor, err := h.service.GetTraceStreamProcessor(ctx, traceID) if err != nil { logEntry.Errorf("Failed to initialize stream processor: %v", err) - dto.ErrorResponse(c, http.StatusInternalServerError, "Internal server error") + dto.ErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("Failed to initialize trace stream: %v", err)) return } - logEntry.Infof("Reading historical events from Stream") - historicalMessages, err := producer.ReadTraceStreamMessages(ctx, streamKey, req.LastID, 100, 0) + historicalMessages, err := h.service.ReadTraceStreamMessages(ctx, streamKey, req.LastID, 100, 0) if err != nil { logEntry.Errorf("failed to read historical events from redis: %v", err) dto.ErrorResponse(c, http.StatusInternalServerError, "Failed to read event history") @@ -157,7 +164,7 @@ func GetTraceStream(c *gin.Context) { } if len(historicalMessages) > 0 { - lastID, completed, err := sendSSEEvents(c, processor, historicalMessages) + lastID, completed, err := sendTraceSSEEvents(c, processor, historicalMessages) if err != nil { logEntry.Errorf("failed to send historical stream events of ID %s: %v", req.LastID, err) dto.ErrorResponse(c, http.StatusInternalServerError, "Failed to send stream events") @@ -165,25 +172,20 @@ func GetTraceStream(c *gin.Context) { } if completed { - logEntry.Info("Trace completed during historical events, closing stream connection") return } req.LastID = lastID } - logEntry.Infof("Switching to real-time event monitoring from ID: %s", req.LastID) for { select { case <-c.Done(): - logEntry.Info("Request context done") return - default: - newMessages, err := producer.ReadTraceStreamMessages(ctx, streamKey, req.LastID, 10, time.Second) + newMessages, err := h.service.ReadTraceStreamMessages(ctx, streamKey, req.LastID, 10, time.Second) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - logEntry.Infof("Context done while reading stream: %v", err) return } @@ -193,11 +195,10 @@ func GetTraceStream(c *gin.Context) { } if len(newMessages) == 0 { - logEntry.Debug("No new messages, continuing") continue } - lastID, completed, err := sendSSEEvents(c, processor, newMessages) + lastID, completed, err := sendTraceSSEEvents(c, processor, newMessages) if err != nil { logEntry.Errorf("failed to send stream events of ID %s: %v", lastID, err) return @@ -205,18 +206,14 @@ func GetTraceStream(c *gin.Context) { req.LastID = lastID if completed { - logEntry.Info("Trace completed, closing stream connection") - time.Sleep(1 * time.Second) + time.Sleep(time.Second) return } - - logrus.Info("Sent SSE messages, lastID:", lastID) } } } -// sendSSEEvents processes and sends stream messages as SSE events -func sendSSEEvents(c *gin.Context, processor *producer.StreamProcessor, streams []redis.XStream) (string, bool, error) { +func sendTraceSSEEvents(c *gin.Context, processor *StreamProcessor, streams []redis.XStream) (string, bool, error) { if len(streams) == 0 || len(streams[0].Messages) == 0 { return "", false, fmt.Errorf("no messages to process") } diff --git a/src/module/trace/handler_service.go b/src/module/trace/handler_service.go new file mode 100644 index 00000000..998d7edc --- /dev/null +++ b/src/module/trace/handler_service.go @@ -0,0 +1,22 @@ +package trace + +import ( + "context" + "time" + + "aegis/dto" + + "github.com/redis/go-redis/v9" +) + +// HandlerService captures trace operations consumed by HTTP handlers and gateway adapters. +type HandlerService interface { + GetTrace(context.Context, string) (*TraceDetailResp, error) + ListTraces(context.Context, *ListTraceReq) (*dto.ListResp[TraceResp], error) + GetTraceStreamProcessor(context.Context, string) (*StreamProcessor, error) + ReadTraceStreamMessages(context.Context, string, string, int64, time.Duration) ([]redis.XStream, error) +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/trace/module.go b/src/module/trace/module.go new file mode 100644 index 00000000..0bfbfad5 --- /dev/null +++ b/src/module/trace/module.go @@ -0,0 +1,10 @@ +package trace + +import "go.uber.org/fx" + +var Module = fx.Module("trace", + fx.Provide(NewRepository), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/module/trace/repository.go b/src/module/trace/repository.go new file mode 100644 index 00000000..4e32c047 --- /dev/null +++ b/src/module/trace/repository.go @@ -0,0 +1,63 @@ +package trace + +import ( + "aegis/consts" + "aegis/model" + "fmt" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) GetTraceByID(traceID string) (*model.Trace, error) { + var trace model.Trace + if err := r.db.Model(&model.Trace{}). + Preload("Project"). + Preload("Tasks", func(db *gorm.DB) *gorm.DB { + return db.Order("level ASC, sequence ASC") + }). + Where("id = ? AND status != ?", traceID, consts.CommonDeleted). + First(&trace).Error; err != nil { + return nil, err + } + return &trace, nil +} + +func (r *Repository) ListTraces(limit, offset int, filterOptions *ListTraceFilters) ([]model.Trace, int64, error) { + var ( + traces []model.Trace + total int64 + ) + + query := r.db.Model(&model.Trace{}).Preload("Project") + if filterOptions.TraceType != nil { + query = query.Where("type = ?", *filterOptions.TraceType) + } + if filterOptions.GroupID != "" { + query = query.Where("group_id = ?", filterOptions.GroupID) + } + if filterOptions.ProjectID > 0 { + query = query.Where("project_id = ?", filterOptions.ProjectID) + } + if filterOptions.State != nil { + query = query.Where("state = ?", *filterOptions.State) + } + if filterOptions.Status != nil { + query = query.Where("status = ?", *filterOptions.Status) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count traces: %w", err) + } + if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&traces).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list traces: %w", err) + } + return traces, total, nil +} diff --git a/src/module/trace/service.go b/src/module/trace/service.go new file mode 100644 index 00000000..6af2c1d3 --- /dev/null +++ b/src/module/trace/service.go @@ -0,0 +1,97 @@ +package trace + +import ( + "context" + "fmt" + "time" + + "aegis/config" + "aegis/consts" + "aegis/dto" + redisinfra "aegis/infra/redis" + + goredis "github.com/redis/go-redis/v9" +) + +type Service struct { + repo *Repository + redis *redisinfra.Gateway +} + +func NewService(repo *Repository, redis *redisinfra.Gateway) *Service { + return &Service{repo: repo, redis: redis} +} + +func (s *Service) GetTrace(_ context.Context, traceID string) (*TraceDetailResp, error) { + trace, err := s.repo.GetTraceByID(traceID) + if err != nil { + return nil, fmt.Errorf("failed to get trace: %w", err) + } + return NewTraceDetailResp(trace), nil +} + +func (s *Service) ListTraces(_ context.Context, req *ListTraceReq) (*dto.ListResp[TraceResp], error) { + if req == nil { + return nil, fmt.Errorf("list traces request is nil") + } + limit, offset := req.ToGormParams() + filterOptions := req.ToFilterOptions() + traces, total, err := s.repo.ListTraces(limit, offset, filterOptions) + if err != nil { + return nil, fmt.Errorf("failed to list traces: %w", err) + } + items := make([]TraceResp, 0, len(traces)) + for i := range traces { + items = append(items, *NewTraceResp(&traces[i])) + } + return &dto.ListResp[TraceResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) GetTraceStreamProcessor(ctx context.Context, traceID string) (*StreamProcessor, error) { + algorithms, err := s.GetTraceStreamAlgorithms(ctx, traceID) + if err != nil { + return nil, err + } + return NewStreamProcessor(algorithms), nil +} + +func (s *Service) GetTraceStreamAlgorithms(ctx context.Context, traceID string) ([]dto.ContainerVersionItem, error) { + trace, err := s.repo.GetTraceByID(traceID) + if err != nil { + return nil, fmt.Errorf("failed to fetch trace: %w", err) + } + + var algorithms []dto.ContainerVersionItem + if trace.Type == consts.TraceTypeFullPipeline && s.redis.CheckCachedField(ctx, consts.InjectionAlgorithmsKey, trace.GroupID) { + if err := s.redis.GetHashField(ctx, consts.InjectionAlgorithmsKey, trace.GroupID, &algorithms); err != nil { + return nil, fmt.Errorf("failed to get algorithms from Redis: %w", err) + } + } + + if len(algorithms) == 0 { + return nil, nil + } + + filtered := algorithms[:0] + for _, algorithm := range algorithms { + if algorithm.ContainerName != config.GetDetectorName() { + filtered = append(filtered, algorithm) + } + } + return filtered, nil +} + +func (s *Service) ReadTraceStreamMessages(ctx context.Context, streamKey, lastID string, count int64, block time.Duration) ([]goredis.XStream, error) { + if lastID == "" { + lastID = "0" + } + + messages, err := s.redis.XRead(ctx, []string{streamKey, lastID}, count, block) + if err != nil { + return nil, fmt.Errorf("failed to read stream messages: %w", err) + } + return messages, nil +} diff --git a/src/module/trace/stream.go b/src/module/trace/stream.go new file mode 100644 index 00000000..c4ab77f3 --- /dev/null +++ b/src/module/trace/stream.go @@ -0,0 +1,175 @@ +package trace + +import ( + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + + "aegis/consts" + "aegis/dto" + + "github.com/redis/go-redis/v9" +) + +var payloadTypeRegistry = map[consts.EventType]reflect.Type{ + consts.EventAlgoRunStarted: reflect.TypeFor[dto.ExecutionInfo](), + consts.EventAlgoRunSucceed: reflect.TypeFor[dto.ExecutionResult](), + consts.EventAlgoRunFailed: reflect.TypeFor[dto.ExecutionResult](), + consts.EventDatapackBuildStarted: reflect.TypeFor[dto.DatapackInfo](), + consts.EventDatapackBuildSucceed: reflect.TypeFor[dto.DatapackResult](), + consts.EventDatapackBuildFailed: reflect.TypeFor[dto.DatapackResult](), + consts.EventJobSucceed: reflect.TypeFor[dto.JobMessage](), + consts.EventJobFailed: reflect.TypeFor[dto.JobMessage](), +} + +type StreamProcessor struct { + isCompleted bool + algorithmMap map[string]struct{} + finishedCount int +} + +func NewStreamProcessor(algorithms []dto.ContainerVersionItem) *StreamProcessor { + algorithmMap := make(map[string]struct{}, len(algorithms)) + for _, algorithm := range algorithms { + algorithmMap[algorithm.ContainerName] = struct{}{} + } + + return &StreamProcessor{ + isCompleted: false, + algorithmMap: algorithmMap, + finishedCount: 0, + } +} + +func (sp *StreamProcessor) IsCompleted() bool { + return sp.isCompleted +} + +func (sp *StreamProcessor) ProcessMessageForSSE(msg redis.XMessage) (string, *dto.TraceStreamEvent, error) { + streamEvent, err := parseStreamEvent(msg.ID, msg.Values) + if err != nil { + return "", nil, fmt.Errorf("failed to parse stream message value: %v", err) + } + + switch streamEvent.EventName { + case consts.EventImageBuildSucceed, consts.EventRestartPedestalFailed, consts.EventFaultInjectionFailed, consts.EventDatapackBuildFailed, consts.EventDatapackNoAnomaly, consts.EventDatapackNoDetectorData: + sp.isCompleted = true + case consts.EventDatapackResultCollection: + sp.isCompleted = len(sp.algorithmMap) == 0 + case consts.EventAlgoResultCollection, consts.EventAlgoRunFailed: + payload, ok := streamEvent.Payload.(*dto.ExecutionResult) + if !ok { + return "", nil, fmt.Errorf("invalid payload type for task status update event: %T", streamEvent.Payload) + } + + if len(sp.algorithmMap) == 0 { + sp.isCompleted = true + break + } + if _, exists := sp.algorithmMap[payload.Algorithm]; exists { + sp.finishedCount++ + if sp.finishedCount >= len(sp.algorithmMap) { + sp.isCompleted = true + } + } + } + + return msg.ID, streamEvent, nil +} + +func parseStreamEvent(id string, values map[string]any) (*dto.TraceStreamEvent, error) { + message := "missing or invalid key %s in redis stream message values" + + taskID, ok := values[consts.RdbEventTaskID].(string) + if !ok || taskID == "" { + return nil, fmt.Errorf(message, consts.RdbEventTaskID) + } + + timeStamp, err := strconv.Atoi(strings.Split(id, "-")[0]) + if err != nil { + return nil, err + } + + event := &dto.TraceStreamEvent{ + TimeStamp: timeStamp, + TaskID: taskID, + } + + if _, exists := values[consts.RdbEventTaskType]; exists { + taskTypeStr, ok := values[consts.RdbEventTaskType].(string) + if !ok { + return nil, fmt.Errorf(message, consts.RdbEventTaskType) + } + taskTypePtr := consts.GetTaskTypeByName(taskTypeStr) + if taskTypePtr == nil { + return nil, fmt.Errorf("unknown task type name: %s", taskTypeStr) + } + event.TaskType = *taskTypePtr + } + + if _, exists := values[consts.RdbEventFn]; exists { + fnName, ok := values[consts.RdbEventFn].(string) + if !ok { + return nil, fmt.Errorf(message, consts.RdbEventFn) + } + event.FnName = fnName + } + + if _, exists := values[consts.RdbEventFileName]; exists { + fileName, ok := values[consts.RdbEventFileName].(string) + if !ok { + return nil, fmt.Errorf(message, consts.RdbEventTaskID) + } + event.FileName = fileName + } + + if _, exists := values[consts.RdbEventLine]; exists { + lineInt64, ok := values[consts.RdbEventLine].(string) + if !ok { + return nil, fmt.Errorf(message, consts.RdbEventLine) + } + line, err := strconv.Atoi(lineInt64) + if err != nil { + return nil, fmt.Errorf("invalid line number: %w", err) + } + event.Line = line + } + + if _, exists := values[consts.RdbEventName]; exists { + eventName, ok := values[consts.RdbEventName].(string) + if !ok { + return nil, fmt.Errorf(message, consts.RdbEventName) + } + event.EventName = consts.EventType(eventName) + } + + if _, exists := values[consts.RdbEventPayload]; exists && values[consts.RdbEventPayload] != nil { + payloadStr, ok := values[consts.RdbEventPayload].(string) + if !ok { + return nil, fmt.Errorf(message, consts.RdbEventPayload) + } + payload, err := parsePayloadByEventType(event.EventName, payloadStr) + if err != nil { + return nil, fmt.Errorf(message, consts.RdbEventPayload) + } + event.Payload = payload + } + + return event, nil +} + +func parsePayloadByEventType(eventType consts.EventType, payloadStr string) (any, error) { + payloadType, exists := payloadTypeRegistry[eventType] + if !exists { + return nil, nil + } + + valuePtr := reflect.New(payloadType) + if err := json.Unmarshal([]byte(payloadStr), valuePtr.Interface()); err != nil { + return nil, fmt.Errorf("failed to unmarshal payload for event %s: %w", eventType, err) + } + + return valuePtr.Interface(), nil +} diff --git a/src/dto/user.go b/src/module/user/api_types.go similarity index 56% rename from src/dto/user.go rename to src/module/user/api_types.go index 71612146..d205d1cc 100644 --- a/src/dto/user.go +++ b/src/module/user/api_types.go @@ -1,4 +1,4 @@ -package dto +package user import ( "fmt" @@ -6,12 +6,12 @@ import ( "time" "aegis/consts" - "aegis/database" + "aegis/dto" + "aegis/model" + rbac "aegis/module/rbac" ) -// ===================== User CRUD DTOs ===================== - -// CreateUserReq represents user creation request +// CreateUserReq represents user creation request. type CreateUserReq struct { Username string `json:"username" binding:"required"` Email string `json:"email" binding:"required,email"` @@ -37,9 +37,9 @@ func (req *CreateUserReq) Validate() error { return nil } -// ListUserReq represents user list query parameters +// ListUserReq represents user list query parameters. type ListUserReq struct { - PaginationReq + dto.PaginationReq IsActive *bool `form:"is_active"` Status *consts.StatusType `form:"status"` } @@ -48,89 +48,10 @@ func (req *ListUserReq) Validate() error { if err := req.PaginationReq.Validate(); err != nil { return err } - return validateStatusField(req.Status, false) -} - -type UserSearchReq struct { - AdvancedSearchReq[string] - - // User-specific filter shortcuts - UsernamePattern string `json:"username_pattern,omitempty"` // Username fuzzy match - EmailPattern string `json:"email_pattern,omitempty"` // Email fuzzy match - FullNamePattern string `json:"fullname_pattern,omitempty"` // Full name fuzzy match - RoleIDs []int `json:"role_ids,omitempty"` // Role ID filter - ProjectIDs []int `json:"project_ids,omitempty"` // Project ID filter - Departments []string `json:"departments,omitempty"` // Department filter - LastLoginRange *DateRange `json:"last_login_range,omitempty"` // Last login time range + return validateStatus(req.Status, false) } -// ConvertToSearchReq converts UserSearchReq to SearchReq with user-specific filters -func (usr *UserSearchReq) ConvertToSearchReq() *SearchReq[string] { - sr := usr.ConvertAdvancedToSearch() - - // Add user-specific filters - if usr.UsernamePattern != "" { - sr.AddFilter("username", OpLike, usr.UsernamePattern) - } - - if usr.EmailPattern != "" { - sr.AddFilter("email", OpLike, usr.EmailPattern) - } - - if usr.FullNamePattern != "" { - sr.AddFilter("full_name", OpLike, usr.FullNamePattern) - } - - if len(usr.RoleIDs) > 0 { - values := make([]string, len(usr.RoleIDs)) - for i, v := range usr.RoleIDs { - values[i] = fmt.Sprintf("%v", v) - } - sr.Filters = append(sr.Filters, SearchFilter{ - Field: "role_id", - Operator: OpIn, - Values: values, - }) - } - - if len(usr.ProjectIDs) > 0 { - values := make([]string, len(usr.ProjectIDs)) - for i, v := range usr.ProjectIDs { - values[i] = fmt.Sprintf("%v", v) - } - sr.Filters = append(sr.Filters, SearchFilter{ - Field: "project_id", - Operator: OpIn, - Values: values, - }) - } - - if len(usr.Departments) > 0 { - values := make([]string, len(usr.Departments)) - for i, v := range usr.Departments { - values[i] = fmt.Sprintf("%v", v) - } - sr.Filters = append(sr.Filters, SearchFilter{ - Field: "department", - Operator: OpIn, - Values: values, - }) - } - - if usr.LastLoginRange != nil { - if usr.LastLoginRange.From != nil && usr.LastLoginRange.To != nil { - sr.AddFilter("last_login_at", OpDateBetween, []interface{}{usr.LastLoginRange.From, usr.LastLoginRange.To}) - } else if usr.LastLoginRange.From != nil { - sr.AddFilter("last_login_at", OpDateAfter, usr.LastLoginRange.From) - } else if usr.LastLoginRange.To != nil { - sr.AddFilter("last_login_at", OpDateBefore, usr.LastLoginRange.To) - } - } - - return sr -} - -// UpdateUserReq represents user update request +// UpdateUserReq represents user update request. type UpdateUserReq struct { Email *string `json:"email,omitempty" binding:"omitempty,email"` FullName *string `json:"full_name,omitempty" binding:"omitempty"` @@ -141,10 +62,10 @@ type UpdateUserReq struct { } func (req *UpdateUserReq) Validate() error { - return validateStatusField(req.Status, true) + return validateStatus(req.Status, true) } -func (req *UpdateUserReq) PatchUserModel(target *database.User) { +func (req *UpdateUserReq) PatchUserModel(target *model.User) { if req.Email != nil { target.Email = *req.Email } @@ -165,7 +86,7 @@ func (req *UpdateUserReq) PatchUserModel(target *database.User) { } } -// UserResp represents basic user response +// UserResp represents basic user response. type UserResp struct { ID int `json:"id"` Username string `json:"username"` @@ -180,7 +101,7 @@ type UserResp struct { UpdatedAt time.Time `json:"updated_at"` } -func NewUserResp(user *database.User) *UserResp { +func NewUserResp(user *model.User) *UserResp { return &UserResp{ ID: user.ID, Username: user.Username, @@ -196,54 +117,38 @@ func NewUserResp(user *database.User) *UserResp { } } -// UserDetailResp represents detailed user response with roles and projects +// UserDetailResp represents detailed user response with roles and permissions. type UserDetailResp struct { UserResp - GlobalRoles []RoleResp `json:"global_roles,omitempty"` - Permissions []PermissionResp `json:"permissions,omitempty"` - ContainerRoles []UserContainerInfo `json:"container_roles,omitempty"` - DatasetRoles []UserDatasetInfo `json:"dataset_roles,omitempty"` - ProjectRoles []UserProjectInfo `json:"project_roles,omitempty"` + GlobalRoles []rbac.RoleResp `json:"global_roles,omitempty"` + Permissions []rbac.PermissionResp `json:"permissions,omitempty"` + ContainerRoles []UserContainerInfo `json:"container_roles,omitempty"` + DatasetRoles []UserDatasetInfo `json:"dataset_roles,omitempty"` + ProjectRoles []UserProjectInfo `json:"project_roles,omitempty"` } -func NewUserDetailResp(user *database.User) *UserDetailResp { +func NewUserDetailResp(user *model.User) *UserDetailResp { return &UserDetailResp{ UserResp: *NewUserResp(user), } } -type UserProfileResp struct { - ID int `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - FullName string `json:"full_name"` - Avatar string `json:"avatar,omitempty"` - Phone string `json:"phone,omitempty"` - LastLoginAt *time.Time `json:"last_login_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - - ContainerRoles []UserContainerInfo `json:"container_roles,omitempty"` - DatasetRoles []UserDatasetInfo `json:"dataset_roles,omitempty"` - ProjectRoles []UserProjectInfo `json:"project_roles,omitempty"` -} - -func NewUserProfileResp(user *database.User) *UserProfileResp { - return &UserProfileResp{ - ID: user.ID, - Username: user.Username, - Email: user.Email, - FullName: user.FullName, - Avatar: user.Avatar, - Phone: user.Phone, - LastLoginAt: user.LastLoginAt, - CreatedAt: user.CreatedAt, +func validateStatus(statusPtr *consts.StatusType, isMutation bool) error { + if statusPtr == nil { + return nil + } + status := *statusPtr + if _, exists := consts.ValidStatuses[status]; !exists { + return fmt.Errorf("invalid status value: %d", status) } + if isMutation && status == consts.CommonDeleted { + return fmt.Errorf("status value cannot be set to deleted (%d) directly through this update/create operation", consts.CommonDeleted) + } + return nil } -// ===================== User-Permission DTOs ===================== - -// AssignUserPermissionItem represents a single user-permission assignment item +// AssignUserPermissionItem represents a single user-permission assignment item. type AssignUserPermissionItem struct { PermissionID int `json:"permission_id" binding:"required,min=1"` GrantType *consts.GrantType `json:"grant_type" binding:"required"` @@ -266,8 +171,8 @@ func (item *AssignUserPermissionItem) Validate() error { return nil } -func (item *AssignUserPermissionItem) ConvertToUserPermission() *database.UserPermission { - return &database.UserPermission{ +func (item *AssignUserPermissionItem) ConvertToUserPermission() *model.UserPermission { + return &model.UserPermission{ PermissionID: item.PermissionID, GrantType: *item.GrantType, ExpiresAt: item.ExpiresAt, @@ -277,7 +182,7 @@ func (item *AssignUserPermissionItem) ConvertToUserPermission() *database.UserPe } } -// AssignUserPermissionReq represents direct user-permission assignment req +// AssignUserPermissionReq represents direct user-permission assignment request. type AssignUserPermissionReq struct { Items []AssignUserPermissionItem `json:"items" binding:"required"` } @@ -294,7 +199,7 @@ func (req *AssignUserPermissionReq) Validate() error { return nil } -// RemoveUserPermissionReq represents direct user-permission removal req +// RemoveUserPermissionReq represents direct user-permission removal request. type RemoveUserPermissionReq struct { PermissionIDs []int `json:"permission_ids" binding:"required"` } @@ -311,9 +216,7 @@ func (req *RemoveUserPermissionReq) Validate() error { return nil } -// ===================== User-Container Relationship DTOs ===================== - -// UserContainerResponse represents user-container relationship +// UserContainerInfo represents a user's role binding on a container. type UserContainerInfo struct { ContainerID int `json:"container_id"` ContainerName string `json:"container_name"` @@ -321,25 +224,21 @@ type UserContainerInfo struct { JoinedAt time.Time `json:"joined_at"` } -func NewUserContainerInfo(userContainer *database.UserContainer) *UserContainerInfo { +func NewUserContainerInfo(userContainer *model.UserContainer) *UserContainerInfo { resp := &UserContainerInfo{ ContainerID: userContainer.ContainerID, JoinedAt: userContainer.CreatedAt, } - if userContainer.Container != nil { resp.ContainerName = userContainer.Container.Name } if userContainer.Role != nil { resp.RoleName = userContainer.Role.Name } - return resp } -// ===================== User-Project Relationship DTOs ===================== - -// UserDatasetInfo represents user-dataset relationship +// UserDatasetInfo represents a user's role binding on a dataset. type UserDatasetInfo struct { DatasetID int `json:"dataset_id"` DatasetName string `json:"dataset_name"` @@ -347,25 +246,21 @@ type UserDatasetInfo struct { JoinedAt time.Time `json:"joined_at"` } -func NewUserDatasetInfo(userDataset *database.UserDataset) *UserDatasetInfo { +func NewUserDatasetInfo(userDataset *model.UserDataset) *UserDatasetInfo { resp := &UserDatasetInfo{ DatasetID: userDataset.DatasetID, JoinedAt: userDataset.CreatedAt, } - if userDataset.Dataset != nil { resp.DatasetName = userDataset.Dataset.Name } if userDataset.Role != nil { resp.RoleName = userDataset.Role.Name } - return resp } -// ===================== User-Project Relationship DTOs ===================== - -// UserProjectResponse represents user-project relationship +// UserProjectInfo represents a user's role binding on a project. type UserProjectInfo struct { ProjectID int `json:"project_id"` ProjectName string `json:"project_name"` @@ -373,18 +268,16 @@ type UserProjectInfo struct { JoinedAt time.Time `json:"joined_at"` } -func NewUserProjectInfo(userProject *database.UserProject) *UserProjectInfo { +func NewUserProjectInfo(userProject *model.UserProject) *UserProjectInfo { resp := &UserProjectInfo{ ProjectID: userProject.ProjectID, JoinedAt: userProject.CreatedAt, } - if userProject.Project != nil { resp.ProjectName = userProject.Project.Name } if userProject.Role != nil { resp.RoleName = userProject.Role.Name } - return resp } diff --git a/src/handlers/v2/users.go b/src/module/user/handler.go similarity index 56% rename from src/handlers/v2/users.go rename to src/module/user/handler.go index 35673809..32414765 100644 --- a/src/handlers/v2/users.go +++ b/src/module/user/handler.go @@ -1,17 +1,24 @@ -package v2 +package user import ( - "aegis/consts" + "aegis/httpx" "net/http" "strconv" + "aegis/consts" "aegis/dto" - "aegis/handlers" - producer "aegis/service/producer" "github.com/gin-gonic/gin" ) +type Handler struct { + service HandlerService +} + +func NewHandler(service HandlerService) *Handler { + return &Handler{service: service} +} + // CreateUser handles user creation // // @Summary Create a new user @@ -21,15 +28,15 @@ import ( // @Accept json // @Produce json // @Security BearerAuth -// @Param request body dto.CreateUserReq true "User creation request" -// @Success 201 {object} dto.GenericResponse[dto.UserResp] "User created successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 409 {object} dto.GenericResponse[any] "User already exists" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param request body CreateUserReq true "User creation request" +// @Success 201 {object} dto.GenericResponse[UserResp] "User created successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 409 {object} dto.GenericResponse[any] "User already exists" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users [post] -// @x-api-type {"sdk":"true"} -func CreateUser(c *gin.Context) { - var req dto.CreateUserReq +// @x-api-type {"admin":"true"} +func (h *Handler) CreateUser(c *gin.Context) { + var req CreateUserReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return @@ -40,8 +47,8 @@ func CreateUser(c *gin.Context) { return } - resp, err := producer.CreateUser(&req) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.CreateUser(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { return } @@ -64,20 +71,15 @@ func CreateUser(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "User not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users/{id} [delete] -// @x-api-type {"sdk":"true"} -func DeleteUser(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, err := strconv.Atoi(idStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") +// @x-api-type {"admin":"true"} +func (h *Handler) DeleteUser(c *gin.Context) { + userID, ok := parseUserID(c) + if !ok { return } - - err = producer.DeleteUser(id) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.DeleteUser(c.Request.Context(), userID)) { return } - dto.JSONResponse[any](c, http.StatusNoContent, "User deleted successfully", nil) } @@ -89,28 +91,24 @@ func DeleteUser(c *gin.Context) { // @ID get_user_by_id // @Produce json // @Security BearerAuth -// @Param id path int true "User ID" -// @Success 200 {object} dto.GenericResponse[dto.UserDetailResp] "User retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid user ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "User not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param id path int true "User ID" +// @Success 200 {object} dto.GenericResponse[UserDetailResp] "User retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid user ID" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "User not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users/{id}/detail [get] -// @x-api-type {"sdk":"true"} -func GetUserDetailV2(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, err := strconv.Atoi(idStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") +// @x-api-type {"admin":"true"} +func (h *Handler) GetUserDetail(c *gin.Context) { + userID, ok := parseUserID(c) + if !ok { return } - - resp, err := producer.GetUserDetail(id) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.GetUserDetail(c.Request.Context(), userID) + if httpx.HandleServiceError(c, err) { return } - dto.SuccessResponse(c, resp) } @@ -122,36 +120,33 @@ func GetUserDetailV2(c *gin.Context) { // @ID list_users // @Produce json // @Security BearerAuth -// @Param page query int false "Page number" default(1) -// @Param size query int false "Page size" default(20) -// @Param username query string false "Filter by username" -// @Param email query string false "Filter by email" -// @Param is_active query bool false "Filter by active status" -// @Param status query consts.StatusType false "Filter by status" -// @Success 200 {object} dto.GenericResponse[dto.ListResp[dto.UserResp]] "Users retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param page query int false "Page number" default(1) +// @Param size query int false "Page size" default(20) +// @Param username query string false "Filter by username" +// @Param email query string false "Filter by email" +// @Param is_active query bool false "Filter by active status" +// @Param status query consts.StatusType false "Filter by status" +// @Success 200 {object} dto.GenericResponse[dto.ListResp[UserResp]] "Users retrieved successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid request format or parameters" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users [get] -// @x-api-type {"sdk":"true"} -func ListUsersV2(c *gin.Context) { - var req dto.ListUserReq +// @x-api-type {"admin":"true"} +func (h *Handler) ListUsers(c *gin.Context) { + var req ListUserReq if err := c.ShouldBindQuery(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - resp, err := producer.ListUsers(&req) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.ListUsers(c.Request.Context(), &req) + if httpx.HandleServiceError(c, err) { return } - dto.SuccessResponse(c, resp) } @@ -164,40 +159,33 @@ func ListUsersV2(c *gin.Context) { // @Accept json // @Produce json // @Security BearerAuth -// @Param id path int true "User ID" -// @Param request body dto.UpdateUserReq true "User update request" -// @Success 202 {object} dto.GenericResponse[dto.UserResp] "User updated successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid user ID/request" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "User not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" +// @Param id path int true "User ID" +// @Param request body UpdateUserReq true "User update request" +// @Success 202 {object} dto.GenericResponse[UserResp] "User updated successfully" +// @Failure 400 {object} dto.GenericResponse[any] "Invalid user ID/request" +// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" +// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" +// @Failure 404 {object} dto.GenericResponse[any] "User not found" +// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users/{id} [patch] -// @x-api-type {"sdk":"true"} -func UpdateUser(c *gin.Context) { - idStr := c.Param(consts.URLPathID) - id, err := strconv.Atoi(idStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") +// @x-api-type {"admin":"true"} +func (h *Handler) UpdateUser(c *gin.Context) { + userID, ok := parseUserID(c) + if !ok { return } - - var req dto.UpdateUserReq + var req UpdateUserReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - - resp, err := producer.UpdateUser(&req, id) - if handlers.HandleServiceError(c, err) { + resp, err := h.service.UpdateUser(c.Request.Context(), &req, userID) + if httpx.HandleServiceError(c, err) { return } - dto.JSONResponse[any](c, http.StatusAccepted, "User updated successfully", resp) } -// ===================== User-Role API ===================== - // AssignUserRole handles user-role assignment // // @Summary Assign global role to user @@ -215,26 +203,15 @@ func UpdateUser(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "Resource not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users/{user_id}/role/{role_id} [post] -func AssignUserRole(c *gin.Context) { - userIDStr := c.Param(consts.URLPathUserID) - userID, err := strconv.Atoi(userIDStr) - if err != nil || userID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") - return - } - - roleIDStr := c.Param(consts.URLPathRoleID) - roleID, err := strconv.Atoi(roleIDStr) - if err != nil || roleID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid role ID") +// @x-api-type {"admin":"true"} +func (h *Handler) AssignRole(c *gin.Context) { + userID, roleID, ok := parseUserAndRoleIDs(c) + if !ok { return } - - err = producer.AssignRoleToUser(userID, roleID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.AssignRole(c.Request.Context(), userID, roleID)) { return } - dto.JSONResponse[any](c, http.StatusOK, "Role assigned successfully", nil) } @@ -255,63 +232,18 @@ func AssignUserRole(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "User or role not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users/{user_id}/roles/{role_id} [delete] -func RemoveGlobalRole(c *gin.Context) { - userIDStr := c.Param(consts.URLPathUserID) - userID, err := strconv.Atoi(userIDStr) - if err != nil || userID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") - return - } - - roleIDStr := c.Param(consts.URLPathRoleID) - roleID, err := strconv.Atoi(roleIDStr) - if err != nil || roleID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid role ID") +// @x-api-type {"admin":"true"} +func (h *Handler) RemoveRole(c *gin.Context) { + userID, roleID, ok := parseUserAndRoleIDs(c) + if !ok { return } - - err = producer.RemoveRoleFromUser(userID, roleID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.RemoveRole(c.Request.Context(), userID, roleID)) { return } - dto.JSONResponse[any](c, http.StatusNoContent, "Role removed successfully", nil) } -// ListUsersFromRole handles listing users assigned to a role -// -// @Summary List users from role -// @Description Get list of users assigned to a specific role -// @Tags Roles -// @ID list_users_by_role -// @Produce json -// @Security BearerAuth -// @Param role_id path int true "Role ID" -// @Success 200 {object} dto.GenericResponse[[]dto.UserResp] "Users retrieved successfully" -// @Failure 400 {object} dto.GenericResponse[any] "Invalid role ID" -// @Failure 401 {object} dto.GenericResponse[any] "Authentication required" -// @Failure 403 {object} dto.GenericResponse[any] "Permission denied" -// @Failure 404 {object} dto.GenericResponse[any] "Role not found" -// @Failure 500 {object} dto.GenericResponse[any] "Internal server error" -// @Router /api/v2/roles/{role_id}/users [get] -func ListUsersFromRole(c *gin.Context) { - roleIdStr := c.Param(consts.URLPathRoleID) - roleID, err := strconv.Atoi(roleIdStr) - if err != nil || roleID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid role ID") - return - } - - userResps, err := producer.ListUsersFromRole(roleID) - if handlers.HandleServiceError(c, err) { - return - } - - dto.SuccessResponse(c, userResps) -} - -// ===================== User-Permission API ===================== - // AssignUserPermission handles direct user-permission assignment // // @Summary Assign permission to user @@ -322,7 +254,7 @@ func ListUsersFromRole(c *gin.Context) { // @Produce json // @Security BearerAuth // @Param user_id path int true "User ID" -// @Param request body dto.AssignUserPermissionReq true "User permission assignment request" +// @Param request body AssignUserPermissionReq true "User permission assignment request" // @Success 200 {object} dto.GenericResponse[any] "Permission assigned successfully" // @Failure 400 {object} dto.GenericResponse[any] "Invalid user ID or invalid request format or parameters" // @Failure 401 {object} dto.GenericResponse[any] "Authentication required" @@ -330,30 +262,24 @@ func ListUsersFromRole(c *gin.Context) { // @Failuer 404 {object} dto.GenericResponse[any] "Resource not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users/{user_id}/permissions/assign [post] -func AssignUserPermission(c *gin.Context) { - userIDStr := c.Param(consts.URLPathUserID) - userID, err := strconv.Atoi(userIDStr) - if err != nil || userID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") +// @x-api-type {"admin":"true"} +func (h *Handler) AssignPermissions(c *gin.Context) { + userID, ok := parseUserID(c) + if !ok { return } - - var req dto.AssignUserPermissionReq + var req AssignUserPermissionReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - err = producer.BatchAssignUserPermissions(&req, userID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.AssignPermissions(c.Request.Context(), &req, userID)) { return } - dto.JSONResponse[any](c, http.StatusOK, "Permissions assigned successfully", nil) } @@ -367,7 +293,7 @@ func AssignUserPermission(c *gin.Context) { // @Produce json // @Security BearerAuth // @Param user_id path int true "User ID" -// @Param request body dto.RemoveUserPermissionReq true "User permission removal request" +// @Param request body RemoveUserPermissionReq true "User permission removal request" // @Success 200 {object} dto.GenericResponse[any] "Permission removed successfully" // @Failure 400 {object} dto.GenericResponse[any] "Invalid user or permission ID" // @Failure 401 {object} dto.GenericResponse[any] "Authentication required" @@ -375,35 +301,27 @@ func AssignUserPermission(c *gin.Context) { // @Failuer 404 {object} dto.GenericResponse[any] "Resource not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users/{user_id}/permissions/remove [post] -func RemoveUserPermission(c *gin.Context) { - userIDStr := c.Param(consts.URLPathUserID) - userID, err := strconv.Atoi(userIDStr) - if err != nil || userID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") +// @x-api-type {"admin":"true"} +func (h *Handler) RemovePermissions(c *gin.Context) { + userID, ok := parseUserID(c) + if !ok { return } - - var req dto.RemoveUserPermissionReq + var req RemoveUserPermissionReq if err := c.ShouldBindJSON(&req); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Invalid request format: "+err.Error()) return } - if err := req.Validate(); err != nil { dto.ErrorResponse(c, http.StatusBadRequest, "Validation failed: "+err.Error()) return } - - err = producer.BatchRemoveUserPermissions(&req, userID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.RemovePermissions(c.Request.Context(), &req, userID)) { return } - dto.JSONResponse[any](c, http.StatusOK, "Permissions removed successfully", nil) } -// ===================== User-Container API ===================== - // AssignUserContainer handles user-container assignment // // @Summary Assign user to container @@ -422,33 +340,23 @@ func RemoveUserPermission(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "User or container or role not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users/{user_id}/containers/{container_id}/roles/{role_id} [post] -func AssignUserContainer(c *gin.Context) { - userIDStr := c.Param(consts.URLPathUserID) - userID, err := strconv.Atoi(userIDStr) - if err != nil || userID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") +// @x-api-type {"admin":"true"} +func (h *Handler) AssignContainer(c *gin.Context) { + userID, ok := parseUserID(c) + if !ok { return } - - containerIDStr := c.Param(consts.URLPathContainerID) - containerID, err := strconv.Atoi(containerIDStr) - if err != nil || containerID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container ID") + containerID, ok := parsePathID(c, consts.URLPathContainerID, "Invalid container ID") + if !ok { return } - - roleIDStr := c.Param(consts.URLPathRoleID) - roleID, err := strconv.Atoi(roleIDStr) - if err != nil || roleID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid role ID") + roleID, ok := parsePathID(c, consts.URLPathRoleID, "Invalid role ID") + if !ok { return } - - err = producer.AssignContainerToUser(userID, containerID, roleID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.AssignContainer(c.Request.Context(), userID, containerID, roleID)) { return } - dto.JSONResponse[any](c, http.StatusOK, "User assigned to container successfully", nil) } @@ -469,31 +377,22 @@ func AssignUserContainer(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "User or container not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users/{user_id}/containers/{container_id} [delete] -func RemoveUserContainer(c *gin.Context) { - userIDStr := c.Param(consts.URLPathUserID) - userID, err := strconv.Atoi(userIDStr) - if err != nil || userID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") +// @x-api-type {"admin":"true"} +func (h *Handler) RemoveContainer(c *gin.Context) { + userID, ok := parseUserID(c) + if !ok { return } - - containerIDStr := c.Param(consts.URLPathContainerID) - containerID, err := strconv.Atoi(containerIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid container ID") + containerID, ok := parsePathID(c, consts.URLPathContainerID, "Invalid container ID") + if !ok { return } - - err = producer.RemoveContainerFromUser(userID, containerID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.RemoveContainer(c.Request.Context(), userID, containerID)) { return } - dto.JSONResponse[any](c, http.StatusNoContent, "User removed from container successfully", nil) } -// ===================== User-Dataset API ===================== - // AssignUserDataset handles user-dataset assignment // // @Summary Assign user to dataset @@ -512,33 +411,23 @@ func RemoveUserContainer(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "User or dataset or role not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users/{user_id}/datasets/{dataset_id}/roles/{role_id} [post] -func AssignUserDataset(c *gin.Context) { - userIDStr := c.Param(consts.URLPathUserID) - userID, err := strconv.Atoi(userIDStr) - if err != nil || userID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") +// @x-api-type {"admin":"true"} +func (h *Handler) AssignDataset(c *gin.Context) { + userID, ok := parseUserID(c) + if !ok { return } - - datasetIDStr := c.Param(consts.URLPathDatasetID) - datasetID, err := strconv.Atoi(datasetIDStr) - if err != nil || datasetID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset ID") + datasetID, ok := parsePathID(c, consts.URLPathDatasetID, "Invalid dataset ID") + if !ok { return } - - roleIDStr := c.Param(consts.URLPathRoleID) - roleID, err := strconv.Atoi(roleIDStr) - if err != nil || roleID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid role ID") + roleID, ok := parsePathID(c, consts.URLPathRoleID, "Invalid role ID") + if !ok { return } - - err = producer.AssignDatasetToUser(userID, datasetID, roleID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.AssignDataset(c.Request.Context(), userID, datasetID, roleID)) { return } - dto.JSONResponse[any](c, http.StatusOK, "User assigned to dataset successfully", nil) } @@ -559,31 +448,22 @@ func AssignUserDataset(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "User or dataset not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users/{user_id}/datasets/{dataset_id} [delete] -func RemoveUserDataset(c *gin.Context) { - userIDStr := c.Param(consts.URLPathUserID) - userID, err := strconv.Atoi(userIDStr) - if err != nil || userID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") +// @x-api-type {"admin":"true"} +func (h *Handler) RemoveDataset(c *gin.Context) { + userID, ok := parseUserID(c) + if !ok { return } - - datasetIDStr := c.Param(consts.URLPathDatasetID) - datasetID, err := strconv.Atoi(datasetIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid dataset ID") + datasetID, ok := parsePathID(c, consts.URLPathDatasetID, "Invalid dataset ID") + if !ok { return } - - err = producer.RemoveDatasetFromUser(userID, datasetID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.RemoveDataset(c.Request.Context(), userID, datasetID)) { return } - dto.JSONResponse[any](c, http.StatusNoContent, "User removed from dataset successfully", nil) } -// ===================== User-Project API ===================== - // AssignUserToProject handles user-project assignment // // @Summary Assign user to project @@ -602,33 +482,23 @@ func RemoveUserDataset(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "User or project or role not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users/{user_id}/projects/{project_id}/roles/{role_id} [post] -func AssignUserProject(c *gin.Context) { - userIDStr := c.Param(consts.URLPathUserID) - userID, err := strconv.Atoi(userIDStr) - if err != nil || userID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") +// @x-api-type {"admin":"true"} +func (h *Handler) AssignProject(c *gin.Context) { + userID, ok := parseUserID(c) + if !ok { return } - - projectIDStr := c.Param(consts.URLPathProjectID) - projectID, err := strconv.Atoi(projectIDStr) - if err != nil || projectID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") + projectID, ok := parsePathID(c, consts.URLPathProjectID, "Invalid project ID") + if !ok { return } - - roleIDstr := c.Param(consts.URLPathRoleID) - roleID, err := strconv.Atoi(roleIDstr) - if err != nil || roleID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid role ID") + roleID, ok := parsePathID(c, consts.URLPathRoleID, "Invalid role ID") + if !ok { return } - - err = producer.AssignProjectToUser(userID, projectID, roleID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.AssignProject(c.Request.Context(), userID, projectID, roleID)) { return } - dto.JSONResponse[any](c, http.StatusOK, "User assigned to project successfully", nil) } @@ -649,25 +519,44 @@ func AssignUserProject(c *gin.Context) { // @Failure 404 {object} dto.GenericResponse[any] "User or project not found" // @Failure 500 {object} dto.GenericResponse[any] "Internal server error" // @Router /api/v2/users/{user_id}/projects/{project_id} [delete] -func RemoveUserProject(c *gin.Context) { - userIDStr := c.Param(consts.URLPathUserID) - userID, err := strconv.Atoi(userIDStr) - if err != nil || userID <= 0 { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid user ID") +// @x-api-type {"admin":"true"} +func (h *Handler) RemoveProject(c *gin.Context) { + userID, ok := parseUserID(c) + if !ok { return } - - projectIDStr := c.Param(consts.URLPathProjectID) - projectID, err := strconv.Atoi(projectIDStr) - if err != nil { - dto.ErrorResponse(c, http.StatusBadRequest, "Invalid project ID") + projectID, ok := parsePathID(c, consts.URLPathProjectID, "Invalid project ID") + if !ok { return } - - err = producer.RemoveProjectFromUser(userID, projectID) - if handlers.HandleServiceError(c, err) { + if httpx.HandleServiceError(c, h.service.RemoveProject(c.Request.Context(), userID, projectID)) { return } - dto.JSONResponse[any](c, http.StatusNoContent, "User removed from project successfully", nil) } + +func parseUserID(c *gin.Context) (int, bool) { + return parsePathID(c, consts.URLPathUserID, "Invalid user ID") +} + +func parseUserAndRoleIDs(c *gin.Context) (int, int, bool) { + userID, ok := parseUserID(c) + if !ok { + return 0, 0, false + } + roleID, ok := parsePathID(c, consts.URLPathRoleID, "Invalid role ID") + if !ok { + return 0, 0, false + } + return userID, roleID, true +} + +func parsePathID(c *gin.Context, name, message string) (int, bool) { + value := c.Param(name) + id, err := strconv.Atoi(value) + if err != nil || id <= 0 { + dto.ErrorResponse(c, http.StatusBadRequest, message) + return 0, false + } + return id, true +} diff --git a/src/module/user/handler_service.go b/src/module/user/handler_service.go new file mode 100644 index 00000000..484929a3 --- /dev/null +++ b/src/module/user/handler_service.go @@ -0,0 +1,30 @@ +package user + +import ( + "context" + + "aegis/dto" +) + +// HandlerService captures the user operations consumed by the HTTP handler. +type HandlerService interface { + CreateUser(context.Context, *CreateUserReq) (*UserResp, error) + DeleteUser(context.Context, int) error + GetUserDetail(context.Context, int) (*UserDetailResp, error) + ListUsers(context.Context, *ListUserReq) (*dto.ListResp[UserResp], error) + UpdateUser(context.Context, *UpdateUserReq, int) (*UserResp, error) + AssignRole(context.Context, int, int) error + RemoveRole(context.Context, int, int) error + AssignPermissions(context.Context, *AssignUserPermissionReq, int) error + RemovePermissions(context.Context, *RemoveUserPermissionReq, int) error + AssignContainer(context.Context, int, int, int) error + RemoveContainer(context.Context, int, int) error + AssignDataset(context.Context, int, int, int) error + RemoveDataset(context.Context, int, int) error + AssignProject(context.Context, int, int, int) error + RemoveProject(context.Context, int, int) error +} + +func AsHandlerService(service *Service) HandlerService { + return service +} diff --git a/src/module/user/module.go b/src/module/user/module.go new file mode 100644 index 00000000..2b55f7a8 --- /dev/null +++ b/src/module/user/module.go @@ -0,0 +1,10 @@ +package user + +import "go.uber.org/fx" + +var Module = fx.Module("user", + fx.Provide(NewRepository), + fx.Provide(NewService), + fx.Provide(AsHandlerService), + fx.Provide(NewHandler), +) diff --git a/src/module/user/repository.go b/src/module/user/repository.go new file mode 100644 index 00000000..2af653b0 --- /dev/null +++ b/src/module/user/repository.go @@ -0,0 +1,388 @@ +package user + +import ( + "aegis/consts" + "aegis/model" + "fmt" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) createUserIfUnique(user *model.User) error { + var existingByUsername model.User + if err := r.db.Where("username = ?", user.Username).First(&existingByUsername).Error; err == nil { + return fmt.Errorf("%w: username %s already exists", consts.ErrAlreadyExists, user.Username) + } + + var existingByEmail model.User + if err := r.db.Where("email = ?", user.Email).First(&existingByEmail).Error; err == nil { + return fmt.Errorf("%w: email %s already exists", consts.ErrAlreadyExists, user.Email) + } + if err := r.db.Omit("active_username").Create(user).Error; err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + return nil +} + +func (r *Repository) getUserDetailBase(userID int) (*model.User, error) { + var user model.User + if err := r.db.Where("id = ?", userID).First(&user).Error; err != nil { + return nil, fmt.Errorf("failed to find user with id %d: %w", userID, err) + } + return &user, nil +} + +func (r *Repository) deleteUserCascade(userID int) (int64, error) { + if err := r.ensureActiveRecordExists(&model.User{}, userID, "user"); err != nil { + return 0, err + } + + if err := r.db.Model(&model.UserContainer{}). + Where("user_id = ? AND status != ?", userID, consts.CommonDeleted). + Update("status", consts.CommonDeleted).Error; err != nil { + return 0, fmt.Errorf("failed to remove containers from user: %w", err) + } + if err := r.db.Model(&model.UserDataset{}). + Where("user_id = ? AND status != ?", userID, consts.CommonDeleted). + Update("status", consts.CommonDeleted).Error; err != nil { + return 0, fmt.Errorf("failed to remove datasets from user: %w", err) + } + if err := r.db.Model(&model.UserProject{}). + Where("user_id = ? AND status != ?", userID, consts.CommonDeleted). + Update("status", consts.CommonDeleted).Error; err != nil { + return 0, fmt.Errorf("failed to remove projects from user: %w", err) + } + if err := r.db.Where("user_id = ?", userID).Delete(&model.UserPermission{}).Error; err != nil { + return 0, fmt.Errorf("failed to remove permissions from user: %w", err) + } + if err := r.db.Where("user_id = ?", userID).Delete(&model.UserRole{}).Error; err != nil { + return 0, fmt.Errorf("failed to remove roles from user: %w", err) + } + + result := r.db.Model(&model.User{}). + Where("id = ? AND status != ?", userID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if result.Error != nil { + return 0, fmt.Errorf("failed to delete user %d: %w", userID, result.Error) + } + return result.RowsAffected, nil +} + +func (r *Repository) listUserViews(limit, offset int, isActive *bool, status *consts.StatusType) ([]model.User, int64, error) { + var users []model.User + var total int64 + + query := r.db.Model(&model.User{}).Where("status != ?", consts.CommonDeleted) + if status != nil { + query = query.Where("status = ?", *status) + } + if isActive != nil { + query = query.Where("is_active = ?", *isActive) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count users: %w", err) + } + if err := query.Limit(limit).Offset(offset).Find(&users).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list users: %w", err) + } + return users, total, nil +} + +func (r *Repository) updateMutableUser(userID int, patch func(*model.User)) (*model.User, error) { + var user model.User + if err := r.db.Where("id = ?", userID).First(&user).Error; err != nil { + return nil, fmt.Errorf("failed to find user with id %d: %w", userID, err) + } + patch(&user) + if err := r.db.Omit("active_username").Save(&user).Error; err != nil { + return nil, fmt.Errorf("failed to update user: %w", err) + } + return &user, nil +} + +func (r *Repository) loadUserDetailRelations(userID int) ([]model.Role, []model.Permission, []model.UserContainer, []model.UserDataset, []model.UserProject, error) { + var roles []model.Role + if err := r.db.Table("roles"). + Joins("JOIN user_roles ur ON ur.role_id = roles.id"). + Where("ur.user_id = ? AND roles.status = ?", userID, consts.CommonEnabled). + Find(&roles).Error; err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("failed to list roles by user id: %w", err) + } + + var permissions []model.Permission + if err := r.db.Table("permissions"). + Joins("JOIN user_permissions up ON up.permission_id = permissions.id"). + Where("up.user_id = ? AND permissions.status = ?", userID, consts.CommonEnabled). + Find(&permissions).Error; err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("failed to list permissions by user id: %w", err) + } + + var userContainers []model.UserContainer + if err := r.db.Where("user_id = ? AND status != ?", userID, consts.CommonDeleted). + Find(&userContainers).Error; err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("failed to list user containers: %w", err) + } + + var userDatasets []model.UserDataset + if err := r.db.Where("user_id = ? AND status != ?", userID, consts.CommonDeleted). + Find(&userDatasets).Error; err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("failed to list user datasets: %w", err) + } + + var userProjects []model.UserProject + if err := r.db.Where("user_id = ? AND status != ?", userID, consts.CommonDeleted). + Find(&userProjects).Error; err != nil { + return nil, nil, nil, nil, nil, fmt.Errorf("failed to list user projects: %w", err) + } + + return roles, permissions, userContainers, userDatasets, userProjects, nil +} + +func (r *Repository) assignGlobalRole(userID, roleID int) error { + if err := r.ensureActiveRecordExists(&model.User{}, userID, "user"); err != nil { + return err + } + if err := r.ensureActiveRecordExists(&model.Role{}, roleID, "role"); err != nil { + return err + } + if err := r.db.Create(&model.UserRole{UserID: userID, RoleID: roleID}).Error; err != nil { + return fmt.Errorf("failed to create user-role association: %w", err) + } + return nil +} + +func (r *Repository) removeGlobalRole(userID, roleID int) error { + if err := r.ensureActiveRecordExists(&model.User{}, userID, "user"); err != nil { + return err + } + if err := r.ensureActiveRecordExists(&model.Role{}, roleID, "role"); err != nil { + return err + } + if err := r.db.Where("user_id = ? AND role_id = ?", userID, roleID). + Delete(&model.UserRole{}).Error; err != nil { + return fmt.Errorf("failed to delete user-role association: %w", err) + } + return nil +} + +func (r *Repository) buildUserPermissions(userID int, items []AssignUserPermissionItem) ([]model.UserPermission, error) { + if err := r.ensureActiveRecordExists(&model.User{}, userID, "user"); err != nil { + return nil, err + } + + permissionIDs := make([]int, 0, len(items)) + for _, item := range items { + permissionIDs = append(permissionIDs, item.PermissionID) + } + permissions, err := r.listPermissionsByIDs(permissionIDs) + if err != nil { + return nil, fmt.Errorf("failed to list permissions by ids: %w", err) + } + permissionMap := make(map[int]struct{}, len(permissions)) + for _, permission := range permissions { + permissionMap[permission.ID] = struct{}{} + } + + userPermissions := make([]model.UserPermission, 0, len(items)) + for _, item := range items { + if _, exists := permissionMap[item.PermissionID]; !exists { + return nil, fmt.Errorf("%w: permission id %d not found", consts.ErrNotFound, item.PermissionID) + } + if item.ContainerID != nil { + if err := r.ensureActiveRecordExists(&model.Container{}, *item.ContainerID, "container"); err != nil { + return nil, fmt.Errorf("%w: container id %d not found", consts.ErrNotFound, *item.ContainerID) + } + } + if item.DatasetID != nil { + if err := r.ensureActiveRecordExists(&model.Dataset{}, *item.DatasetID, "dataset"); err != nil { + return nil, fmt.Errorf("%w: dataset id %d not found", consts.ErrNotFound, *item.DatasetID) + } + } + if item.ProjectID != nil { + if err := r.ensureActiveRecordExists(&model.Project{}, *item.ProjectID, "project"); err != nil { + return nil, fmt.Errorf("%w: project id %d not found", consts.ErrNotFound, *item.ProjectID) + } + } + + userPermission := item.ConvertToUserPermission() + userPermission.UserID = userID + userPermissions = append(userPermissions, *userPermission) + } + return userPermissions, nil +} + +func (r *Repository) batchCreateUserPermissions(userPermissions []model.UserPermission) error { + if len(userPermissions) == 0 { + return nil + } + if err := r.db.Create(&userPermissions).Error; err != nil { + return fmt.Errorf("failed to batch create user permissions: %w", err) + } + return nil +} + +func (r *Repository) batchDeleteUserPermissions(userID int, permissionIDs []int) error { + if err := r.ensureActiveRecordExists(&model.User{}, userID, "user"); err != nil { + return err + } + + permissions, err := r.listPermissionsByIDs(permissionIDs) + if err != nil { + return fmt.Errorf("failed to list permissions by ids: %w", err) + } + permissionMap := make(map[int]struct{}, len(permissions)) + for _, permission := range permissions { + permissionMap[permission.ID] = struct{}{} + } + for _, permissionID := range permissionIDs { + if _, exists := permissionMap[permissionID]; !exists { + return fmt.Errorf("%w: permission id %d not found", consts.ErrNotFound, permissionID) + } + } + + if err := r.db.Where("user_id = ? AND permission_id IN (?)", userID, permissionIDs). + Delete(&model.UserPermission{}).Error; err != nil { + return fmt.Errorf("failed to batch delete user permissions: %w", err) + } + return nil +} + +func (r *Repository) assignContainerRole(userID, containerID, roleID int) error { + if err := r.ensureActiveRecordExists(&model.User{}, userID, "user"); err != nil { + return err + } + if err := r.ensureActiveRecordExists(&model.Container{}, containerID, "container"); err != nil { + return err + } + if err := r.ensureActiveRecordExists(&model.Role{}, roleID, "role"); err != nil { + return err + } + + if err := r.db.Create(&model.UserContainer{ + UserID: userID, + ContainerID: containerID, + RoleID: roleID, + }).Error; err != nil { + return fmt.Errorf("failed to create user-container association: %w", err) + } + return nil +} + +func (r *Repository) removeContainerRole(userID, containerID int) (int64, error) { + if err := r.ensureActiveRecordExists(&model.User{}, userID, "user"); err != nil { + return 0, err + } + if err := r.ensureActiveRecordExists(&model.Container{}, containerID, "container"); err != nil { + return 0, err + } + result := r.db.Model(&model.UserContainer{}). + Where("user_id = ? AND container_id = ? AND status != ?", userID, containerID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if result.Error != nil { + return 0, fmt.Errorf("failed to delete user-container association: %w", result.Error) + } + return result.RowsAffected, nil +} + +func (r *Repository) assignDatasetRole(userID, datasetID, roleID int) error { + if err := r.ensureActiveRecordExists(&model.User{}, userID, "user"); err != nil { + return err + } + if err := r.ensureActiveRecordExists(&model.Dataset{}, datasetID, "dataset"); err != nil { + return err + } + if err := r.ensureActiveRecordExists(&model.Role{}, roleID, "role"); err != nil { + return err + } + + if err := r.db.Create(&model.UserDataset{ + UserID: userID, + DatasetID: datasetID, + RoleID: roleID, + }).Error; err != nil { + return fmt.Errorf("failed to create user-dataset association: %w", err) + } + return nil +} + +func (r *Repository) removeDatasetRole(userID, datasetID int) (int64, error) { + if err := r.ensureActiveRecordExists(&model.User{}, userID, "user"); err != nil { + return 0, err + } + if err := r.ensureActiveRecordExists(&model.Dataset{}, datasetID, "dataset"); err != nil { + return 0, err + } + result := r.db.Model(&model.UserDataset{}). + Where("user_id = ? AND dataset_id = ? AND status != ?", userID, datasetID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if result.Error != nil { + return 0, fmt.Errorf("failed to delete user-dataset association: %w", result.Error) + } + return result.RowsAffected, nil +} + +func (r *Repository) assignProjectRole(userID, projectID, roleID int) error { + if err := r.ensureActiveRecordExists(&model.User{}, userID, "user"); err != nil { + return err + } + if err := r.ensureActiveRecordExists(&model.Project{}, projectID, "project"); err != nil { + return err + } + if err := r.ensureActiveRecordExists(&model.Role{}, roleID, "role"); err != nil { + return err + } + + if err := r.db.Create(&model.UserProject{ + UserID: userID, + ProjectID: projectID, + RoleID: roleID, + }).Error; err != nil { + return fmt.Errorf("failed to create user-project association: %w", err) + } + return nil +} + +func (r *Repository) removeProjectRole(userID, projectID int) (int64, error) { + if err := r.ensureActiveRecordExists(&model.User{}, userID, "user"); err != nil { + return 0, err + } + if err := r.ensureActiveRecordExists(&model.Project{}, projectID, "project"); err != nil { + return 0, err + } + result := r.db.Model(&model.UserProject{}). + Where("user_id = ? AND project_id = ? AND status != ?", userID, projectID, consts.CommonDeleted). + Update("status", consts.CommonDeleted) + if result.Error != nil { + return 0, fmt.Errorf("failed to delete user-project association: %w", result.Error) + } + return result.RowsAffected, nil +} + +func (r *Repository) ensureActiveRecordExists(model any, id int, entity string) error { + if err := r.db.Where("id = ? AND status != ?", id, consts.CommonDeleted).First(model).Error; err != nil { + return fmt.Errorf("failed to find %s with id %d: %w", entity, id, err) + } + return nil +} + +func (r *Repository) listPermissionsByIDs(permissionIDs []int) ([]model.Permission, error) { + if len(permissionIDs) == 0 { + return []model.Permission{}, nil + } + + var permissions []model.Permission + if err := r.db.Where("id IN (?) AND status = ?", permissionIDs, consts.CommonEnabled). + Find(&permissions).Error; err != nil { + return nil, fmt.Errorf("failed to query permissions: %w", err) + } + return permissions, nil +} diff --git a/src/module/user/service.go b/src/module/user/service.go new file mode 100644 index 00000000..cf6565b7 --- /dev/null +++ b/src/module/user/service.go @@ -0,0 +1,330 @@ +package user + +import ( + "context" + "errors" + "fmt" + + "aegis/consts" + "aegis/dto" + "aegis/model" + rbac "aegis/module/rbac" + + "gorm.io/gorm" +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) CreateUser(_ context.Context, req *CreateUserReq) (*UserResp, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + user := &model.User{ + Username: req.Username, + Email: req.Email, + Password: req.Password, + FullName: req.FullName, + Phone: req.Phone, + Avatar: req.Avatar, + Status: consts.CommonEnabled, + IsActive: true, + } + + if err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + return repo.createUserIfUnique(user) + }); err != nil { + return nil, err + } + + return NewUserResp(user), nil +} + +func (s *Service) DeleteUser(_ context.Context, userID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if err := repo.ensureActiveRecordExists(&model.User{}, userID, "user"); err != nil { + if errors.Is(err, consts.ErrNotFound) { + return fmt.Errorf("%w: user not found", consts.ErrNotFound) + } + return fmt.Errorf("failed to get user: %w", err) + } + + rows, err := repo.deleteUserCascade(userID) + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("%w: user id %d not found", consts.ErrNotFound, userID) + } + return nil + }) +} + +func (s *Service) GetUserDetail(_ context.Context, userID int) (*UserDetailResp, error) { + user, err := s.repo.getUserDetailBase(userID) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return nil, fmt.Errorf("%w: user with ID %d not found", consts.ErrNotFound, userID) + } + return nil, fmt.Errorf("failed to get user: %w", err) + } + + resp := NewUserDetailResp(user) + + globalRoles, permissions, userContainers, userDatasets, userProjects, err := s.repo.loadUserDetailRelations(user.ID) + if err != nil { + return nil, fmt.Errorf("failed to get user detail relations: %w", err) + } + resp.GlobalRoles = make([]rbac.RoleResp, len(globalRoles)) + for i, role := range globalRoles { + resp.GlobalRoles[i] = *rbac.NewRoleResp(&role) + } + + resp.Permissions = make([]rbac.PermissionResp, len(permissions)) + for i, permission := range permissions { + resp.Permissions[i] = *rbac.NewPermissionResp(&permission) + } + + containerRoles, datasetRoles, projectRoles := buildUserResourceRoles(userContainers, userDatasets, userProjects) + resp.ContainerRoles = containerRoles + resp.DatasetRoles = datasetRoles + resp.ProjectRoles = projectRoles + + return resp, nil +} + +func (s *Service) ListUsers(_ context.Context, req *ListUserReq) (*dto.ListResp[UserResp], error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + limit, offset := req.ToGormParams() + users, total, err := s.repo.listUserViews(limit, offset, req.IsActive, req.Status) + if err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + + items := make([]UserResp, len(users)) + for i, user := range users { + items[i] = *NewUserResp(&user) + } + + return &dto.ListResp[UserResp]{ + Items: items, + Pagination: req.ConvertToPaginationInfo(total), + }, nil +} + +func (s *Service) UpdateUser(_ context.Context, req *UpdateUserReq, userID int) (*UserResp, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + var updatedUser *model.User + err := s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + user, err := repo.updateMutableUser(userID, func(existingUser *model.User) { + req.PatchUserModel(existingUser) + }) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return fmt.Errorf("%w: user not found", consts.ErrNotFound) + } + return fmt.Errorf("failed to get user: %w", err) + } + + updatedUser = user + return nil + }) + if err != nil { + return nil, err + } + + return NewUserResp(updatedUser), nil +} + +func (s *Service) AssignRole(_ context.Context, userID, roleID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if err := repo.assignGlobalRole(userID, roleID); err != nil { + if errors.Is(err, consts.ErrNotFound) { + if userErr := repo.ensureActiveRecordExists(&model.User{}, userID, "user"); userErr != nil { + return fmt.Errorf("%w: user not found", consts.ErrNotFound) + } + return fmt.Errorf("%w: role not found", consts.ErrNotFound) + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: user already has this role", consts.ErrAlreadyExists) + } + return err + } + return nil + }) +} + +func (s *Service) RemoveRole(_ context.Context, userID, roleID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if err := repo.removeGlobalRole(userID, roleID); err != nil { + if errors.Is(err, consts.ErrNotFound) { + if userErr := repo.ensureActiveRecordExists(&model.User{}, userID, "user"); userErr != nil { + return fmt.Errorf("%w: user not found", consts.ErrNotFound) + } + return fmt.Errorf("%w: role not found", consts.ErrNotFound) + } + return err + } + return nil + }) +} + +func (s *Service) AssignPermissions(_ context.Context, req *AssignUserPermissionReq, userID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + userPermissions, err := repo.buildUserPermissions(userID, req.Items) + if err != nil { + if errors.Is(err, consts.ErrNotFound) { + return fmt.Errorf("%w: failed to resolve permission assignment targets", consts.ErrNotFound) + } + return err + } + + if err := repo.batchCreateUserPermissions(userPermissions); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: user already has one or more of these permissions", consts.ErrAlreadyExists) + } + return fmt.Errorf("failed to assign permissions to user: %w", err) + } + return nil + }) +} + +func (s *Service) RemovePermissions(_ context.Context, req *RemoveUserPermissionReq, userID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if err := repo.batchDeleteUserPermissions(userID, req.PermissionIDs); err != nil { + if errors.Is(err, consts.ErrNotFound) { + return fmt.Errorf("%w: failed to resolve user or permissions", consts.ErrNotFound) + } + return fmt.Errorf("failed to remove permissions from user: %w", err) + } + return nil + }) +} + +func (s *Service) AssignContainer(_ context.Context, userID, containerID, roleID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if err := repo.assignContainerRole(userID, containerID, roleID); err != nil { + if errors.Is(err, consts.ErrNotFound) { + return fmt.Errorf("%w: user/container/role not found", consts.ErrNotFound) + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: user already assigned to this container", consts.ErrAlreadyExists) + } + return err + } + return nil + }) +} + +func (s *Service) RemoveContainer(_ context.Context, userID, containerID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + rows, err := repo.removeContainerRole(userID, containerID) + if err != nil { + return fmt.Errorf("failed to remove user from container: %w", err) + } + if rows == 0 { + return fmt.Errorf("%w: user is not assigned to this container", consts.ErrNotFound) + } + return nil + }) +} + +func (s *Service) AssignDataset(_ context.Context, userID, datasetID, roleID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if err := repo.assignDatasetRole(userID, datasetID, roleID); err != nil { + if errors.Is(err, consts.ErrNotFound) { + return fmt.Errorf("%w: user/dataset/role not found", consts.ErrNotFound) + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: user already assigned to this dataset", consts.ErrAlreadyExists) + } + return err + } + return nil + }) +} + +func (s *Service) RemoveDataset(_ context.Context, userID, datasetID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + rows, err := repo.removeDatasetRole(userID, datasetID) + if err != nil { + return fmt.Errorf("failed to remove user from dataset: %w", err) + } + if rows == 0 { + return fmt.Errorf("%w: user is not assigned to this dataset", consts.ErrNotFound) + } + return nil + }) +} + +func (s *Service) AssignProject(_ context.Context, userID, projectID, roleID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + if err := repo.assignProjectRole(userID, projectID, roleID); err != nil { + if errors.Is(err, consts.ErrNotFound) { + return fmt.Errorf("%w: user/project/role not found", consts.ErrNotFound) + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: user already assigned to this project", consts.ErrAlreadyExists) + } + return err + } + return nil + }) +} + +func (s *Service) RemoveProject(_ context.Context, userID, projectID int) error { + return s.repo.db.Transaction(func(tx *gorm.DB) error { + repo := NewRepository(tx) + rows, err := repo.removeProjectRole(userID, projectID) + if err != nil { + return fmt.Errorf("failed to remove user from project: %w", err) + } + if rows == 0 { + return fmt.Errorf("%w: user is not assigned to this project", consts.ErrNotFound) + } + return nil + }) +} + +func buildUserResourceRoles(userContainers []model.UserContainer, userDatasets []model.UserDataset, userProjects []model.UserProject) ([]UserContainerInfo, []UserDatasetInfo, []UserProjectInfo) { + containerRoles := make([]UserContainerInfo, 0, len(userContainers)) + for _, uc := range userContainers { + containerRoles = append(containerRoles, *NewUserContainerInfo(&uc)) + } + + datasetRoles := make([]UserDatasetInfo, 0, len(userDatasets)) + for _, ud := range userDatasets { + datasetRoles = append(datasetRoles, *NewUserDatasetInfo(&ud)) + } + + projectRoles := make([]UserProjectInfo, 0, len(userProjects)) + for _, up := range userProjects { + projectRoles = append(projectRoles, *NewUserProjectInfo(&up)) + } + + return containerRoles, datasetRoles, projectRoles +} diff --git a/src/module/user/service_test.go b/src/module/user/service_test.go new file mode 100644 index 00000000..b25ed074 --- /dev/null +++ b/src/module/user/service_test.go @@ -0,0 +1,197 @@ +package user + +import ( + "database/sql/driver" + "regexp" + "testing" + "time" + + "aegis/consts" + "aegis/utils" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +type passwordHashMatcher struct { + plain string +} + +func (m passwordHashMatcher) Match(v driver.Value) bool { + hash, ok := v.(string) + if !ok { + return false + } + return utils.VerifyPassword(m.plain, hash) +} + +func newUserTestService(t *testing.T) (*Service, sqlmock.Sqlmock, func()) { + t.Helper() + + sqlDB, mock, err := sqlmock.New() + require.NoError(t, err) + + db, err := gorm.Open(mysql.New(mysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + require.NoError(t, err) + + return NewService(NewRepository(db)), mock, func() { + _ = sqlDB.Close() + } +} + +func TestServiceCreateUserValidationError(t *testing.T) { + service := NewService(nil) + + _, err := service.CreateUser(t.Context(), &CreateUserReq{ + Username: "demo", + Email: "demo@example.com", + Password: "short", + }) + + require.Error(t, err) + require.ErrorContains(t, err, "validation failed") + require.ErrorContains(t, err, "password must be at least 8 characters") +} + +func TestServiceListUsersSuccess(t *testing.T) { + service, mock, cleanup := newUserTestService(t) + defer cleanup() + + now := time.Now() + mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM `users` WHERE status != ?")). + WithArgs(consts.CommonDeleted). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `users` WHERE status != ? LIMIT ?")). + WithArgs(consts.CommonDeleted, 20). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "username", "email", "password", "full_name", "avatar", "phone", "last_login_at", + "is_active", "status", "created_at", "updated_at", + }).AddRow(1, "demo", "demo@example.com", "hashed", "Demo User", "", "", nil, true, consts.CommonEnabled, now, now)) + + resp, err := service.ListUsers(t.Context(), &ListUserReq{}) + + require.NoError(t, err) + require.Len(t, resp.Items, 1) + require.Equal(t, "demo", resp.Items[0].Username) + require.Equal(t, 1, resp.Pagination.Page) + require.Equal(t, int(consts.PageSizeMedium), resp.Pagination.Size) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestServiceCreateUserSuccess(t *testing.T) { + service, mock, cleanup := newUserTestService(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `users` WHERE username = ? ORDER BY `users`.`id` LIMIT ?")). + WithArgs("demo", 1). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `users` WHERE email = ? ORDER BY `users`.`id` LIMIT ?")). + WithArgs("demo@example.com", 1). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `users` (`username`,`email`,`password`,`full_name`,`avatar`,`phone`,`last_login_at`,`is_active`,`status`,`created_at`,`updated_at`) VALUES (?,?,?,?,?,?,?,?,?,?,?)")). + WithArgs("demo", "demo@example.com", passwordHashMatcher{plain: "password123"}, "Demo User", "", "", nil, true, consts.CommonEnabled, sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(5, 1)) + mock.ExpectCommit() + + resp, err := service.CreateUser(t.Context(), &CreateUserReq{ + Username: "demo", + Email: "demo@example.com", + Password: "password123", + FullName: "Demo User", + }) + + require.NoError(t, err) + require.Equal(t, 5, resp.ID) + require.Equal(t, "demo", resp.Username) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestServiceGetUserDetailSuccess(t *testing.T) { + service, mock, cleanup := newUserTestService(t) + defer cleanup() + + now := time.Now() + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `users` WHERE id = ? ORDER BY `users`.`id` LIMIT ?")). + WithArgs(1, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "username", "email", "password", "full_name", "avatar", "phone", "last_login_at", + "is_active", "status", "created_at", "updated_at", + }).AddRow(1, "demo", "demo@example.com", "hashed", "Demo User", "", "", nil, true, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT `roles`.`id`,`roles`.`name`,`roles`.`display_name`,`roles`.`description`,`roles`.`is_system`,`roles`.`status`,`roles`.`created_at`,`roles`.`updated_at`,`roles`.`active_name` FROM `roles` JOIN user_roles ur ON ur.role_id = roles.id WHERE ur.user_id = ? AND roles.status = ?")). + WithArgs(1, consts.CommonEnabled). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "display_name", "description", "is_system", "status", "created_at", "updated_at", "active_name", + }).AddRow(2, "admin", "Admin", "", true, consts.CommonEnabled, now, now, "admin")) + mock.ExpectQuery(regexp.QuoteMeta("SELECT `permissions`.`id`,`permissions`.`name`,`permissions`.`display_name`,`permissions`.`description`,`permissions`.`action`,`permissions`.`scope`,`permissions`.`resource_id`,`permissions`.`is_system`,`permissions`.`status`,`permissions`.`created_at`,`permissions`.`updated_at`,`permissions`.`active_name` FROM `permissions` JOIN user_permissions up ON up.permission_id = permissions.id WHERE up.user_id = ? AND permissions.status = ?")). + WithArgs(1, consts.CommonEnabled). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "name", "display_name", "description", "action", "scope", "resource_id", "is_system", "status", "created_at", "updated_at", "active_name", + }).AddRow(3, "user.read", "User Read", "", consts.ActionRead, consts.ScopeAll, 1, true, consts.CommonEnabled, now, now, "user.read")) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_containers` WHERE user_id = ? AND status != ?")). + WithArgs(1, consts.CommonDeleted). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "container_id", "role_id", "status", "created_at", "updated_at"})) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_datasets` WHERE user_id = ? AND status != ?")). + WithArgs(1, consts.CommonDeleted). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "dataset_id", "role_id", "status", "created_at", "updated_at"})) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_projects` WHERE user_id = ? AND status != ?")). + WithArgs(1, consts.CommonDeleted). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "project_id", "role_id", "workspace_config", "status", "created_at", "updated_at", "active_user_project"})) + + resp, err := service.GetUserDetail(t.Context(), 1) + + require.NoError(t, err) + require.Equal(t, "demo", resp.Username) + require.Len(t, resp.GlobalRoles, 1) + require.Len(t, resp.Permissions, 1) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestServiceDeleteUserSuccess(t *testing.T) { + service, mock, cleanup := newUserTestService(t) + defer cleanup() + + now := time.Now() + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `users` WHERE id = ? AND status != ? ORDER BY `users`.`id` LIMIT ?")). + WithArgs(1, consts.CommonDeleted, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "username", "email", "password", "full_name", "avatar", "phone", "last_login_at", + "is_active", "status", "created_at", "updated_at", + }).AddRow(1, "demo", "demo@example.com", "hashed", "Demo User", "", "", nil, true, consts.CommonEnabled, now, now)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `users` WHERE id = ? AND status != ? ORDER BY `users`.`id` LIMIT ?")). + WithArgs(1, consts.CommonDeleted, 1). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "username", "email", "password", "full_name", "avatar", "phone", "last_login_at", + "is_active", "status", "created_at", "updated_at", + }).AddRow(1, "demo", "demo@example.com", "hashed", "Demo User", "", "", nil, true, consts.CommonEnabled, now, now)) + mock.ExpectExec(regexp.QuoteMeta("UPDATE `user_containers` SET `status`=?,`updated_at`=? WHERE user_id = ? AND status != ?")). + WithArgs(consts.CommonDeleted, sqlmock.AnyArg(), 1, consts.CommonDeleted). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(regexp.QuoteMeta("UPDATE `user_datasets` SET `status`=?,`updated_at`=? WHERE user_id = ? AND status != ?")). + WithArgs(consts.CommonDeleted, sqlmock.AnyArg(), 1, consts.CommonDeleted). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(regexp.QuoteMeta("UPDATE `user_projects` SET `status`=?,`updated_at`=? WHERE user_id = ? AND status != ?")). + WithArgs(consts.CommonDeleted, sqlmock.AnyArg(), 1, consts.CommonDeleted). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM `user_permissions` WHERE user_id = ?")). + WithArgs(1). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM `user_roles` WHERE user_id = ?")). + WithArgs(1). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(regexp.QuoteMeta("UPDATE `users` SET `status`=?,`updated_at`=? WHERE id = ? AND status != ?")). + WithArgs(consts.CommonDeleted, sqlmock.AnyArg(), 1, consts.CommonDeleted). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + err := service.DeleteUser(t.Context(), 1) + + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/src/proto/iam/v1/iam.pb.go b/src/proto/iam/v1/iam.pb.go new file mode 100644 index 00000000..40f21573 --- /dev/null +++ b/src/proto/iam/v1/iam.pb.go @@ -0,0 +1,2219 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: proto/iam/v1/iam.proto + +package iamv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type VerifyTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerifyTokenRequest) Reset() { + *x = VerifyTokenRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerifyTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerifyTokenRequest) ProtoMessage() {} + +func (x *VerifyTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerifyTokenRequest.ProtoReflect.Descriptor instead. +func (*VerifyTokenRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{0} +} + +func (x *VerifyTokenRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +type VerifyTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"` + TokenType string `protobuf:"bytes,2,opt,name=token_type,json=tokenType,proto3" json:"token_type,omitempty"` + UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Username string `protobuf:"bytes,4,opt,name=username,proto3" json:"username,omitempty"` + Email string `protobuf:"bytes,5,opt,name=email,proto3" json:"email,omitempty"` + IsActive bool `protobuf:"varint,6,opt,name=is_active,json=isActive,proto3" json:"is_active,omitempty"` + IsAdmin bool `protobuf:"varint,7,opt,name=is_admin,json=isAdmin,proto3" json:"is_admin,omitempty"` + Roles []string `protobuf:"bytes,8,rep,name=roles,proto3" json:"roles,omitempty"` + ExpiresAtUnix int64 `protobuf:"varint,9,opt,name=expires_at_unix,json=expiresAtUnix,proto3" json:"expires_at_unix,omitempty"` + AuthType string `protobuf:"bytes,10,opt,name=auth_type,json=authType,proto3" json:"auth_type,omitempty"` + KeyId int64 `protobuf:"varint,11,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty"` + TaskId string `protobuf:"bytes,12,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + ApiKeyScopes []string `protobuf:"bytes,13,rep,name=api_key_scopes,json=apiKeyScopes,proto3" json:"api_key_scopes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VerifyTokenResponse) Reset() { + *x = VerifyTokenResponse{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VerifyTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VerifyTokenResponse) ProtoMessage() {} + +func (x *VerifyTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VerifyTokenResponse.ProtoReflect.Descriptor instead. +func (*VerifyTokenResponse) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{1} +} + +func (x *VerifyTokenResponse) GetValid() bool { + if x != nil { + return x.Valid + } + return false +} + +func (x *VerifyTokenResponse) GetTokenType() string { + if x != nil { + return x.TokenType + } + return "" +} + +func (x *VerifyTokenResponse) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *VerifyTokenResponse) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *VerifyTokenResponse) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *VerifyTokenResponse) GetIsActive() bool { + if x != nil { + return x.IsActive + } + return false +} + +func (x *VerifyTokenResponse) GetIsAdmin() bool { + if x != nil { + return x.IsAdmin + } + return false +} + +func (x *VerifyTokenResponse) GetRoles() []string { + if x != nil { + return x.Roles + } + return nil +} + +func (x *VerifyTokenResponse) GetExpiresAtUnix() int64 { + if x != nil { + return x.ExpiresAtUnix + } + return 0 +} + +func (x *VerifyTokenResponse) GetAuthType() string { + if x != nil { + return x.AuthType + } + return "" +} + +func (x *VerifyTokenResponse) GetKeyId() int64 { + if x != nil { + return x.KeyId + } + return 0 +} + +func (x *VerifyTokenResponse) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *VerifyTokenResponse) GetApiKeyScopes() []string { + if x != nil { + return x.ApiKeyScopes + } + return nil +} + +type CheckPermissionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Action string `protobuf:"bytes,2,opt,name=action,proto3" json:"action,omitempty"` + Scope string `protobuf:"bytes,3,opt,name=scope,proto3" json:"scope,omitempty"` + ResourceName string `protobuf:"bytes,4,opt,name=resource_name,json=resourceName,proto3" json:"resource_name,omitempty"` + TeamId int64 `protobuf:"varint,5,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + ProjectId int64 `protobuf:"varint,6,opt,name=project_id,json=projectId,proto3" json:"project_id,omitempty"` + ContainerId int64 `protobuf:"varint,7,opt,name=container_id,json=containerId,proto3" json:"container_id,omitempty"` + DatasetId int64 `protobuf:"varint,8,opt,name=dataset_id,json=datasetId,proto3" json:"dataset_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckPermissionRequest) Reset() { + *x = CheckPermissionRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckPermissionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckPermissionRequest) ProtoMessage() {} + +func (x *CheckPermissionRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CheckPermissionRequest.ProtoReflect.Descriptor instead. +func (*CheckPermissionRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{2} +} + +func (x *CheckPermissionRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *CheckPermissionRequest) GetAction() string { + if x != nil { + return x.Action + } + return "" +} + +func (x *CheckPermissionRequest) GetScope() string { + if x != nil { + return x.Scope + } + return "" +} + +func (x *CheckPermissionRequest) GetResourceName() string { + if x != nil { + return x.ResourceName + } + return "" +} + +func (x *CheckPermissionRequest) GetTeamId() int64 { + if x != nil { + return x.TeamId + } + return 0 +} + +func (x *CheckPermissionRequest) GetProjectId() int64 { + if x != nil { + return x.ProjectId + } + return 0 +} + +func (x *CheckPermissionRequest) GetContainerId() int64 { + if x != nil { + return x.ContainerId + } + return 0 +} + +func (x *CheckPermissionRequest) GetDatasetId() int64 { + if x != nil { + return x.DatasetId + } + return 0 +} + +type CheckPermissionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Allowed bool `protobuf:"varint,1,opt,name=allowed,proto3" json:"allowed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckPermissionResponse) Reset() { + *x = CheckPermissionResponse{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckPermissionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckPermissionResponse) ProtoMessage() {} + +func (x *CheckPermissionResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CheckPermissionResponse.ProtoReflect.Descriptor instead. +func (*CheckPermissionResponse) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{3} +} + +func (x *CheckPermissionResponse) GetAllowed() bool { + if x != nil { + return x.Allowed + } + return false +} + +type UserTeamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + TeamId int64 `protobuf:"varint,2,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserTeamRequest) Reset() { + *x = UserTeamRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserTeamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserTeamRequest) ProtoMessage() {} + +func (x *UserTeamRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserTeamRequest.ProtoReflect.Descriptor instead. +func (*UserTeamRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{4} +} + +func (x *UserTeamRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *UserTeamRequest) GetTeamId() int64 { + if x != nil { + return x.TeamId + } + return 0 +} + +type TeamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TeamId int64 `protobuf:"varint,1,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TeamRequest) Reset() { + *x = TeamRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TeamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TeamRequest) ProtoMessage() {} + +func (x *TeamRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TeamRequest.ProtoReflect.Descriptor instead. +func (*TeamRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{5} +} + +func (x *TeamRequest) GetTeamId() int64 { + if x != nil { + return x.TeamId + } + return 0 +} + +type UserProjectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ProjectId int64 `protobuf:"varint,2,opt,name=project_id,json=projectId,proto3" json:"project_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserProjectRequest) Reset() { + *x = UserProjectRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserProjectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserProjectRequest) ProtoMessage() {} + +func (x *UserProjectRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserProjectRequest.ProtoReflect.Descriptor instead. +func (*UserProjectRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{6} +} + +func (x *UserProjectRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *UserProjectRequest) GetProjectId() int64 { + if x != nil { + return x.ProjectId + } + return 0 +} + +type BoolResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value bool `protobuf:"varint,1,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BoolResponse) Reset() { + *x = BoolResponse{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BoolResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BoolResponse) ProtoMessage() {} + +func (x *BoolResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BoolResponse.ProtoReflect.Descriptor instead. +func (*BoolResponse) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{7} +} + +func (x *BoolResponse) GetValue() bool { + if x != nil { + return x.Value + } + return false +} + +type ExchangeAPIKeyTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + KeyId string `protobuf:"bytes,1,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty"` + Timestamp string `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + Nonce string `protobuf:"bytes,3,opt,name=nonce,proto3" json:"nonce,omitempty"` + Signature string `protobuf:"bytes,4,opt,name=signature,proto3" json:"signature,omitempty"` + Method string `protobuf:"bytes,5,opt,name=method,proto3" json:"method,omitempty"` + Path string `protobuf:"bytes,6,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExchangeAPIKeyTokenRequest) Reset() { + *x = ExchangeAPIKeyTokenRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExchangeAPIKeyTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExchangeAPIKeyTokenRequest) ProtoMessage() {} + +func (x *ExchangeAPIKeyTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExchangeAPIKeyTokenRequest.ProtoReflect.Descriptor instead. +func (*ExchangeAPIKeyTokenRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{8} +} + +func (x *ExchangeAPIKeyTokenRequest) GetKeyId() string { + if x != nil { + return x.KeyId + } + return "" +} + +func (x *ExchangeAPIKeyTokenRequest) GetTimestamp() string { + if x != nil { + return x.Timestamp + } + return "" +} + +func (x *ExchangeAPIKeyTokenRequest) GetNonce() string { + if x != nil { + return x.Nonce + } + return "" +} + +func (x *ExchangeAPIKeyTokenRequest) GetSignature() string { + if x != nil { + return x.Signature + } + return "" +} + +func (x *ExchangeAPIKeyTokenRequest) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *ExchangeAPIKeyTokenRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type ExchangeAPIKeyTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + TokenType string `protobuf:"bytes,2,opt,name=token_type,json=tokenType,proto3" json:"token_type,omitempty"` + ExpiresAtUnix int64 `protobuf:"varint,3,opt,name=expires_at_unix,json=expiresAtUnix,proto3" json:"expires_at_unix,omitempty"` + AuthType string `protobuf:"bytes,4,opt,name=auth_type,json=authType,proto3" json:"auth_type,omitempty"` + KeyId string `protobuf:"bytes,5,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExchangeAPIKeyTokenResponse) Reset() { + *x = ExchangeAPIKeyTokenResponse{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExchangeAPIKeyTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExchangeAPIKeyTokenResponse) ProtoMessage() {} + +func (x *ExchangeAPIKeyTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExchangeAPIKeyTokenResponse.ProtoReflect.Descriptor instead. +func (*ExchangeAPIKeyTokenResponse) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{9} +} + +func (x *ExchangeAPIKeyTokenResponse) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *ExchangeAPIKeyTokenResponse) GetTokenType() string { + if x != nil { + return x.TokenType + } + return "" +} + +func (x *ExchangeAPIKeyTokenResponse) GetExpiresAtUnix() int64 { + if x != nil { + return x.ExpiresAtUnix + } + return 0 +} + +func (x *ExchangeAPIKeyTokenResponse) GetAuthType() string { + if x != nil { + return x.AuthType + } + return "" +} + +func (x *ExchangeAPIKeyTokenResponse) GetKeyId() string { + if x != nil { + return x.KeyId + } + return "" +} + +type MutationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Body *structpb.Struct `protobuf:"bytes,1,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MutationRequest) Reset() { + *x = MutationRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MutationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MutationRequest) ProtoMessage() {} + +func (x *MutationRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MutationRequest.ProtoReflect.Descriptor instead. +func (*MutationRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{10} +} + +func (x *MutationRequest) GetBody() *structpb.Struct { + if x != nil { + return x.Body + } + return nil +} + +type QueryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Query *structpb.Struct `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QueryRequest) Reset() { + *x = QueryRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QueryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryRequest) ProtoMessage() {} + +func (x *QueryRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryRequest.ProtoReflect.Descriptor instead. +func (*QueryRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{11} +} + +func (x *QueryRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type IDRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IDRequest) Reset() { + *x = IDRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IDRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IDRequest) ProtoMessage() {} + +func (x *IDRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IDRequest.ProtoReflect.Descriptor instead. +func (*IDRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{12} +} + +func (x *IDRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +type UpdateByIDRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Body *structpb.Struct `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateByIDRequest) Reset() { + *x = UpdateByIDRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateByIDRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateByIDRequest) ProtoMessage() {} + +func (x *UpdateByIDRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateByIDRequest.ProtoReflect.Descriptor instead. +func (*UpdateByIDRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{13} +} + +func (x *UpdateByIDRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *UpdateByIDRequest) GetBody() *structpb.Struct { + if x != nil { + return x.Body + } + return nil +} + +type UserIDRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserIDRequest) Reset() { + *x = UserIDRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserIDRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserIDRequest) ProtoMessage() {} + +func (x *UserIDRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserIDRequest.ProtoReflect.Descriptor instead. +func (*UserIDRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{14} +} + +func (x *UserIDRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +type UserQueryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Query *structpb.Struct `protobuf:"bytes,2,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserQueryRequest) Reset() { + *x = UserQueryRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserQueryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserQueryRequest) ProtoMessage() {} + +func (x *UserQueryRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserQueryRequest.ProtoReflect.Descriptor instead. +func (*UserQueryRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{15} +} + +func (x *UserQueryRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *UserQueryRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type UserBodyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Body *structpb.Struct `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserBodyRequest) Reset() { + *x = UserBodyRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserBodyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserBodyRequest) ProtoMessage() {} + +func (x *UserBodyRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserBodyRequest.ProtoReflect.Descriptor instead. +func (*UserBodyRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{16} +} + +func (x *UserBodyRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *UserBodyRequest) GetBody() *structpb.Struct { + if x != nil { + return x.Body + } + return nil +} + +type UserScopedIDRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Id int64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserScopedIDRequest) Reset() { + *x = UserScopedIDRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserScopedIDRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserScopedIDRequest) ProtoMessage() {} + +func (x *UserScopedIDRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserScopedIDRequest.ProtoReflect.Descriptor instead. +func (*UserScopedIDRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{17} +} + +func (x *UserScopedIDRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *UserScopedIDRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +type UserRoleBindingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + RoleId int64 `protobuf:"varint,2,opt,name=role_id,json=roleId,proto3" json:"role_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserRoleBindingRequest) Reset() { + *x = UserRoleBindingRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserRoleBindingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserRoleBindingRequest) ProtoMessage() {} + +func (x *UserRoleBindingRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserRoleBindingRequest.ProtoReflect.Descriptor instead. +func (*UserRoleBindingRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{18} +} + +func (x *UserRoleBindingRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *UserRoleBindingRequest) GetRoleId() int64 { + if x != nil { + return x.RoleId + } + return 0 +} + +type UserResourceBindingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ResourceId int64 `protobuf:"varint,2,opt,name=resource_id,json=resourceId,proto3" json:"resource_id,omitempty"` + RoleId int64 `protobuf:"varint,3,opt,name=role_id,json=roleId,proto3" json:"role_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserResourceBindingRequest) Reset() { + *x = UserResourceBindingRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserResourceBindingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserResourceBindingRequest) ProtoMessage() {} + +func (x *UserResourceBindingRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserResourceBindingRequest.ProtoReflect.Descriptor instead. +func (*UserResourceBindingRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{19} +} + +func (x *UserResourceBindingRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *UserResourceBindingRequest) GetResourceId() int64 { + if x != nil { + return x.ResourceId + } + return 0 +} + +func (x *UserResourceBindingRequest) GetRoleId() int64 { + if x != nil { + return x.RoleId + } + return 0 +} + +type LogoutRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + TokenId string `protobuf:"bytes,2,opt,name=token_id,json=tokenId,proto3" json:"token_id,omitempty"` + ExpiresAtUnix int64 `protobuf:"varint,3,opt,name=expires_at_unix,json=expiresAtUnix,proto3" json:"expires_at_unix,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LogoutRequest) Reset() { + *x = LogoutRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LogoutRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LogoutRequest) ProtoMessage() {} + +func (x *LogoutRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. +func (*LogoutRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{20} +} + +func (x *LogoutRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *LogoutRequest) GetTokenId() string { + if x != nil { + return x.TokenId + } + return "" +} + +func (x *LogoutRequest) GetExpiresAtUnix() int64 { + if x != nil { + return x.ExpiresAtUnix + } + return 0 +} + +type RolePermissionsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RoleId int64 `protobuf:"varint,1,opt,name=role_id,json=roleId,proto3" json:"role_id,omitempty"` + PermissionIds []int64 `protobuf:"varint,2,rep,packed,name=permission_ids,json=permissionIds,proto3" json:"permission_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RolePermissionsRequest) Reset() { + *x = RolePermissionsRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RolePermissionsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RolePermissionsRequest) ProtoMessage() {} + +func (x *RolePermissionsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RolePermissionsRequest.ProtoReflect.Descriptor instead. +func (*RolePermissionsRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{21} +} + +func (x *RolePermissionsRequest) GetRoleId() int64 { + if x != nil { + return x.RoleId + } + return 0 +} + +func (x *RolePermissionsRequest) GetPermissionIds() []int64 { + if x != nil { + return x.PermissionIds + } + return nil +} + +type CreateTeamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Body *structpb.Struct `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateTeamRequest) Reset() { + *x = CreateTeamRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateTeamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateTeamRequest) ProtoMessage() {} + +func (x *CreateTeamRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateTeamRequest.ProtoReflect.Descriptor instead. +func (*CreateTeamRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{22} +} + +func (x *CreateTeamRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *CreateTeamRequest) GetBody() *structpb.Struct { + if x != nil { + return x.Body + } + return nil +} + +type ListTeamsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + IsAdmin bool `protobuf:"varint,2,opt,name=is_admin,json=isAdmin,proto3" json:"is_admin,omitempty"` + Query *structpb.Struct `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTeamsRequest) Reset() { + *x = ListTeamsRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTeamsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTeamsRequest) ProtoMessage() {} + +func (x *ListTeamsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTeamsRequest.ProtoReflect.Descriptor instead. +func (*ListTeamsRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{23} +} + +func (x *ListTeamsRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *ListTeamsRequest) GetIsAdmin() bool { + if x != nil { + return x.IsAdmin + } + return false +} + +func (x *ListTeamsRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type UpdateTeamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TeamId int64 `protobuf:"varint,1,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + Body *structpb.Struct `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateTeamRequest) Reset() { + *x = UpdateTeamRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateTeamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateTeamRequest) ProtoMessage() {} + +func (x *UpdateTeamRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateTeamRequest.ProtoReflect.Descriptor instead. +func (*UpdateTeamRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{24} +} + +func (x *UpdateTeamRequest) GetTeamId() int64 { + if x != nil { + return x.TeamId + } + return 0 +} + +func (x *UpdateTeamRequest) GetBody() *structpb.Struct { + if x != nil { + return x.Body + } + return nil +} + +type ListTeamProjectsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TeamId int64 `protobuf:"varint,1,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + Query *structpb.Struct `protobuf:"bytes,2,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTeamProjectsRequest) Reset() { + *x = ListTeamProjectsRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTeamProjectsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTeamProjectsRequest) ProtoMessage() {} + +func (x *ListTeamProjectsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTeamProjectsRequest.ProtoReflect.Descriptor instead. +func (*ListTeamProjectsRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{25} +} + +func (x *ListTeamProjectsRequest) GetTeamId() int64 { + if x != nil { + return x.TeamId + } + return 0 +} + +func (x *ListTeamProjectsRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type AddTeamMemberRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TeamId int64 `protobuf:"varint,1,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + Body *structpb.Struct `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddTeamMemberRequest) Reset() { + *x = AddTeamMemberRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddTeamMemberRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddTeamMemberRequest) ProtoMessage() {} + +func (x *AddTeamMemberRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddTeamMemberRequest.ProtoReflect.Descriptor instead. +func (*AddTeamMemberRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{26} +} + +func (x *AddTeamMemberRequest) GetTeamId() int64 { + if x != nil { + return x.TeamId + } + return 0 +} + +func (x *AddTeamMemberRequest) GetBody() *structpb.Struct { + if x != nil { + return x.Body + } + return nil +} + +type RemoveTeamMemberRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TeamId int64 `protobuf:"varint,1,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + CurrentUserId int64 `protobuf:"varint,2,opt,name=current_user_id,json=currentUserId,proto3" json:"current_user_id,omitempty"` + TargetUserId int64 `protobuf:"varint,3,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveTeamMemberRequest) Reset() { + *x = RemoveTeamMemberRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveTeamMemberRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveTeamMemberRequest) ProtoMessage() {} + +func (x *RemoveTeamMemberRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveTeamMemberRequest.ProtoReflect.Descriptor instead. +func (*RemoveTeamMemberRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{27} +} + +func (x *RemoveTeamMemberRequest) GetTeamId() int64 { + if x != nil { + return x.TeamId + } + return 0 +} + +func (x *RemoveTeamMemberRequest) GetCurrentUserId() int64 { + if x != nil { + return x.CurrentUserId + } + return 0 +} + +func (x *RemoveTeamMemberRequest) GetTargetUserId() int64 { + if x != nil { + return x.TargetUserId + } + return 0 +} + +type UpdateTeamMemberRoleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TeamId int64 `protobuf:"varint,1,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + TargetUserId int64 `protobuf:"varint,2,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"` + CurrentUserId int64 `protobuf:"varint,3,opt,name=current_user_id,json=currentUserId,proto3" json:"current_user_id,omitempty"` + Body *structpb.Struct `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateTeamMemberRoleRequest) Reset() { + *x = UpdateTeamMemberRoleRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateTeamMemberRoleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateTeamMemberRoleRequest) ProtoMessage() {} + +func (x *UpdateTeamMemberRoleRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateTeamMemberRoleRequest.ProtoReflect.Descriptor instead. +func (*UpdateTeamMemberRoleRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{28} +} + +func (x *UpdateTeamMemberRoleRequest) GetTeamId() int64 { + if x != nil { + return x.TeamId + } + return 0 +} + +func (x *UpdateTeamMemberRoleRequest) GetTargetUserId() int64 { + if x != nil { + return x.TargetUserId + } + return 0 +} + +func (x *UpdateTeamMemberRoleRequest) GetCurrentUserId() int64 { + if x != nil { + return x.CurrentUserId + } + return 0 +} + +func (x *UpdateTeamMemberRoleRequest) GetBody() *structpb.Struct { + if x != nil { + return x.Body + } + return nil +} + +type ListTeamMembersRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TeamId int64 `protobuf:"varint,1,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + Query *structpb.Struct `protobuf:"bytes,2,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTeamMembersRequest) Reset() { + *x = ListTeamMembersRequest{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTeamMembersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTeamMembersRequest) ProtoMessage() {} + +func (x *ListTeamMembersRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTeamMembersRequest.ProtoReflect.Descriptor instead. +func (*ListTeamMembersRequest) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{29} +} + +func (x *ListTeamMembersRequest) GetTeamId() int64 { + if x != nil { + return x.TeamId + } + return 0 +} + +func (x *ListTeamMembersRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type StructResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data *structpb.Struct `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StructResponse) Reset() { + *x = StructResponse{} + mi := &file_proto_iam_v1_iam_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StructResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StructResponse) ProtoMessage() {} + +func (x *StructResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_iam_v1_iam_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StructResponse.ProtoReflect.Descriptor instead. +func (*StructResponse) Descriptor() ([]byte, []int) { + return file_proto_iam_v1_iam_proto_rawDescGZIP(), []int{30} +} + +func (x *StructResponse) GetData() *structpb.Struct { + if x != nil { + return x.Data + } + return nil +} + +var File_proto_iam_v1_iam_proto protoreflect.FileDescriptor + +const file_proto_iam_v1_iam_proto_rawDesc = "" + + "\n" + + "\x16proto/iam/v1/iam.proto\x12\x06iam.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"*\n" + + "\x12VerifyTokenRequest\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\"\xfe\x02\n" + + "\x13VerifyTokenResponse\x12\x14\n" + + "\x05valid\x18\x01 \x01(\bR\x05valid\x12\x1d\n" + + "\n" + + "token_type\x18\x02 \x01(\tR\ttokenType\x12\x17\n" + + "\auser_id\x18\x03 \x01(\x03R\x06userId\x12\x1a\n" + + "\busername\x18\x04 \x01(\tR\busername\x12\x14\n" + + "\x05email\x18\x05 \x01(\tR\x05email\x12\x1b\n" + + "\tis_active\x18\x06 \x01(\bR\bisActive\x12\x19\n" + + "\bis_admin\x18\a \x01(\bR\aisAdmin\x12\x14\n" + + "\x05roles\x18\b \x03(\tR\x05roles\x12&\n" + + "\x0fexpires_at_unix\x18\t \x01(\x03R\rexpiresAtUnix\x12\x1b\n" + + "\tauth_type\x18\n" + + " \x01(\tR\bauthType\x12\x15\n" + + "\x06key_id\x18\v \x01(\x03R\x05keyId\x12\x17\n" + + "\atask_id\x18\f \x01(\tR\x06taskId\x12$\n" + + "\x0eapi_key_scopes\x18\r \x03(\tR\fapiKeyScopes\"\xfe\x01\n" + + "\x16CheckPermissionRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x16\n" + + "\x06action\x18\x02 \x01(\tR\x06action\x12\x14\n" + + "\x05scope\x18\x03 \x01(\tR\x05scope\x12#\n" + + "\rresource_name\x18\x04 \x01(\tR\fresourceName\x12\x17\n" + + "\ateam_id\x18\x05 \x01(\x03R\x06teamId\x12\x1d\n" + + "\n" + + "project_id\x18\x06 \x01(\x03R\tprojectId\x12!\n" + + "\fcontainer_id\x18\a \x01(\x03R\vcontainerId\x12\x1d\n" + + "\n" + + "dataset_id\x18\b \x01(\x03R\tdatasetId\"3\n" + + "\x17CheckPermissionResponse\x12\x18\n" + + "\aallowed\x18\x01 \x01(\bR\aallowed\"C\n" + + "\x0fUserTeamRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x17\n" + + "\ateam_id\x18\x02 \x01(\x03R\x06teamId\"&\n" + + "\vTeamRequest\x12\x17\n" + + "\ateam_id\x18\x01 \x01(\x03R\x06teamId\"L\n" + + "\x12UserProjectRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x1d\n" + + "\n" + + "project_id\x18\x02 \x01(\x03R\tprojectId\"$\n" + + "\fBoolResponse\x12\x14\n" + + "\x05value\x18\x01 \x01(\bR\x05value\"\xb1\x01\n" + + "\x1aExchangeAPIKeyTokenRequest\x12\x15\n" + + "\x06key_id\x18\x01 \x01(\tR\x05keyId\x12\x1c\n" + + "\ttimestamp\x18\x02 \x01(\tR\ttimestamp\x12\x14\n" + + "\x05nonce\x18\x03 \x01(\tR\x05nonce\x12\x1c\n" + + "\tsignature\x18\x04 \x01(\tR\tsignature\x12\x16\n" + + "\x06method\x18\x05 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x06 \x01(\tR\x04path\"\xae\x01\n" + + "\x1bExchangeAPIKeyTokenResponse\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1d\n" + + "\n" + + "token_type\x18\x02 \x01(\tR\ttokenType\x12&\n" + + "\x0fexpires_at_unix\x18\x03 \x01(\x03R\rexpiresAtUnix\x12\x1b\n" + + "\tauth_type\x18\x04 \x01(\tR\bauthType\x12\x15\n" + + "\x06key_id\x18\x05 \x01(\tR\x05keyId\">\n" + + "\x0fMutationRequest\x12+\n" + + "\x04body\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x04body\"=\n" + + "\fQueryRequest\x12-\n" + + "\x05query\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x05query\"\x1b\n" + + "\tIDRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\"P\n" + + "\x11UpdateByIDRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\x12+\n" + + "\x04body\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x04body\"(\n" + + "\rUserIDRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\x03R\x06userId\"Z\n" + + "\x10UserQueryRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12-\n" + + "\x05query\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x05query\"W\n" + + "\x0fUserBodyRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12+\n" + + "\x04body\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x04body\">\n" + + "\x13UserScopedIDRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x0e\n" + + "\x02id\x18\x02 \x01(\x03R\x02id\"J\n" + + "\x16UserRoleBindingRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x17\n" + + "\arole_id\x18\x02 \x01(\x03R\x06roleId\"o\n" + + "\x1aUserResourceBindingRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x1f\n" + + "\vresource_id\x18\x02 \x01(\x03R\n" + + "resourceId\x12\x17\n" + + "\arole_id\x18\x03 \x01(\x03R\x06roleId\"k\n" + + "\rLogoutRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x19\n" + + "\btoken_id\x18\x02 \x01(\tR\atokenId\x12&\n" + + "\x0fexpires_at_unix\x18\x03 \x01(\x03R\rexpiresAtUnix\"X\n" + + "\x16RolePermissionsRequest\x12\x17\n" + + "\arole_id\x18\x01 \x01(\x03R\x06roleId\x12%\n" + + "\x0epermission_ids\x18\x02 \x03(\x03R\rpermissionIds\"Y\n" + + "\x11CreateTeamRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12+\n" + + "\x04body\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x04body\"u\n" + + "\x10ListTeamsRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x19\n" + + "\bis_admin\x18\x02 \x01(\bR\aisAdmin\x12-\n" + + "\x05query\x18\x03 \x01(\v2\x17.google.protobuf.StructR\x05query\"Y\n" + + "\x11UpdateTeamRequest\x12\x17\n" + + "\ateam_id\x18\x01 \x01(\x03R\x06teamId\x12+\n" + + "\x04body\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x04body\"a\n" + + "\x17ListTeamProjectsRequest\x12\x17\n" + + "\ateam_id\x18\x01 \x01(\x03R\x06teamId\x12-\n" + + "\x05query\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x05query\"\\\n" + + "\x14AddTeamMemberRequest\x12\x17\n" + + "\ateam_id\x18\x01 \x01(\x03R\x06teamId\x12+\n" + + "\x04body\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x04body\"\x80\x01\n" + + "\x17RemoveTeamMemberRequest\x12\x17\n" + + "\ateam_id\x18\x01 \x01(\x03R\x06teamId\x12&\n" + + "\x0fcurrent_user_id\x18\x02 \x01(\x03R\rcurrentUserId\x12$\n" + + "\x0etarget_user_id\x18\x03 \x01(\x03R\ftargetUserId\"\xb1\x01\n" + + "\x1bUpdateTeamMemberRoleRequest\x12\x17\n" + + "\ateam_id\x18\x01 \x01(\x03R\x06teamId\x12$\n" + + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12&\n" + + "\x0fcurrent_user_id\x18\x03 \x01(\x03R\rcurrentUserId\x12+\n" + + "\x04body\x18\x04 \x01(\v2\x17.google.protobuf.StructR\x04body\"`\n" + + "\x16ListTeamMembersRequest\x12\x17\n" + + "\ateam_id\x18\x01 \x01(\x03R\x06teamId\x12-\n" + + "\x05query\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x05query\"=\n" + + "\x0eStructResponse\x12+\n" + + "\x04data\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x04data2\xca \n" + + "\n" + + "IAMService\x12F\n" + + "\vVerifyToken\x12\x1a.iam.v1.VerifyTokenRequest\x1a\x1b.iam.v1.VerifyTokenResponse\x12R\n" + + "\x0fCheckPermission\x12\x1e.iam.v1.CheckPermissionRequest\x1a\x1f.iam.v1.CheckPermissionResponse\x128\n" + + "\x05Login\x12\x17.iam.v1.MutationRequest\x1a\x16.iam.v1.StructResponse\x12;\n" + + "\bRegister\x12\x17.iam.v1.MutationRequest\x1a\x16.iam.v1.StructResponse\x12?\n" + + "\fRefreshToken\x12\x17.iam.v1.MutationRequest\x1a\x16.iam.v1.StructResponse\x127\n" + + "\x06Logout\x12\x15.iam.v1.LogoutRequest\x1a\x16.google.protobuf.Empty\x12A\n" + + "\x0eChangePassword\x12\x17.iam.v1.UserBodyRequest\x1a\x16.google.protobuf.Empty\x12;\n" + + "\n" + + "GetProfile\x12\x15.iam.v1.UserIDRequest\x1a\x16.iam.v1.StructResponse\x12?\n" + + "\fCreateAPIKey\x12\x17.iam.v1.UserBodyRequest\x1a\x16.iam.v1.StructResponse\x12?\n" + + "\vListAPIKeys\x12\x18.iam.v1.UserQueryRequest\x1a\x16.iam.v1.StructResponse\x12@\n" + + "\tGetAPIKey\x12\x1b.iam.v1.UserScopedIDRequest\x1a\x16.iam.v1.StructResponse\x12C\n" + + "\fDeleteAPIKey\x12\x1b.iam.v1.UserScopedIDRequest\x1a\x16.google.protobuf.Empty\x12D\n" + + "\rDisableAPIKey\x12\x1b.iam.v1.UserScopedIDRequest\x1a\x16.google.protobuf.Empty\x12C\n" + + "\fEnableAPIKey\x12\x1b.iam.v1.UserScopedIDRequest\x1a\x16.google.protobuf.Empty\x12C\n" + + "\fRevokeAPIKey\x12\x1b.iam.v1.UserScopedIDRequest\x1a\x16.google.protobuf.Empty\x12C\n" + + "\fRotateAPIKey\x12\x1b.iam.v1.UserScopedIDRequest\x1a\x16.iam.v1.StructResponse\x12@\n" + + "\x0fIsUserTeamAdmin\x12\x17.iam.v1.UserTeamRequest\x1a\x14.iam.v1.BoolResponse\x12=\n" + + "\fIsUserInTeam\x12\x17.iam.v1.UserTeamRequest\x1a\x14.iam.v1.BoolResponse\x129\n" + + "\fIsTeamPublic\x12\x13.iam.v1.TeamRequest\x1a\x14.iam.v1.BoolResponse\x12F\n" + + "\x12IsUserProjectAdmin\x12\x1a.iam.v1.UserProjectRequest\x1a\x14.iam.v1.BoolResponse\x12C\n" + + "\x0fIsUserInProject\x12\x1a.iam.v1.UserProjectRequest\x1a\x14.iam.v1.BoolResponse\x12^\n" + + "\x13ExchangeAPIKeyToken\x12\".iam.v1.ExchangeAPIKeyTokenRequest\x1a#.iam.v1.ExchangeAPIKeyTokenResponse\x12=\n" + + "\n" + + "CreateUser\x12\x17.iam.v1.MutationRequest\x1a\x16.iam.v1.StructResponse\x127\n" + + "\n" + + "DeleteUser\x12\x11.iam.v1.IDRequest\x1a\x16.google.protobuf.Empty\x124\n" + + "\aGetUser\x12\x11.iam.v1.IDRequest\x1a\x16.iam.v1.StructResponse\x129\n" + + "\tListUsers\x12\x14.iam.v1.QueryRequest\x1a\x16.iam.v1.StructResponse\x12?\n" + + "\n" + + "UpdateUser\x12\x19.iam.v1.UpdateByIDRequest\x1a\x16.iam.v1.StructResponse\x12H\n" + + "\x0eAssignUserRole\x12\x1e.iam.v1.UserRoleBindingRequest\x1a\x16.google.protobuf.Empty\x12H\n" + + "\x0eRemoveUserRole\x12\x1e.iam.v1.UserRoleBindingRequest\x1a\x16.google.protobuf.Empty\x12H\n" + + "\x15AssignUserPermissions\x12\x17.iam.v1.UserBodyRequest\x1a\x16.google.protobuf.Empty\x12H\n" + + "\x15RemoveUserPermissions\x12\x17.iam.v1.UserBodyRequest\x1a\x16.google.protobuf.Empty\x12Q\n" + + "\x13AssignUserContainer\x12\".iam.v1.UserResourceBindingRequest\x1a\x16.google.protobuf.Empty\x12J\n" + + "\x13RemoveUserContainer\x12\x1b.iam.v1.UserScopedIDRequest\x1a\x16.google.protobuf.Empty\x12O\n" + + "\x11AssignUserDataset\x12\".iam.v1.UserResourceBindingRequest\x1a\x16.google.protobuf.Empty\x12H\n" + + "\x11RemoveUserDataset\x12\x1b.iam.v1.UserScopedIDRequest\x1a\x16.google.protobuf.Empty\x12O\n" + + "\x11AssignUserProject\x12\".iam.v1.UserResourceBindingRequest\x1a\x16.google.protobuf.Empty\x12H\n" + + "\x11RemoveUserProject\x12\x1b.iam.v1.UserScopedIDRequest\x1a\x16.google.protobuf.Empty\x12=\n" + + "\n" + + "CreateRole\x12\x17.iam.v1.MutationRequest\x1a\x16.iam.v1.StructResponse\x127\n" + + "\n" + + "DeleteRole\x12\x11.iam.v1.IDRequest\x1a\x16.google.protobuf.Empty\x124\n" + + "\aGetRole\x12\x11.iam.v1.IDRequest\x1a\x16.iam.v1.StructResponse\x129\n" + + "\tListRoles\x12\x14.iam.v1.QueryRequest\x1a\x16.iam.v1.StructResponse\x12?\n" + + "\n" + + "UpdateRole\x12\x19.iam.v1.UpdateByIDRequest\x1a\x16.iam.v1.StructResponse\x12O\n" + + "\x15AssignRolePermissions\x12\x1e.iam.v1.RolePermissionsRequest\x1a\x16.google.protobuf.Empty\x12O\n" + + "\x15RemoveRolePermissions\x12\x1e.iam.v1.RolePermissionsRequest\x1a\x16.google.protobuf.Empty\x12>\n" + + "\x11ListUsersFromRole\x12\x11.iam.v1.IDRequest\x1a\x16.iam.v1.StructResponse\x12:\n" + + "\rGetPermission\x12\x11.iam.v1.IDRequest\x1a\x16.iam.v1.StructResponse\x12?\n" + + "\x0fListPermissions\x12\x14.iam.v1.QueryRequest\x1a\x16.iam.v1.StructResponse\x12D\n" + + "\x17ListRolesFromPermission\x12\x11.iam.v1.IDRequest\x1a\x16.iam.v1.StructResponse\x128\n" + + "\vGetResource\x12\x11.iam.v1.IDRequest\x1a\x16.iam.v1.StructResponse\x12=\n" + + "\rListResources\x12\x14.iam.v1.QueryRequest\x1a\x16.iam.v1.StructResponse\x12D\n" + + "\x17ListResourcePermissions\x12\x11.iam.v1.IDRequest\x1a\x16.iam.v1.StructResponse\x12?\n" + + "\n" + + "CreateTeam\x12\x19.iam.v1.CreateTeamRequest\x1a\x16.iam.v1.StructResponse\x129\n" + + "\n" + + "DeleteTeam\x12\x13.iam.v1.TeamRequest\x1a\x16.google.protobuf.Empty\x126\n" + + "\aGetTeam\x12\x13.iam.v1.TeamRequest\x1a\x16.iam.v1.StructResponse\x12=\n" + + "\tListTeams\x12\x18.iam.v1.ListTeamsRequest\x1a\x16.iam.v1.StructResponse\x12?\n" + + "\n" + + "UpdateTeam\x12\x19.iam.v1.UpdateTeamRequest\x1a\x16.iam.v1.StructResponse\x12K\n" + + "\x10ListTeamProjects\x12\x1f.iam.v1.ListTeamProjectsRequest\x1a\x16.iam.v1.StructResponse\x12E\n" + + "\rAddTeamMember\x12\x1c.iam.v1.AddTeamMemberRequest\x1a\x16.google.protobuf.Empty\x12K\n" + + "\x10RemoveTeamMember\x12\x1f.iam.v1.RemoveTeamMemberRequest\x1a\x16.google.protobuf.Empty\x12S\n" + + "\x14UpdateTeamMemberRole\x12#.iam.v1.UpdateTeamMemberRoleRequest\x1a\x16.google.protobuf.Empty\x12I\n" + + "\x0fListTeamMembers\x12\x1e.iam.v1.ListTeamMembersRequest\x1a\x16.iam.v1.StructResponseB\x1aZ\x18aegis/proto/iam/v1;iamv1b\x06proto3" + +var ( + file_proto_iam_v1_iam_proto_rawDescOnce sync.Once + file_proto_iam_v1_iam_proto_rawDescData []byte +) + +func file_proto_iam_v1_iam_proto_rawDescGZIP() []byte { + file_proto_iam_v1_iam_proto_rawDescOnce.Do(func() { + file_proto_iam_v1_iam_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_iam_v1_iam_proto_rawDesc), len(file_proto_iam_v1_iam_proto_rawDesc))) + }) + return file_proto_iam_v1_iam_proto_rawDescData +} + +var file_proto_iam_v1_iam_proto_msgTypes = make([]protoimpl.MessageInfo, 31) +var file_proto_iam_v1_iam_proto_goTypes = []any{ + (*VerifyTokenRequest)(nil), // 0: iam.v1.VerifyTokenRequest + (*VerifyTokenResponse)(nil), // 1: iam.v1.VerifyTokenResponse + (*CheckPermissionRequest)(nil), // 2: iam.v1.CheckPermissionRequest + (*CheckPermissionResponse)(nil), // 3: iam.v1.CheckPermissionResponse + (*UserTeamRequest)(nil), // 4: iam.v1.UserTeamRequest + (*TeamRequest)(nil), // 5: iam.v1.TeamRequest + (*UserProjectRequest)(nil), // 6: iam.v1.UserProjectRequest + (*BoolResponse)(nil), // 7: iam.v1.BoolResponse + (*ExchangeAPIKeyTokenRequest)(nil), // 8: iam.v1.ExchangeAPIKeyTokenRequest + (*ExchangeAPIKeyTokenResponse)(nil), // 9: iam.v1.ExchangeAPIKeyTokenResponse + (*MutationRequest)(nil), // 10: iam.v1.MutationRequest + (*QueryRequest)(nil), // 11: iam.v1.QueryRequest + (*IDRequest)(nil), // 12: iam.v1.IDRequest + (*UpdateByIDRequest)(nil), // 13: iam.v1.UpdateByIDRequest + (*UserIDRequest)(nil), // 14: iam.v1.UserIDRequest + (*UserQueryRequest)(nil), // 15: iam.v1.UserQueryRequest + (*UserBodyRequest)(nil), // 16: iam.v1.UserBodyRequest + (*UserScopedIDRequest)(nil), // 17: iam.v1.UserScopedIDRequest + (*UserRoleBindingRequest)(nil), // 18: iam.v1.UserRoleBindingRequest + (*UserResourceBindingRequest)(nil), // 19: iam.v1.UserResourceBindingRequest + (*LogoutRequest)(nil), // 20: iam.v1.LogoutRequest + (*RolePermissionsRequest)(nil), // 21: iam.v1.RolePermissionsRequest + (*CreateTeamRequest)(nil), // 22: iam.v1.CreateTeamRequest + (*ListTeamsRequest)(nil), // 23: iam.v1.ListTeamsRequest + (*UpdateTeamRequest)(nil), // 24: iam.v1.UpdateTeamRequest + (*ListTeamProjectsRequest)(nil), // 25: iam.v1.ListTeamProjectsRequest + (*AddTeamMemberRequest)(nil), // 26: iam.v1.AddTeamMemberRequest + (*RemoveTeamMemberRequest)(nil), // 27: iam.v1.RemoveTeamMemberRequest + (*UpdateTeamMemberRoleRequest)(nil), // 28: iam.v1.UpdateTeamMemberRoleRequest + (*ListTeamMembersRequest)(nil), // 29: iam.v1.ListTeamMembersRequest + (*StructResponse)(nil), // 30: iam.v1.StructResponse + (*structpb.Struct)(nil), // 31: google.protobuf.Struct + (*emptypb.Empty)(nil), // 32: google.protobuf.Empty +} +var file_proto_iam_v1_iam_proto_depIdxs = []int32{ + 31, // 0: iam.v1.MutationRequest.body:type_name -> google.protobuf.Struct + 31, // 1: iam.v1.QueryRequest.query:type_name -> google.protobuf.Struct + 31, // 2: iam.v1.UpdateByIDRequest.body:type_name -> google.protobuf.Struct + 31, // 3: iam.v1.UserQueryRequest.query:type_name -> google.protobuf.Struct + 31, // 4: iam.v1.UserBodyRequest.body:type_name -> google.protobuf.Struct + 31, // 5: iam.v1.CreateTeamRequest.body:type_name -> google.protobuf.Struct + 31, // 6: iam.v1.ListTeamsRequest.query:type_name -> google.protobuf.Struct + 31, // 7: iam.v1.UpdateTeamRequest.body:type_name -> google.protobuf.Struct + 31, // 8: iam.v1.ListTeamProjectsRequest.query:type_name -> google.protobuf.Struct + 31, // 9: iam.v1.AddTeamMemberRequest.body:type_name -> google.protobuf.Struct + 31, // 10: iam.v1.UpdateTeamMemberRoleRequest.body:type_name -> google.protobuf.Struct + 31, // 11: iam.v1.ListTeamMembersRequest.query:type_name -> google.protobuf.Struct + 31, // 12: iam.v1.StructResponse.data:type_name -> google.protobuf.Struct + 0, // 13: iam.v1.IAMService.VerifyToken:input_type -> iam.v1.VerifyTokenRequest + 2, // 14: iam.v1.IAMService.CheckPermission:input_type -> iam.v1.CheckPermissionRequest + 10, // 15: iam.v1.IAMService.Login:input_type -> iam.v1.MutationRequest + 10, // 16: iam.v1.IAMService.Register:input_type -> iam.v1.MutationRequest + 10, // 17: iam.v1.IAMService.RefreshToken:input_type -> iam.v1.MutationRequest + 20, // 18: iam.v1.IAMService.Logout:input_type -> iam.v1.LogoutRequest + 16, // 19: iam.v1.IAMService.ChangePassword:input_type -> iam.v1.UserBodyRequest + 14, // 20: iam.v1.IAMService.GetProfile:input_type -> iam.v1.UserIDRequest + 16, // 21: iam.v1.IAMService.CreateAPIKey:input_type -> iam.v1.UserBodyRequest + 15, // 22: iam.v1.IAMService.ListAPIKeys:input_type -> iam.v1.UserQueryRequest + 17, // 23: iam.v1.IAMService.GetAPIKey:input_type -> iam.v1.UserScopedIDRequest + 17, // 24: iam.v1.IAMService.DeleteAPIKey:input_type -> iam.v1.UserScopedIDRequest + 17, // 25: iam.v1.IAMService.DisableAPIKey:input_type -> iam.v1.UserScopedIDRequest + 17, // 26: iam.v1.IAMService.EnableAPIKey:input_type -> iam.v1.UserScopedIDRequest + 17, // 27: iam.v1.IAMService.RevokeAPIKey:input_type -> iam.v1.UserScopedIDRequest + 17, // 28: iam.v1.IAMService.RotateAPIKey:input_type -> iam.v1.UserScopedIDRequest + 4, // 29: iam.v1.IAMService.IsUserTeamAdmin:input_type -> iam.v1.UserTeamRequest + 4, // 30: iam.v1.IAMService.IsUserInTeam:input_type -> iam.v1.UserTeamRequest + 5, // 31: iam.v1.IAMService.IsTeamPublic:input_type -> iam.v1.TeamRequest + 6, // 32: iam.v1.IAMService.IsUserProjectAdmin:input_type -> iam.v1.UserProjectRequest + 6, // 33: iam.v1.IAMService.IsUserInProject:input_type -> iam.v1.UserProjectRequest + 8, // 34: iam.v1.IAMService.ExchangeAPIKeyToken:input_type -> iam.v1.ExchangeAPIKeyTokenRequest + 10, // 35: iam.v1.IAMService.CreateUser:input_type -> iam.v1.MutationRequest + 12, // 36: iam.v1.IAMService.DeleteUser:input_type -> iam.v1.IDRequest + 12, // 37: iam.v1.IAMService.GetUser:input_type -> iam.v1.IDRequest + 11, // 38: iam.v1.IAMService.ListUsers:input_type -> iam.v1.QueryRequest + 13, // 39: iam.v1.IAMService.UpdateUser:input_type -> iam.v1.UpdateByIDRequest + 18, // 40: iam.v1.IAMService.AssignUserRole:input_type -> iam.v1.UserRoleBindingRequest + 18, // 41: iam.v1.IAMService.RemoveUserRole:input_type -> iam.v1.UserRoleBindingRequest + 16, // 42: iam.v1.IAMService.AssignUserPermissions:input_type -> iam.v1.UserBodyRequest + 16, // 43: iam.v1.IAMService.RemoveUserPermissions:input_type -> iam.v1.UserBodyRequest + 19, // 44: iam.v1.IAMService.AssignUserContainer:input_type -> iam.v1.UserResourceBindingRequest + 17, // 45: iam.v1.IAMService.RemoveUserContainer:input_type -> iam.v1.UserScopedIDRequest + 19, // 46: iam.v1.IAMService.AssignUserDataset:input_type -> iam.v1.UserResourceBindingRequest + 17, // 47: iam.v1.IAMService.RemoveUserDataset:input_type -> iam.v1.UserScopedIDRequest + 19, // 48: iam.v1.IAMService.AssignUserProject:input_type -> iam.v1.UserResourceBindingRequest + 17, // 49: iam.v1.IAMService.RemoveUserProject:input_type -> iam.v1.UserScopedIDRequest + 10, // 50: iam.v1.IAMService.CreateRole:input_type -> iam.v1.MutationRequest + 12, // 51: iam.v1.IAMService.DeleteRole:input_type -> iam.v1.IDRequest + 12, // 52: iam.v1.IAMService.GetRole:input_type -> iam.v1.IDRequest + 11, // 53: iam.v1.IAMService.ListRoles:input_type -> iam.v1.QueryRequest + 13, // 54: iam.v1.IAMService.UpdateRole:input_type -> iam.v1.UpdateByIDRequest + 21, // 55: iam.v1.IAMService.AssignRolePermissions:input_type -> iam.v1.RolePermissionsRequest + 21, // 56: iam.v1.IAMService.RemoveRolePermissions:input_type -> iam.v1.RolePermissionsRequest + 12, // 57: iam.v1.IAMService.ListUsersFromRole:input_type -> iam.v1.IDRequest + 12, // 58: iam.v1.IAMService.GetPermission:input_type -> iam.v1.IDRequest + 11, // 59: iam.v1.IAMService.ListPermissions:input_type -> iam.v1.QueryRequest + 12, // 60: iam.v1.IAMService.ListRolesFromPermission:input_type -> iam.v1.IDRequest + 12, // 61: iam.v1.IAMService.GetResource:input_type -> iam.v1.IDRequest + 11, // 62: iam.v1.IAMService.ListResources:input_type -> iam.v1.QueryRequest + 12, // 63: iam.v1.IAMService.ListResourcePermissions:input_type -> iam.v1.IDRequest + 22, // 64: iam.v1.IAMService.CreateTeam:input_type -> iam.v1.CreateTeamRequest + 5, // 65: iam.v1.IAMService.DeleteTeam:input_type -> iam.v1.TeamRequest + 5, // 66: iam.v1.IAMService.GetTeam:input_type -> iam.v1.TeamRequest + 23, // 67: iam.v1.IAMService.ListTeams:input_type -> iam.v1.ListTeamsRequest + 24, // 68: iam.v1.IAMService.UpdateTeam:input_type -> iam.v1.UpdateTeamRequest + 25, // 69: iam.v1.IAMService.ListTeamProjects:input_type -> iam.v1.ListTeamProjectsRequest + 26, // 70: iam.v1.IAMService.AddTeamMember:input_type -> iam.v1.AddTeamMemberRequest + 27, // 71: iam.v1.IAMService.RemoveTeamMember:input_type -> iam.v1.RemoveTeamMemberRequest + 28, // 72: iam.v1.IAMService.UpdateTeamMemberRole:input_type -> iam.v1.UpdateTeamMemberRoleRequest + 29, // 73: iam.v1.IAMService.ListTeamMembers:input_type -> iam.v1.ListTeamMembersRequest + 1, // 74: iam.v1.IAMService.VerifyToken:output_type -> iam.v1.VerifyTokenResponse + 3, // 75: iam.v1.IAMService.CheckPermission:output_type -> iam.v1.CheckPermissionResponse + 30, // 76: iam.v1.IAMService.Login:output_type -> iam.v1.StructResponse + 30, // 77: iam.v1.IAMService.Register:output_type -> iam.v1.StructResponse + 30, // 78: iam.v1.IAMService.RefreshToken:output_type -> iam.v1.StructResponse + 32, // 79: iam.v1.IAMService.Logout:output_type -> google.protobuf.Empty + 32, // 80: iam.v1.IAMService.ChangePassword:output_type -> google.protobuf.Empty + 30, // 81: iam.v1.IAMService.GetProfile:output_type -> iam.v1.StructResponse + 30, // 82: iam.v1.IAMService.CreateAPIKey:output_type -> iam.v1.StructResponse + 30, // 83: iam.v1.IAMService.ListAPIKeys:output_type -> iam.v1.StructResponse + 30, // 84: iam.v1.IAMService.GetAPIKey:output_type -> iam.v1.StructResponse + 32, // 85: iam.v1.IAMService.DeleteAPIKey:output_type -> google.protobuf.Empty + 32, // 86: iam.v1.IAMService.DisableAPIKey:output_type -> google.protobuf.Empty + 32, // 87: iam.v1.IAMService.EnableAPIKey:output_type -> google.protobuf.Empty + 32, // 88: iam.v1.IAMService.RevokeAPIKey:output_type -> google.protobuf.Empty + 30, // 89: iam.v1.IAMService.RotateAPIKey:output_type -> iam.v1.StructResponse + 7, // 90: iam.v1.IAMService.IsUserTeamAdmin:output_type -> iam.v1.BoolResponse + 7, // 91: iam.v1.IAMService.IsUserInTeam:output_type -> iam.v1.BoolResponse + 7, // 92: iam.v1.IAMService.IsTeamPublic:output_type -> iam.v1.BoolResponse + 7, // 93: iam.v1.IAMService.IsUserProjectAdmin:output_type -> iam.v1.BoolResponse + 7, // 94: iam.v1.IAMService.IsUserInProject:output_type -> iam.v1.BoolResponse + 9, // 95: iam.v1.IAMService.ExchangeAPIKeyToken:output_type -> iam.v1.ExchangeAPIKeyTokenResponse + 30, // 96: iam.v1.IAMService.CreateUser:output_type -> iam.v1.StructResponse + 32, // 97: iam.v1.IAMService.DeleteUser:output_type -> google.protobuf.Empty + 30, // 98: iam.v1.IAMService.GetUser:output_type -> iam.v1.StructResponse + 30, // 99: iam.v1.IAMService.ListUsers:output_type -> iam.v1.StructResponse + 30, // 100: iam.v1.IAMService.UpdateUser:output_type -> iam.v1.StructResponse + 32, // 101: iam.v1.IAMService.AssignUserRole:output_type -> google.protobuf.Empty + 32, // 102: iam.v1.IAMService.RemoveUserRole:output_type -> google.protobuf.Empty + 32, // 103: iam.v1.IAMService.AssignUserPermissions:output_type -> google.protobuf.Empty + 32, // 104: iam.v1.IAMService.RemoveUserPermissions:output_type -> google.protobuf.Empty + 32, // 105: iam.v1.IAMService.AssignUserContainer:output_type -> google.protobuf.Empty + 32, // 106: iam.v1.IAMService.RemoveUserContainer:output_type -> google.protobuf.Empty + 32, // 107: iam.v1.IAMService.AssignUserDataset:output_type -> google.protobuf.Empty + 32, // 108: iam.v1.IAMService.RemoveUserDataset:output_type -> google.protobuf.Empty + 32, // 109: iam.v1.IAMService.AssignUserProject:output_type -> google.protobuf.Empty + 32, // 110: iam.v1.IAMService.RemoveUserProject:output_type -> google.protobuf.Empty + 30, // 111: iam.v1.IAMService.CreateRole:output_type -> iam.v1.StructResponse + 32, // 112: iam.v1.IAMService.DeleteRole:output_type -> google.protobuf.Empty + 30, // 113: iam.v1.IAMService.GetRole:output_type -> iam.v1.StructResponse + 30, // 114: iam.v1.IAMService.ListRoles:output_type -> iam.v1.StructResponse + 30, // 115: iam.v1.IAMService.UpdateRole:output_type -> iam.v1.StructResponse + 32, // 116: iam.v1.IAMService.AssignRolePermissions:output_type -> google.protobuf.Empty + 32, // 117: iam.v1.IAMService.RemoveRolePermissions:output_type -> google.protobuf.Empty + 30, // 118: iam.v1.IAMService.ListUsersFromRole:output_type -> iam.v1.StructResponse + 30, // 119: iam.v1.IAMService.GetPermission:output_type -> iam.v1.StructResponse + 30, // 120: iam.v1.IAMService.ListPermissions:output_type -> iam.v1.StructResponse + 30, // 121: iam.v1.IAMService.ListRolesFromPermission:output_type -> iam.v1.StructResponse + 30, // 122: iam.v1.IAMService.GetResource:output_type -> iam.v1.StructResponse + 30, // 123: iam.v1.IAMService.ListResources:output_type -> iam.v1.StructResponse + 30, // 124: iam.v1.IAMService.ListResourcePermissions:output_type -> iam.v1.StructResponse + 30, // 125: iam.v1.IAMService.CreateTeam:output_type -> iam.v1.StructResponse + 32, // 126: iam.v1.IAMService.DeleteTeam:output_type -> google.protobuf.Empty + 30, // 127: iam.v1.IAMService.GetTeam:output_type -> iam.v1.StructResponse + 30, // 128: iam.v1.IAMService.ListTeams:output_type -> iam.v1.StructResponse + 30, // 129: iam.v1.IAMService.UpdateTeam:output_type -> iam.v1.StructResponse + 30, // 130: iam.v1.IAMService.ListTeamProjects:output_type -> iam.v1.StructResponse + 32, // 131: iam.v1.IAMService.AddTeamMember:output_type -> google.protobuf.Empty + 32, // 132: iam.v1.IAMService.RemoveTeamMember:output_type -> google.protobuf.Empty + 32, // 133: iam.v1.IAMService.UpdateTeamMemberRole:output_type -> google.protobuf.Empty + 30, // 134: iam.v1.IAMService.ListTeamMembers:output_type -> iam.v1.StructResponse + 74, // [74:135] is the sub-list for method output_type + 13, // [13:74] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name +} + +func init() { file_proto_iam_v1_iam_proto_init() } +func file_proto_iam_v1_iam_proto_init() { + if File_proto_iam_v1_iam_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_iam_v1_iam_proto_rawDesc), len(file_proto_iam_v1_iam_proto_rawDesc)), + NumEnums: 0, + NumMessages: 31, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_iam_v1_iam_proto_goTypes, + DependencyIndexes: file_proto_iam_v1_iam_proto_depIdxs, + MessageInfos: file_proto_iam_v1_iam_proto_msgTypes, + }.Build() + File_proto_iam_v1_iam_proto = out.File + file_proto_iam_v1_iam_proto_goTypes = nil + file_proto_iam_v1_iam_proto_depIdxs = nil +} diff --git a/src/proto/iam/v1/iam.proto b/src/proto/iam/v1/iam.proto new file mode 100644 index 00000000..ae5188ad --- /dev/null +++ b/src/proto/iam/v1/iam.proto @@ -0,0 +1,248 @@ +syntax = "proto3"; + +package iam.v1; + +option go_package = "aegis/proto/iam/v1;iamv1"; + +import "google/protobuf/empty.proto"; +import "google/protobuf/struct.proto"; + +service IAMService { + rpc VerifyToken(VerifyTokenRequest) returns (VerifyTokenResponse); + rpc CheckPermission(CheckPermissionRequest) returns (CheckPermissionResponse); + rpc Login(MutationRequest) returns (StructResponse); + rpc Register(MutationRequest) returns (StructResponse); + rpc RefreshToken(MutationRequest) returns (StructResponse); + rpc Logout(LogoutRequest) returns (google.protobuf.Empty); + rpc ChangePassword(UserBodyRequest) returns (google.protobuf.Empty); + rpc GetProfile(UserIDRequest) returns (StructResponse); + rpc CreateAPIKey(UserBodyRequest) returns (StructResponse); + rpc ListAPIKeys(UserQueryRequest) returns (StructResponse); + rpc GetAPIKey(UserScopedIDRequest) returns (StructResponse); + rpc DeleteAPIKey(UserScopedIDRequest) returns (google.protobuf.Empty); + rpc DisableAPIKey(UserScopedIDRequest) returns (google.protobuf.Empty); + rpc EnableAPIKey(UserScopedIDRequest) returns (google.protobuf.Empty); + rpc RevokeAPIKey(UserScopedIDRequest) returns (google.protobuf.Empty); + rpc RotateAPIKey(UserScopedIDRequest) returns (StructResponse); + rpc IsUserTeamAdmin(UserTeamRequest) returns (BoolResponse); + rpc IsUserInTeam(UserTeamRequest) returns (BoolResponse); + rpc IsTeamPublic(TeamRequest) returns (BoolResponse); + rpc IsUserProjectAdmin(UserProjectRequest) returns (BoolResponse); + rpc IsUserInProject(UserProjectRequest) returns (BoolResponse); + rpc ExchangeAPIKeyToken(ExchangeAPIKeyTokenRequest) returns (ExchangeAPIKeyTokenResponse); + rpc CreateUser(MutationRequest) returns (StructResponse); + rpc DeleteUser(IDRequest) returns (google.protobuf.Empty); + rpc GetUser(IDRequest) returns (StructResponse); + rpc ListUsers(QueryRequest) returns (StructResponse); + rpc UpdateUser(UpdateByIDRequest) returns (StructResponse); + rpc AssignUserRole(UserRoleBindingRequest) returns (google.protobuf.Empty); + rpc RemoveUserRole(UserRoleBindingRequest) returns (google.protobuf.Empty); + rpc AssignUserPermissions(UserBodyRequest) returns (google.protobuf.Empty); + rpc RemoveUserPermissions(UserBodyRequest) returns (google.protobuf.Empty); + rpc AssignUserContainer(UserResourceBindingRequest) returns (google.protobuf.Empty); + rpc RemoveUserContainer(UserScopedIDRequest) returns (google.protobuf.Empty); + rpc AssignUserDataset(UserResourceBindingRequest) returns (google.protobuf.Empty); + rpc RemoveUserDataset(UserScopedIDRequest) returns (google.protobuf.Empty); + rpc AssignUserProject(UserResourceBindingRequest) returns (google.protobuf.Empty); + rpc RemoveUserProject(UserScopedIDRequest) returns (google.protobuf.Empty); + rpc CreateRole(MutationRequest) returns (StructResponse); + rpc DeleteRole(IDRequest) returns (google.protobuf.Empty); + rpc GetRole(IDRequest) returns (StructResponse); + rpc ListRoles(QueryRequest) returns (StructResponse); + rpc UpdateRole(UpdateByIDRequest) returns (StructResponse); + rpc AssignRolePermissions(RolePermissionsRequest) returns (google.protobuf.Empty); + rpc RemoveRolePermissions(RolePermissionsRequest) returns (google.protobuf.Empty); + rpc ListUsersFromRole(IDRequest) returns (StructResponse); + rpc GetPermission(IDRequest) returns (StructResponse); + rpc ListPermissions(QueryRequest) returns (StructResponse); + rpc ListRolesFromPermission(IDRequest) returns (StructResponse); + rpc GetResource(IDRequest) returns (StructResponse); + rpc ListResources(QueryRequest) returns (StructResponse); + rpc ListResourcePermissions(IDRequest) returns (StructResponse); + rpc CreateTeam(CreateTeamRequest) returns (StructResponse); + rpc DeleteTeam(TeamRequest) returns (google.protobuf.Empty); + rpc GetTeam(TeamRequest) returns (StructResponse); + rpc ListTeams(ListTeamsRequest) returns (StructResponse); + rpc UpdateTeam(UpdateTeamRequest) returns (StructResponse); + rpc ListTeamProjects(ListTeamProjectsRequest) returns (StructResponse); + rpc AddTeamMember(AddTeamMemberRequest) returns (google.protobuf.Empty); + rpc RemoveTeamMember(RemoveTeamMemberRequest) returns (google.protobuf.Empty); + rpc UpdateTeamMemberRole(UpdateTeamMemberRoleRequest) returns (google.protobuf.Empty); + rpc ListTeamMembers(ListTeamMembersRequest) returns (StructResponse); +} + +message VerifyTokenRequest { + string token = 1; +} + +message VerifyTokenResponse { + bool valid = 1; + string token_type = 2; + int64 user_id = 3; + string username = 4; + string email = 5; + bool is_active = 6; + bool is_admin = 7; + repeated string roles = 8; + int64 expires_at_unix = 9; + string auth_type = 10; + int64 key_id = 11; + string task_id = 12; + repeated string api_key_scopes = 13; +} + +message CheckPermissionRequest { + int64 user_id = 1; + string action = 2; + string scope = 3; + string resource_name = 4; + int64 team_id = 5; + int64 project_id = 6; + int64 container_id = 7; + int64 dataset_id = 8; +} + +message CheckPermissionResponse { + bool allowed = 1; +} + +message UserTeamRequest { + int64 user_id = 1; + int64 team_id = 2; +} + +message TeamRequest { + int64 team_id = 1; +} + +message UserProjectRequest { + int64 user_id = 1; + int64 project_id = 2; +} + +message BoolResponse { + bool value = 1; +} + +message ExchangeAPIKeyTokenRequest { + string key_id = 1; + string timestamp = 2; + string nonce = 3; + string signature = 4; + string method = 5; + string path = 6; +} + +message ExchangeAPIKeyTokenResponse { + string token = 1; + string token_type = 2; + int64 expires_at_unix = 3; + string auth_type = 4; + string key_id = 5; +} + +message MutationRequest { + google.protobuf.Struct body = 1; +} + +message QueryRequest { + google.protobuf.Struct query = 1; +} + +message IDRequest { + int64 id = 1; +} + +message UpdateByIDRequest { + int64 id = 1; + google.protobuf.Struct body = 2; +} + +message UserIDRequest { + int64 user_id = 1; +} + +message UserQueryRequest { + int64 user_id = 1; + google.protobuf.Struct query = 2; +} + +message UserBodyRequest { + int64 user_id = 1; + google.protobuf.Struct body = 2; +} + +message UserScopedIDRequest { + int64 user_id = 1; + int64 id = 2; +} + +message UserRoleBindingRequest { + int64 user_id = 1; + int64 role_id = 2; +} + +message UserResourceBindingRequest { + int64 user_id = 1; + int64 resource_id = 2; + int64 role_id = 3; +} + +message LogoutRequest { + int64 user_id = 1; + string token_id = 2; + int64 expires_at_unix = 3; +} + +message RolePermissionsRequest { + int64 role_id = 1; + repeated int64 permission_ids = 2; +} + +message CreateTeamRequest { + int64 user_id = 1; + google.protobuf.Struct body = 2; +} + +message ListTeamsRequest { + int64 user_id = 1; + bool is_admin = 2; + google.protobuf.Struct query = 3; +} + +message UpdateTeamRequest { + int64 team_id = 1; + google.protobuf.Struct body = 2; +} + +message ListTeamProjectsRequest { + int64 team_id = 1; + google.protobuf.Struct query = 2; +} + +message AddTeamMemberRequest { + int64 team_id = 1; + google.protobuf.Struct body = 2; +} + +message RemoveTeamMemberRequest { + int64 team_id = 1; + int64 current_user_id = 2; + int64 target_user_id = 3; +} + +message UpdateTeamMemberRoleRequest { + int64 team_id = 1; + int64 target_user_id = 2; + int64 current_user_id = 3; + google.protobuf.Struct body = 4; +} + +message ListTeamMembersRequest { + int64 team_id = 1; + google.protobuf.Struct query = 2; +} + +message StructResponse { + google.protobuf.Struct data = 1; +} diff --git a/src/proto/iam/v1/iam_grpc.pb.go b/src/proto/iam/v1/iam_grpc.pb.go new file mode 100644 index 00000000..23ee8c85 --- /dev/null +++ b/src/proto/iam/v1/iam_grpc.pb.go @@ -0,0 +1,2402 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v5.29.3 +// source: proto/iam/v1/iam.proto + +package iamv1 + +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 ( + IAMService_VerifyToken_FullMethodName = "/iam.v1.IAMService/VerifyToken" + IAMService_CheckPermission_FullMethodName = "/iam.v1.IAMService/CheckPermission" + IAMService_Login_FullMethodName = "/iam.v1.IAMService/Login" + IAMService_Register_FullMethodName = "/iam.v1.IAMService/Register" + IAMService_RefreshToken_FullMethodName = "/iam.v1.IAMService/RefreshToken" + IAMService_Logout_FullMethodName = "/iam.v1.IAMService/Logout" + IAMService_ChangePassword_FullMethodName = "/iam.v1.IAMService/ChangePassword" + IAMService_GetProfile_FullMethodName = "/iam.v1.IAMService/GetProfile" + IAMService_CreateAPIKey_FullMethodName = "/iam.v1.IAMService/CreateAPIKey" + IAMService_ListAPIKeys_FullMethodName = "/iam.v1.IAMService/ListAPIKeys" + IAMService_GetAPIKey_FullMethodName = "/iam.v1.IAMService/GetAPIKey" + IAMService_DeleteAPIKey_FullMethodName = "/iam.v1.IAMService/DeleteAPIKey" + IAMService_DisableAPIKey_FullMethodName = "/iam.v1.IAMService/DisableAPIKey" + IAMService_EnableAPIKey_FullMethodName = "/iam.v1.IAMService/EnableAPIKey" + IAMService_RevokeAPIKey_FullMethodName = "/iam.v1.IAMService/RevokeAPIKey" + IAMService_RotateAPIKey_FullMethodName = "/iam.v1.IAMService/RotateAPIKey" + IAMService_IsUserTeamAdmin_FullMethodName = "/iam.v1.IAMService/IsUserTeamAdmin" + IAMService_IsUserInTeam_FullMethodName = "/iam.v1.IAMService/IsUserInTeam" + IAMService_IsTeamPublic_FullMethodName = "/iam.v1.IAMService/IsTeamPublic" + IAMService_IsUserProjectAdmin_FullMethodName = "/iam.v1.IAMService/IsUserProjectAdmin" + IAMService_IsUserInProject_FullMethodName = "/iam.v1.IAMService/IsUserInProject" + IAMService_ExchangeAPIKeyToken_FullMethodName = "/iam.v1.IAMService/ExchangeAPIKeyToken" + IAMService_CreateUser_FullMethodName = "/iam.v1.IAMService/CreateUser" + IAMService_DeleteUser_FullMethodName = "/iam.v1.IAMService/DeleteUser" + IAMService_GetUser_FullMethodName = "/iam.v1.IAMService/GetUser" + IAMService_ListUsers_FullMethodName = "/iam.v1.IAMService/ListUsers" + IAMService_UpdateUser_FullMethodName = "/iam.v1.IAMService/UpdateUser" + IAMService_AssignUserRole_FullMethodName = "/iam.v1.IAMService/AssignUserRole" + IAMService_RemoveUserRole_FullMethodName = "/iam.v1.IAMService/RemoveUserRole" + IAMService_AssignUserPermissions_FullMethodName = "/iam.v1.IAMService/AssignUserPermissions" + IAMService_RemoveUserPermissions_FullMethodName = "/iam.v1.IAMService/RemoveUserPermissions" + IAMService_AssignUserContainer_FullMethodName = "/iam.v1.IAMService/AssignUserContainer" + IAMService_RemoveUserContainer_FullMethodName = "/iam.v1.IAMService/RemoveUserContainer" + IAMService_AssignUserDataset_FullMethodName = "/iam.v1.IAMService/AssignUserDataset" + IAMService_RemoveUserDataset_FullMethodName = "/iam.v1.IAMService/RemoveUserDataset" + IAMService_AssignUserProject_FullMethodName = "/iam.v1.IAMService/AssignUserProject" + IAMService_RemoveUserProject_FullMethodName = "/iam.v1.IAMService/RemoveUserProject" + IAMService_CreateRole_FullMethodName = "/iam.v1.IAMService/CreateRole" + IAMService_DeleteRole_FullMethodName = "/iam.v1.IAMService/DeleteRole" + IAMService_GetRole_FullMethodName = "/iam.v1.IAMService/GetRole" + IAMService_ListRoles_FullMethodName = "/iam.v1.IAMService/ListRoles" + IAMService_UpdateRole_FullMethodName = "/iam.v1.IAMService/UpdateRole" + IAMService_AssignRolePermissions_FullMethodName = "/iam.v1.IAMService/AssignRolePermissions" + IAMService_RemoveRolePermissions_FullMethodName = "/iam.v1.IAMService/RemoveRolePermissions" + IAMService_ListUsersFromRole_FullMethodName = "/iam.v1.IAMService/ListUsersFromRole" + IAMService_GetPermission_FullMethodName = "/iam.v1.IAMService/GetPermission" + IAMService_ListPermissions_FullMethodName = "/iam.v1.IAMService/ListPermissions" + IAMService_ListRolesFromPermission_FullMethodName = "/iam.v1.IAMService/ListRolesFromPermission" + IAMService_GetResource_FullMethodName = "/iam.v1.IAMService/GetResource" + IAMService_ListResources_FullMethodName = "/iam.v1.IAMService/ListResources" + IAMService_ListResourcePermissions_FullMethodName = "/iam.v1.IAMService/ListResourcePermissions" + IAMService_CreateTeam_FullMethodName = "/iam.v1.IAMService/CreateTeam" + IAMService_DeleteTeam_FullMethodName = "/iam.v1.IAMService/DeleteTeam" + IAMService_GetTeam_FullMethodName = "/iam.v1.IAMService/GetTeam" + IAMService_ListTeams_FullMethodName = "/iam.v1.IAMService/ListTeams" + IAMService_UpdateTeam_FullMethodName = "/iam.v1.IAMService/UpdateTeam" + IAMService_ListTeamProjects_FullMethodName = "/iam.v1.IAMService/ListTeamProjects" + IAMService_AddTeamMember_FullMethodName = "/iam.v1.IAMService/AddTeamMember" + IAMService_RemoveTeamMember_FullMethodName = "/iam.v1.IAMService/RemoveTeamMember" + IAMService_UpdateTeamMemberRole_FullMethodName = "/iam.v1.IAMService/UpdateTeamMemberRole" + IAMService_ListTeamMembers_FullMethodName = "/iam.v1.IAMService/ListTeamMembers" +) + +// IAMServiceClient is the client API for IAMService 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. +type IAMServiceClient interface { + VerifyToken(ctx context.Context, in *VerifyTokenRequest, opts ...grpc.CallOption) (*VerifyTokenResponse, error) + CheckPermission(ctx context.Context, in *CheckPermissionRequest, opts ...grpc.CallOption) (*CheckPermissionResponse, error) + Login(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) + Register(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) + RefreshToken(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) + Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + ChangePassword(ctx context.Context, in *UserBodyRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + GetProfile(ctx context.Context, in *UserIDRequest, opts ...grpc.CallOption) (*StructResponse, error) + CreateAPIKey(ctx context.Context, in *UserBodyRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListAPIKeys(ctx context.Context, in *UserQueryRequest, opts ...grpc.CallOption) (*StructResponse, error) + GetAPIKey(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*StructResponse, error) + DeleteAPIKey(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + DisableAPIKey(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + EnableAPIKey(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + RevokeAPIKey(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + RotateAPIKey(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*StructResponse, error) + IsUserTeamAdmin(ctx context.Context, in *UserTeamRequest, opts ...grpc.CallOption) (*BoolResponse, error) + IsUserInTeam(ctx context.Context, in *UserTeamRequest, opts ...grpc.CallOption) (*BoolResponse, error) + IsTeamPublic(ctx context.Context, in *TeamRequest, opts ...grpc.CallOption) (*BoolResponse, error) + IsUserProjectAdmin(ctx context.Context, in *UserProjectRequest, opts ...grpc.CallOption) (*BoolResponse, error) + IsUserInProject(ctx context.Context, in *UserProjectRequest, opts ...grpc.CallOption) (*BoolResponse, error) + ExchangeAPIKeyToken(ctx context.Context, in *ExchangeAPIKeyTokenRequest, opts ...grpc.CallOption) (*ExchangeAPIKeyTokenResponse, error) + CreateUser(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) + DeleteUser(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + GetUser(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListUsers(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*StructResponse, error) + UpdateUser(ctx context.Context, in *UpdateByIDRequest, opts ...grpc.CallOption) (*StructResponse, error) + AssignUserRole(ctx context.Context, in *UserRoleBindingRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + RemoveUserRole(ctx context.Context, in *UserRoleBindingRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + AssignUserPermissions(ctx context.Context, in *UserBodyRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + RemoveUserPermissions(ctx context.Context, in *UserBodyRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + AssignUserContainer(ctx context.Context, in *UserResourceBindingRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + RemoveUserContainer(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + AssignUserDataset(ctx context.Context, in *UserResourceBindingRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + RemoveUserDataset(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + AssignUserProject(ctx context.Context, in *UserResourceBindingRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + RemoveUserProject(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + CreateRole(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) + DeleteRole(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + GetRole(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListRoles(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*StructResponse, error) + UpdateRole(ctx context.Context, in *UpdateByIDRequest, opts ...grpc.CallOption) (*StructResponse, error) + AssignRolePermissions(ctx context.Context, in *RolePermissionsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + RemoveRolePermissions(ctx context.Context, in *RolePermissionsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + ListUsersFromRole(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*StructResponse, error) + GetPermission(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListPermissions(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListRolesFromPermission(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*StructResponse, error) + GetResource(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListResources(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListResourcePermissions(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*StructResponse, error) + CreateTeam(ctx context.Context, in *CreateTeamRequest, opts ...grpc.CallOption) (*StructResponse, error) + DeleteTeam(ctx context.Context, in *TeamRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + GetTeam(ctx context.Context, in *TeamRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListTeams(ctx context.Context, in *ListTeamsRequest, opts ...grpc.CallOption) (*StructResponse, error) + UpdateTeam(ctx context.Context, in *UpdateTeamRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListTeamProjects(ctx context.Context, in *ListTeamProjectsRequest, opts ...grpc.CallOption) (*StructResponse, error) + AddTeamMember(ctx context.Context, in *AddTeamMemberRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + RemoveTeamMember(ctx context.Context, in *RemoveTeamMemberRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + UpdateTeamMemberRole(ctx context.Context, in *UpdateTeamMemberRoleRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + ListTeamMembers(ctx context.Context, in *ListTeamMembersRequest, opts ...grpc.CallOption) (*StructResponse, error) +} + +type iAMServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewIAMServiceClient(cc grpc.ClientConnInterface) IAMServiceClient { + return &iAMServiceClient{cc} +} + +func (c *iAMServiceClient) VerifyToken(ctx context.Context, in *VerifyTokenRequest, opts ...grpc.CallOption) (*VerifyTokenResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(VerifyTokenResponse) + err := c.cc.Invoke(ctx, IAMService_VerifyToken_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) CheckPermission(ctx context.Context, in *CheckPermissionRequest, opts ...grpc.CallOption) (*CheckPermissionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CheckPermissionResponse) + err := c.cc.Invoke(ctx, IAMService_CheckPermission_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) Login(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_Login_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) Register(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_Register_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) RefreshToken(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_RefreshToken_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_Logout_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) ChangePassword(ctx context.Context, in *UserBodyRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_ChangePassword_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) GetProfile(ctx context.Context, in *UserIDRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_GetProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) CreateAPIKey(ctx context.Context, in *UserBodyRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_CreateAPIKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) ListAPIKeys(ctx context.Context, in *UserQueryRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_ListAPIKeys_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) GetAPIKey(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_GetAPIKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) DeleteAPIKey(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_DeleteAPIKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) DisableAPIKey(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_DisableAPIKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) EnableAPIKey(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_EnableAPIKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) RevokeAPIKey(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_RevokeAPIKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) RotateAPIKey(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_RotateAPIKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) IsUserTeamAdmin(ctx context.Context, in *UserTeamRequest, opts ...grpc.CallOption) (*BoolResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(BoolResponse) + err := c.cc.Invoke(ctx, IAMService_IsUserTeamAdmin_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) IsUserInTeam(ctx context.Context, in *UserTeamRequest, opts ...grpc.CallOption) (*BoolResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(BoolResponse) + err := c.cc.Invoke(ctx, IAMService_IsUserInTeam_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) IsTeamPublic(ctx context.Context, in *TeamRequest, opts ...grpc.CallOption) (*BoolResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(BoolResponse) + err := c.cc.Invoke(ctx, IAMService_IsTeamPublic_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) IsUserProjectAdmin(ctx context.Context, in *UserProjectRequest, opts ...grpc.CallOption) (*BoolResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(BoolResponse) + err := c.cc.Invoke(ctx, IAMService_IsUserProjectAdmin_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) IsUserInProject(ctx context.Context, in *UserProjectRequest, opts ...grpc.CallOption) (*BoolResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(BoolResponse) + err := c.cc.Invoke(ctx, IAMService_IsUserInProject_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) ExchangeAPIKeyToken(ctx context.Context, in *ExchangeAPIKeyTokenRequest, opts ...grpc.CallOption) (*ExchangeAPIKeyTokenResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExchangeAPIKeyTokenResponse) + err := c.cc.Invoke(ctx, IAMService_ExchangeAPIKeyToken_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) CreateUser(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_CreateUser_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) DeleteUser(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_DeleteUser_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) GetUser(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_GetUser_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) ListUsers(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_ListUsers_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) UpdateUser(ctx context.Context, in *UpdateByIDRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_UpdateUser_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) AssignUserRole(ctx context.Context, in *UserRoleBindingRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_AssignUserRole_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) RemoveUserRole(ctx context.Context, in *UserRoleBindingRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_RemoveUserRole_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) AssignUserPermissions(ctx context.Context, in *UserBodyRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_AssignUserPermissions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) RemoveUserPermissions(ctx context.Context, in *UserBodyRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_RemoveUserPermissions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) AssignUserContainer(ctx context.Context, in *UserResourceBindingRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_AssignUserContainer_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) RemoveUserContainer(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_RemoveUserContainer_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) AssignUserDataset(ctx context.Context, in *UserResourceBindingRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_AssignUserDataset_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) RemoveUserDataset(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_RemoveUserDataset_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) AssignUserProject(ctx context.Context, in *UserResourceBindingRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_AssignUserProject_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) RemoveUserProject(ctx context.Context, in *UserScopedIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_RemoveUserProject_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) CreateRole(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_CreateRole_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) DeleteRole(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_DeleteRole_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) GetRole(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_GetRole_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) ListRoles(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_ListRoles_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) UpdateRole(ctx context.Context, in *UpdateByIDRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_UpdateRole_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) AssignRolePermissions(ctx context.Context, in *RolePermissionsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_AssignRolePermissions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) RemoveRolePermissions(ctx context.Context, in *RolePermissionsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_RemoveRolePermissions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) ListUsersFromRole(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_ListUsersFromRole_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) GetPermission(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_GetPermission_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) ListPermissions(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_ListPermissions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) ListRolesFromPermission(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_ListRolesFromPermission_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) GetResource(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_GetResource_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) ListResources(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_ListResources_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) ListResourcePermissions(ctx context.Context, in *IDRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_ListResourcePermissions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) CreateTeam(ctx context.Context, in *CreateTeamRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_CreateTeam_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) DeleteTeam(ctx context.Context, in *TeamRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_DeleteTeam_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) GetTeam(ctx context.Context, in *TeamRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_GetTeam_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) ListTeams(ctx context.Context, in *ListTeamsRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_ListTeams_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) UpdateTeam(ctx context.Context, in *UpdateTeamRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_UpdateTeam_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) ListTeamProjects(ctx context.Context, in *ListTeamProjectsRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_ListTeamProjects_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) AddTeamMember(ctx context.Context, in *AddTeamMemberRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_AddTeamMember_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) RemoveTeamMember(ctx context.Context, in *RemoveTeamMemberRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_RemoveTeamMember_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) UpdateTeamMemberRole(ctx context.Context, in *UpdateTeamMemberRoleRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, IAMService_UpdateTeamMemberRole_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *iAMServiceClient) ListTeamMembers(ctx context.Context, in *ListTeamMembersRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, IAMService_ListTeamMembers_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// IAMServiceServer is the server API for IAMService service. +// All implementations must embed UnimplementedIAMServiceServer +// for forward compatibility. +type IAMServiceServer interface { + VerifyToken(context.Context, *VerifyTokenRequest) (*VerifyTokenResponse, error) + CheckPermission(context.Context, *CheckPermissionRequest) (*CheckPermissionResponse, error) + Login(context.Context, *MutationRequest) (*StructResponse, error) + Register(context.Context, *MutationRequest) (*StructResponse, error) + RefreshToken(context.Context, *MutationRequest) (*StructResponse, error) + Logout(context.Context, *LogoutRequest) (*emptypb.Empty, error) + ChangePassword(context.Context, *UserBodyRequest) (*emptypb.Empty, error) + GetProfile(context.Context, *UserIDRequest) (*StructResponse, error) + CreateAPIKey(context.Context, *UserBodyRequest) (*StructResponse, error) + ListAPIKeys(context.Context, *UserQueryRequest) (*StructResponse, error) + GetAPIKey(context.Context, *UserScopedIDRequest) (*StructResponse, error) + DeleteAPIKey(context.Context, *UserScopedIDRequest) (*emptypb.Empty, error) + DisableAPIKey(context.Context, *UserScopedIDRequest) (*emptypb.Empty, error) + EnableAPIKey(context.Context, *UserScopedIDRequest) (*emptypb.Empty, error) + RevokeAPIKey(context.Context, *UserScopedIDRequest) (*emptypb.Empty, error) + RotateAPIKey(context.Context, *UserScopedIDRequest) (*StructResponse, error) + IsUserTeamAdmin(context.Context, *UserTeamRequest) (*BoolResponse, error) + IsUserInTeam(context.Context, *UserTeamRequest) (*BoolResponse, error) + IsTeamPublic(context.Context, *TeamRequest) (*BoolResponse, error) + IsUserProjectAdmin(context.Context, *UserProjectRequest) (*BoolResponse, error) + IsUserInProject(context.Context, *UserProjectRequest) (*BoolResponse, error) + ExchangeAPIKeyToken(context.Context, *ExchangeAPIKeyTokenRequest) (*ExchangeAPIKeyTokenResponse, error) + CreateUser(context.Context, *MutationRequest) (*StructResponse, error) + DeleteUser(context.Context, *IDRequest) (*emptypb.Empty, error) + GetUser(context.Context, *IDRequest) (*StructResponse, error) + ListUsers(context.Context, *QueryRequest) (*StructResponse, error) + UpdateUser(context.Context, *UpdateByIDRequest) (*StructResponse, error) + AssignUserRole(context.Context, *UserRoleBindingRequest) (*emptypb.Empty, error) + RemoveUserRole(context.Context, *UserRoleBindingRequest) (*emptypb.Empty, error) + AssignUserPermissions(context.Context, *UserBodyRequest) (*emptypb.Empty, error) + RemoveUserPermissions(context.Context, *UserBodyRequest) (*emptypb.Empty, error) + AssignUserContainer(context.Context, *UserResourceBindingRequest) (*emptypb.Empty, error) + RemoveUserContainer(context.Context, *UserScopedIDRequest) (*emptypb.Empty, error) + AssignUserDataset(context.Context, *UserResourceBindingRequest) (*emptypb.Empty, error) + RemoveUserDataset(context.Context, *UserScopedIDRequest) (*emptypb.Empty, error) + AssignUserProject(context.Context, *UserResourceBindingRequest) (*emptypb.Empty, error) + RemoveUserProject(context.Context, *UserScopedIDRequest) (*emptypb.Empty, error) + CreateRole(context.Context, *MutationRequest) (*StructResponse, error) + DeleteRole(context.Context, *IDRequest) (*emptypb.Empty, error) + GetRole(context.Context, *IDRequest) (*StructResponse, error) + ListRoles(context.Context, *QueryRequest) (*StructResponse, error) + UpdateRole(context.Context, *UpdateByIDRequest) (*StructResponse, error) + AssignRolePermissions(context.Context, *RolePermissionsRequest) (*emptypb.Empty, error) + RemoveRolePermissions(context.Context, *RolePermissionsRequest) (*emptypb.Empty, error) + ListUsersFromRole(context.Context, *IDRequest) (*StructResponse, error) + GetPermission(context.Context, *IDRequest) (*StructResponse, error) + ListPermissions(context.Context, *QueryRequest) (*StructResponse, error) + ListRolesFromPermission(context.Context, *IDRequest) (*StructResponse, error) + GetResource(context.Context, *IDRequest) (*StructResponse, error) + ListResources(context.Context, *QueryRequest) (*StructResponse, error) + ListResourcePermissions(context.Context, *IDRequest) (*StructResponse, error) + CreateTeam(context.Context, *CreateTeamRequest) (*StructResponse, error) + DeleteTeam(context.Context, *TeamRequest) (*emptypb.Empty, error) + GetTeam(context.Context, *TeamRequest) (*StructResponse, error) + ListTeams(context.Context, *ListTeamsRequest) (*StructResponse, error) + UpdateTeam(context.Context, *UpdateTeamRequest) (*StructResponse, error) + ListTeamProjects(context.Context, *ListTeamProjectsRequest) (*StructResponse, error) + AddTeamMember(context.Context, *AddTeamMemberRequest) (*emptypb.Empty, error) + RemoveTeamMember(context.Context, *RemoveTeamMemberRequest) (*emptypb.Empty, error) + UpdateTeamMemberRole(context.Context, *UpdateTeamMemberRoleRequest) (*emptypb.Empty, error) + ListTeamMembers(context.Context, *ListTeamMembersRequest) (*StructResponse, error) + mustEmbedUnimplementedIAMServiceServer() +} + +// UnimplementedIAMServiceServer 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 UnimplementedIAMServiceServer struct{} + +func (UnimplementedIAMServiceServer) VerifyToken(context.Context, *VerifyTokenRequest) (*VerifyTokenResponse, error) { + return nil, status.Error(codes.Unimplemented, "method VerifyToken not implemented") +} +func (UnimplementedIAMServiceServer) CheckPermission(context.Context, *CheckPermissionRequest) (*CheckPermissionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CheckPermission not implemented") +} +func (UnimplementedIAMServiceServer) Login(context.Context, *MutationRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Login not implemented") +} +func (UnimplementedIAMServiceServer) Register(context.Context, *MutationRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Register not implemented") +} +func (UnimplementedIAMServiceServer) RefreshToken(context.Context, *MutationRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RefreshToken not implemented") +} +func (UnimplementedIAMServiceServer) Logout(context.Context, *LogoutRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method Logout not implemented") +} +func (UnimplementedIAMServiceServer) ChangePassword(context.Context, *UserBodyRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method ChangePassword not implemented") +} +func (UnimplementedIAMServiceServer) GetProfile(context.Context, *UserIDRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetProfile not implemented") +} +func (UnimplementedIAMServiceServer) CreateAPIKey(context.Context, *UserBodyRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateAPIKey not implemented") +} +func (UnimplementedIAMServiceServer) ListAPIKeys(context.Context, *UserQueryRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListAPIKeys not implemented") +} +func (UnimplementedIAMServiceServer) GetAPIKey(context.Context, *UserScopedIDRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetAPIKey not implemented") +} +func (UnimplementedIAMServiceServer) DeleteAPIKey(context.Context, *UserScopedIDRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteAPIKey not implemented") +} +func (UnimplementedIAMServiceServer) DisableAPIKey(context.Context, *UserScopedIDRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DisableAPIKey not implemented") +} +func (UnimplementedIAMServiceServer) EnableAPIKey(context.Context, *UserScopedIDRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method EnableAPIKey not implemented") +} +func (UnimplementedIAMServiceServer) RevokeAPIKey(context.Context, *UserScopedIDRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method RevokeAPIKey not implemented") +} +func (UnimplementedIAMServiceServer) RotateAPIKey(context.Context, *UserScopedIDRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RotateAPIKey not implemented") +} +func (UnimplementedIAMServiceServer) IsUserTeamAdmin(context.Context, *UserTeamRequest) (*BoolResponse, error) { + return nil, status.Error(codes.Unimplemented, "method IsUserTeamAdmin not implemented") +} +func (UnimplementedIAMServiceServer) IsUserInTeam(context.Context, *UserTeamRequest) (*BoolResponse, error) { + return nil, status.Error(codes.Unimplemented, "method IsUserInTeam not implemented") +} +func (UnimplementedIAMServiceServer) IsTeamPublic(context.Context, *TeamRequest) (*BoolResponse, error) { + return nil, status.Error(codes.Unimplemented, "method IsTeamPublic not implemented") +} +func (UnimplementedIAMServiceServer) IsUserProjectAdmin(context.Context, *UserProjectRequest) (*BoolResponse, error) { + return nil, status.Error(codes.Unimplemented, "method IsUserProjectAdmin not implemented") +} +func (UnimplementedIAMServiceServer) IsUserInProject(context.Context, *UserProjectRequest) (*BoolResponse, error) { + return nil, status.Error(codes.Unimplemented, "method IsUserInProject not implemented") +} +func (UnimplementedIAMServiceServer) ExchangeAPIKeyToken(context.Context, *ExchangeAPIKeyTokenRequest) (*ExchangeAPIKeyTokenResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ExchangeAPIKeyToken not implemented") +} +func (UnimplementedIAMServiceServer) CreateUser(context.Context, *MutationRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateUser not implemented") +} +func (UnimplementedIAMServiceServer) DeleteUser(context.Context, *IDRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteUser not implemented") +} +func (UnimplementedIAMServiceServer) GetUser(context.Context, *IDRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetUser not implemented") +} +func (UnimplementedIAMServiceServer) ListUsers(context.Context, *QueryRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListUsers not implemented") +} +func (UnimplementedIAMServiceServer) UpdateUser(context.Context, *UpdateByIDRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateUser not implemented") +} +func (UnimplementedIAMServiceServer) AssignUserRole(context.Context, *UserRoleBindingRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method AssignUserRole not implemented") +} +func (UnimplementedIAMServiceServer) RemoveUserRole(context.Context, *UserRoleBindingRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method RemoveUserRole not implemented") +} +func (UnimplementedIAMServiceServer) AssignUserPermissions(context.Context, *UserBodyRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method AssignUserPermissions not implemented") +} +func (UnimplementedIAMServiceServer) RemoveUserPermissions(context.Context, *UserBodyRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method RemoveUserPermissions not implemented") +} +func (UnimplementedIAMServiceServer) AssignUserContainer(context.Context, *UserResourceBindingRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method AssignUserContainer not implemented") +} +func (UnimplementedIAMServiceServer) RemoveUserContainer(context.Context, *UserScopedIDRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method RemoveUserContainer not implemented") +} +func (UnimplementedIAMServiceServer) AssignUserDataset(context.Context, *UserResourceBindingRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method AssignUserDataset not implemented") +} +func (UnimplementedIAMServiceServer) RemoveUserDataset(context.Context, *UserScopedIDRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method RemoveUserDataset not implemented") +} +func (UnimplementedIAMServiceServer) AssignUserProject(context.Context, *UserResourceBindingRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method AssignUserProject not implemented") +} +func (UnimplementedIAMServiceServer) RemoveUserProject(context.Context, *UserScopedIDRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method RemoveUserProject not implemented") +} +func (UnimplementedIAMServiceServer) CreateRole(context.Context, *MutationRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateRole not implemented") +} +func (UnimplementedIAMServiceServer) DeleteRole(context.Context, *IDRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteRole not implemented") +} +func (UnimplementedIAMServiceServer) GetRole(context.Context, *IDRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetRole not implemented") +} +func (UnimplementedIAMServiceServer) ListRoles(context.Context, *QueryRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListRoles not implemented") +} +func (UnimplementedIAMServiceServer) UpdateRole(context.Context, *UpdateByIDRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateRole not implemented") +} +func (UnimplementedIAMServiceServer) AssignRolePermissions(context.Context, *RolePermissionsRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method AssignRolePermissions not implemented") +} +func (UnimplementedIAMServiceServer) RemoveRolePermissions(context.Context, *RolePermissionsRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method RemoveRolePermissions not implemented") +} +func (UnimplementedIAMServiceServer) ListUsersFromRole(context.Context, *IDRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListUsersFromRole not implemented") +} +func (UnimplementedIAMServiceServer) GetPermission(context.Context, *IDRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetPermission not implemented") +} +func (UnimplementedIAMServiceServer) ListPermissions(context.Context, *QueryRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListPermissions not implemented") +} +func (UnimplementedIAMServiceServer) ListRolesFromPermission(context.Context, *IDRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListRolesFromPermission not implemented") +} +func (UnimplementedIAMServiceServer) GetResource(context.Context, *IDRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetResource not implemented") +} +func (UnimplementedIAMServiceServer) ListResources(context.Context, *QueryRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListResources not implemented") +} +func (UnimplementedIAMServiceServer) ListResourcePermissions(context.Context, *IDRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListResourcePermissions not implemented") +} +func (UnimplementedIAMServiceServer) CreateTeam(context.Context, *CreateTeamRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateTeam not implemented") +} +func (UnimplementedIAMServiceServer) DeleteTeam(context.Context, *TeamRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteTeam not implemented") +} +func (UnimplementedIAMServiceServer) GetTeam(context.Context, *TeamRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetTeam not implemented") +} +func (UnimplementedIAMServiceServer) ListTeams(context.Context, *ListTeamsRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListTeams not implemented") +} +func (UnimplementedIAMServiceServer) UpdateTeam(context.Context, *UpdateTeamRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateTeam not implemented") +} +func (UnimplementedIAMServiceServer) ListTeamProjects(context.Context, *ListTeamProjectsRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListTeamProjects not implemented") +} +func (UnimplementedIAMServiceServer) AddTeamMember(context.Context, *AddTeamMemberRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method AddTeamMember not implemented") +} +func (UnimplementedIAMServiceServer) RemoveTeamMember(context.Context, *RemoveTeamMemberRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method RemoveTeamMember not implemented") +} +func (UnimplementedIAMServiceServer) UpdateTeamMemberRole(context.Context, *UpdateTeamMemberRoleRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateTeamMemberRole not implemented") +} +func (UnimplementedIAMServiceServer) ListTeamMembers(context.Context, *ListTeamMembersRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListTeamMembers not implemented") +} +func (UnimplementedIAMServiceServer) mustEmbedUnimplementedIAMServiceServer() {} +func (UnimplementedIAMServiceServer) testEmbeddedByValue() {} + +// UnsafeIAMServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to IAMServiceServer will +// result in compilation errors. +type UnsafeIAMServiceServer interface { + mustEmbedUnimplementedIAMServiceServer() +} + +func RegisterIAMServiceServer(s grpc.ServiceRegistrar, srv IAMServiceServer) { + // If the following call panics, it indicates UnimplementedIAMServiceServer 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(&IAMService_ServiceDesc, srv) +} + +func _IAMService_VerifyToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(VerifyTokenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).VerifyToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_VerifyToken_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).VerifyToken(ctx, req.(*VerifyTokenRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_CheckPermission_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CheckPermissionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).CheckPermission(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_CheckPermission_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).CheckPermission(ctx, req.(*CheckPermissionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).Login(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_Login_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).Login(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_Register_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).Register(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_Register_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).Register(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_RefreshToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).RefreshToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_RefreshToken_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).RefreshToken(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LogoutRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).Logout(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_Logout_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).Logout(ctx, req.(*LogoutRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_ChangePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserBodyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).ChangePassword(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_ChangePassword_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).ChangePassword(ctx, req.(*UserBodyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_GetProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).GetProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_GetProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).GetProfile(ctx, req.(*UserIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_CreateAPIKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserBodyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).CreateAPIKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_CreateAPIKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).CreateAPIKey(ctx, req.(*UserBodyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_ListAPIKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserQueryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).ListAPIKeys(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_ListAPIKeys_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).ListAPIKeys(ctx, req.(*UserQueryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_GetAPIKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserScopedIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).GetAPIKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_GetAPIKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).GetAPIKey(ctx, req.(*UserScopedIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_DeleteAPIKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserScopedIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).DeleteAPIKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_DeleteAPIKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).DeleteAPIKey(ctx, req.(*UserScopedIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_DisableAPIKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserScopedIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).DisableAPIKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_DisableAPIKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).DisableAPIKey(ctx, req.(*UserScopedIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_EnableAPIKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserScopedIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).EnableAPIKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_EnableAPIKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).EnableAPIKey(ctx, req.(*UserScopedIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_RevokeAPIKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserScopedIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).RevokeAPIKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_RevokeAPIKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).RevokeAPIKey(ctx, req.(*UserScopedIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_RotateAPIKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserScopedIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).RotateAPIKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_RotateAPIKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).RotateAPIKey(ctx, req.(*UserScopedIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_IsUserTeamAdmin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserTeamRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).IsUserTeamAdmin(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_IsUserTeamAdmin_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).IsUserTeamAdmin(ctx, req.(*UserTeamRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_IsUserInTeam_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserTeamRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).IsUserInTeam(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_IsUserInTeam_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).IsUserInTeam(ctx, req.(*UserTeamRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_IsTeamPublic_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TeamRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).IsTeamPublic(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_IsTeamPublic_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).IsTeamPublic(ctx, req.(*TeamRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_IsUserProjectAdmin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserProjectRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).IsUserProjectAdmin(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_IsUserProjectAdmin_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).IsUserProjectAdmin(ctx, req.(*UserProjectRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_IsUserInProject_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserProjectRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).IsUserInProject(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_IsUserInProject_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).IsUserInProject(ctx, req.(*UserProjectRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_ExchangeAPIKeyToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExchangeAPIKeyTokenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).ExchangeAPIKeyToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_ExchangeAPIKeyToken_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).ExchangeAPIKeyToken(ctx, req.(*ExchangeAPIKeyTokenRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_CreateUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).CreateUser(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_CreateUser_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).CreateUser(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_DeleteUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).DeleteUser(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_DeleteUser_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).DeleteUser(ctx, req.(*IDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_GetUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).GetUser(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_GetUser_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).GetUser(ctx, req.(*IDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_ListUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).ListUsers(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_ListUsers_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).ListUsers(ctx, req.(*QueryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_UpdateUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateByIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).UpdateUser(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_UpdateUser_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).UpdateUser(ctx, req.(*UpdateByIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_AssignUserRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserRoleBindingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).AssignUserRole(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_AssignUserRole_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).AssignUserRole(ctx, req.(*UserRoleBindingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_RemoveUserRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserRoleBindingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).RemoveUserRole(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_RemoveUserRole_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).RemoveUserRole(ctx, req.(*UserRoleBindingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_AssignUserPermissions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserBodyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).AssignUserPermissions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_AssignUserPermissions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).AssignUserPermissions(ctx, req.(*UserBodyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_RemoveUserPermissions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserBodyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).RemoveUserPermissions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_RemoveUserPermissions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).RemoveUserPermissions(ctx, req.(*UserBodyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_AssignUserContainer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserResourceBindingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).AssignUserContainer(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_AssignUserContainer_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).AssignUserContainer(ctx, req.(*UserResourceBindingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_RemoveUserContainer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserScopedIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).RemoveUserContainer(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_RemoveUserContainer_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).RemoveUserContainer(ctx, req.(*UserScopedIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_AssignUserDataset_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserResourceBindingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).AssignUserDataset(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_AssignUserDataset_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).AssignUserDataset(ctx, req.(*UserResourceBindingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_RemoveUserDataset_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserScopedIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).RemoveUserDataset(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_RemoveUserDataset_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).RemoveUserDataset(ctx, req.(*UserScopedIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_AssignUserProject_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserResourceBindingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).AssignUserProject(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_AssignUserProject_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).AssignUserProject(ctx, req.(*UserResourceBindingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_RemoveUserProject_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserScopedIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).RemoveUserProject(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_RemoveUserProject_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).RemoveUserProject(ctx, req.(*UserScopedIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_CreateRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).CreateRole(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_CreateRole_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).CreateRole(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_DeleteRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).DeleteRole(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_DeleteRole_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).DeleteRole(ctx, req.(*IDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_GetRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).GetRole(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_GetRole_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).GetRole(ctx, req.(*IDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_ListRoles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).ListRoles(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_ListRoles_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).ListRoles(ctx, req.(*QueryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_UpdateRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateByIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).UpdateRole(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_UpdateRole_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).UpdateRole(ctx, req.(*UpdateByIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_AssignRolePermissions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RolePermissionsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).AssignRolePermissions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_AssignRolePermissions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).AssignRolePermissions(ctx, req.(*RolePermissionsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_RemoveRolePermissions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RolePermissionsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).RemoveRolePermissions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_RemoveRolePermissions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).RemoveRolePermissions(ctx, req.(*RolePermissionsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_ListUsersFromRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).ListUsersFromRole(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_ListUsersFromRole_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).ListUsersFromRole(ctx, req.(*IDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_GetPermission_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).GetPermission(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_GetPermission_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).GetPermission(ctx, req.(*IDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_ListPermissions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).ListPermissions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_ListPermissions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).ListPermissions(ctx, req.(*QueryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_ListRolesFromPermission_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).ListRolesFromPermission(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_ListRolesFromPermission_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).ListRolesFromPermission(ctx, req.(*IDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_GetResource_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).GetResource(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_GetResource_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).GetResource(ctx, req.(*IDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_ListResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).ListResources(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_ListResources_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).ListResources(ctx, req.(*QueryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_ListResourcePermissions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).ListResourcePermissions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_ListResourcePermissions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).ListResourcePermissions(ctx, req.(*IDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_CreateTeam_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateTeamRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).CreateTeam(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_CreateTeam_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).CreateTeam(ctx, req.(*CreateTeamRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_DeleteTeam_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TeamRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).DeleteTeam(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_DeleteTeam_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).DeleteTeam(ctx, req.(*TeamRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_GetTeam_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TeamRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).GetTeam(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_GetTeam_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).GetTeam(ctx, req.(*TeamRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_ListTeams_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListTeamsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).ListTeams(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_ListTeams_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).ListTeams(ctx, req.(*ListTeamsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_UpdateTeam_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateTeamRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).UpdateTeam(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_UpdateTeam_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).UpdateTeam(ctx, req.(*UpdateTeamRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_ListTeamProjects_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListTeamProjectsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).ListTeamProjects(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_ListTeamProjects_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).ListTeamProjects(ctx, req.(*ListTeamProjectsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_AddTeamMember_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddTeamMemberRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).AddTeamMember(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_AddTeamMember_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).AddTeamMember(ctx, req.(*AddTeamMemberRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_RemoveTeamMember_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RemoveTeamMemberRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).RemoveTeamMember(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_RemoveTeamMember_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).RemoveTeamMember(ctx, req.(*RemoveTeamMemberRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_UpdateTeamMemberRole_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateTeamMemberRoleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).UpdateTeamMemberRole(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_UpdateTeamMemberRole_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).UpdateTeamMemberRole(ctx, req.(*UpdateTeamMemberRoleRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _IAMService_ListTeamMembers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListTeamMembersRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IAMServiceServer).ListTeamMembers(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IAMService_ListTeamMembers_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IAMServiceServer).ListTeamMembers(ctx, req.(*ListTeamMembersRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// IAMService_ServiceDesc is the grpc.ServiceDesc for IAMService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var IAMService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "iam.v1.IAMService", + HandlerType: (*IAMServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "VerifyToken", + Handler: _IAMService_VerifyToken_Handler, + }, + { + MethodName: "CheckPermission", + Handler: _IAMService_CheckPermission_Handler, + }, + { + MethodName: "Login", + Handler: _IAMService_Login_Handler, + }, + { + MethodName: "Register", + Handler: _IAMService_Register_Handler, + }, + { + MethodName: "RefreshToken", + Handler: _IAMService_RefreshToken_Handler, + }, + { + MethodName: "Logout", + Handler: _IAMService_Logout_Handler, + }, + { + MethodName: "ChangePassword", + Handler: _IAMService_ChangePassword_Handler, + }, + { + MethodName: "GetProfile", + Handler: _IAMService_GetProfile_Handler, + }, + { + MethodName: "CreateAPIKey", + Handler: _IAMService_CreateAPIKey_Handler, + }, + { + MethodName: "ListAPIKeys", + Handler: _IAMService_ListAPIKeys_Handler, + }, + { + MethodName: "GetAPIKey", + Handler: _IAMService_GetAPIKey_Handler, + }, + { + MethodName: "DeleteAPIKey", + Handler: _IAMService_DeleteAPIKey_Handler, + }, + { + MethodName: "DisableAPIKey", + Handler: _IAMService_DisableAPIKey_Handler, + }, + { + MethodName: "EnableAPIKey", + Handler: _IAMService_EnableAPIKey_Handler, + }, + { + MethodName: "RevokeAPIKey", + Handler: _IAMService_RevokeAPIKey_Handler, + }, + { + MethodName: "RotateAPIKey", + Handler: _IAMService_RotateAPIKey_Handler, + }, + { + MethodName: "IsUserTeamAdmin", + Handler: _IAMService_IsUserTeamAdmin_Handler, + }, + { + MethodName: "IsUserInTeam", + Handler: _IAMService_IsUserInTeam_Handler, + }, + { + MethodName: "IsTeamPublic", + Handler: _IAMService_IsTeamPublic_Handler, + }, + { + MethodName: "IsUserProjectAdmin", + Handler: _IAMService_IsUserProjectAdmin_Handler, + }, + { + MethodName: "IsUserInProject", + Handler: _IAMService_IsUserInProject_Handler, + }, + { + MethodName: "ExchangeAPIKeyToken", + Handler: _IAMService_ExchangeAPIKeyToken_Handler, + }, + { + MethodName: "CreateUser", + Handler: _IAMService_CreateUser_Handler, + }, + { + MethodName: "DeleteUser", + Handler: _IAMService_DeleteUser_Handler, + }, + { + MethodName: "GetUser", + Handler: _IAMService_GetUser_Handler, + }, + { + MethodName: "ListUsers", + Handler: _IAMService_ListUsers_Handler, + }, + { + MethodName: "UpdateUser", + Handler: _IAMService_UpdateUser_Handler, + }, + { + MethodName: "AssignUserRole", + Handler: _IAMService_AssignUserRole_Handler, + }, + { + MethodName: "RemoveUserRole", + Handler: _IAMService_RemoveUserRole_Handler, + }, + { + MethodName: "AssignUserPermissions", + Handler: _IAMService_AssignUserPermissions_Handler, + }, + { + MethodName: "RemoveUserPermissions", + Handler: _IAMService_RemoveUserPermissions_Handler, + }, + { + MethodName: "AssignUserContainer", + Handler: _IAMService_AssignUserContainer_Handler, + }, + { + MethodName: "RemoveUserContainer", + Handler: _IAMService_RemoveUserContainer_Handler, + }, + { + MethodName: "AssignUserDataset", + Handler: _IAMService_AssignUserDataset_Handler, + }, + { + MethodName: "RemoveUserDataset", + Handler: _IAMService_RemoveUserDataset_Handler, + }, + { + MethodName: "AssignUserProject", + Handler: _IAMService_AssignUserProject_Handler, + }, + { + MethodName: "RemoveUserProject", + Handler: _IAMService_RemoveUserProject_Handler, + }, + { + MethodName: "CreateRole", + Handler: _IAMService_CreateRole_Handler, + }, + { + MethodName: "DeleteRole", + Handler: _IAMService_DeleteRole_Handler, + }, + { + MethodName: "GetRole", + Handler: _IAMService_GetRole_Handler, + }, + { + MethodName: "ListRoles", + Handler: _IAMService_ListRoles_Handler, + }, + { + MethodName: "UpdateRole", + Handler: _IAMService_UpdateRole_Handler, + }, + { + MethodName: "AssignRolePermissions", + Handler: _IAMService_AssignRolePermissions_Handler, + }, + { + MethodName: "RemoveRolePermissions", + Handler: _IAMService_RemoveRolePermissions_Handler, + }, + { + MethodName: "ListUsersFromRole", + Handler: _IAMService_ListUsersFromRole_Handler, + }, + { + MethodName: "GetPermission", + Handler: _IAMService_GetPermission_Handler, + }, + { + MethodName: "ListPermissions", + Handler: _IAMService_ListPermissions_Handler, + }, + { + MethodName: "ListRolesFromPermission", + Handler: _IAMService_ListRolesFromPermission_Handler, + }, + { + MethodName: "GetResource", + Handler: _IAMService_GetResource_Handler, + }, + { + MethodName: "ListResources", + Handler: _IAMService_ListResources_Handler, + }, + { + MethodName: "ListResourcePermissions", + Handler: _IAMService_ListResourcePermissions_Handler, + }, + { + MethodName: "CreateTeam", + Handler: _IAMService_CreateTeam_Handler, + }, + { + MethodName: "DeleteTeam", + Handler: _IAMService_DeleteTeam_Handler, + }, + { + MethodName: "GetTeam", + Handler: _IAMService_GetTeam_Handler, + }, + { + MethodName: "ListTeams", + Handler: _IAMService_ListTeams_Handler, + }, + { + MethodName: "UpdateTeam", + Handler: _IAMService_UpdateTeam_Handler, + }, + { + MethodName: "ListTeamProjects", + Handler: _IAMService_ListTeamProjects_Handler, + }, + { + MethodName: "AddTeamMember", + Handler: _IAMService_AddTeamMember_Handler, + }, + { + MethodName: "RemoveTeamMember", + Handler: _IAMService_RemoveTeamMember_Handler, + }, + { + MethodName: "UpdateTeamMemberRole", + Handler: _IAMService_UpdateTeamMemberRole_Handler, + }, + { + MethodName: "ListTeamMembers", + Handler: _IAMService_ListTeamMembers_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto/iam/v1/iam.proto", +} diff --git a/src/proto/orchestrator/v1/orchestrator.pb.go b/src/proto/orchestrator/v1/orchestrator.pb.go new file mode 100644 index 00000000..39d82bae --- /dev/null +++ b/src/proto/orchestrator/v1/orchestrator.pb.go @@ -0,0 +1,1903 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: proto/orchestrator/v1/orchestrator.proto + +package orchestratorv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type PingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingRequest) Reset() { + *x = PingRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingRequest) ProtoMessage() {} + +func (x *PingRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. +func (*PingRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{0} +} + +type PingResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Service string `protobuf:"bytes,1,opt,name=service,proto3" json:"service,omitempty"` + AppId string `protobuf:"bytes,2,opt,name=app_id,json=appId,proto3" json:"app_id,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + TimestampUnix int64 `protobuf:"varint,4,opt,name=timestamp_unix,json=timestampUnix,proto3" json:"timestamp_unix,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingResponse) Reset() { + *x = PingResponse{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingResponse) ProtoMessage() {} + +func (x *PingResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingResponse.ProtoReflect.Descriptor instead. +func (*PingResponse) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{1} +} + +func (x *PingResponse) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *PingResponse) GetAppId() string { + if x != nil { + return x.AppId + } + return "" +} + +func (x *PingResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *PingResponse) GetTimestampUnix() int64 { + if x != nil { + return x.TimestampUnix + } + return 0 +} + +type SubmitExecutionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupId string `protobuf:"bytes,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Body *structpb.Struct `protobuf:"bytes,10,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitExecutionRequest) Reset() { + *x = SubmitExecutionRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitExecutionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitExecutionRequest) ProtoMessage() {} + +func (x *SubmitExecutionRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitExecutionRequest.ProtoReflect.Descriptor instead. +func (*SubmitExecutionRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{2} +} + +func (x *SubmitExecutionRequest) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +func (x *SubmitExecutionRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *SubmitExecutionRequest) GetBody() *structpb.Struct { + if x != nil { + return x.Body + } + return nil +} + +type SubmitExecutionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupId string `protobuf:"bytes,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + Items []*SubmittedExecutionItem `protobuf:"bytes,2,rep,name=items,proto3" json:"items,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitExecutionResponse) Reset() { + *x = SubmitExecutionResponse{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitExecutionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitExecutionResponse) ProtoMessage() {} + +func (x *SubmitExecutionResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitExecutionResponse.ProtoReflect.Descriptor instead. +func (*SubmitExecutionResponse) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{3} +} + +func (x *SubmitExecutionResponse) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +func (x *SubmitExecutionResponse) GetItems() []*SubmittedExecutionItem { + if x != nil { + return x.Items + } + return nil +} + +type SubmittedExecutionItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + Index int64 `protobuf:"varint,1,opt,name=index,proto3" json:"index,omitempty"` + TraceId string `protobuf:"bytes,2,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` + TaskId string `protobuf:"bytes,3,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + AlgorithmId int64 `protobuf:"varint,4,opt,name=algorithm_id,json=algorithmId,proto3" json:"algorithm_id,omitempty"` + AlgorithmVersionId int64 `protobuf:"varint,5,opt,name=algorithm_version_id,json=algorithmVersionId,proto3" json:"algorithm_version_id,omitempty"` + DatapackId int64 `protobuf:"varint,6,opt,name=datapack_id,json=datapackId,proto3" json:"datapack_id,omitempty"` + DatasetId int64 `protobuf:"varint,7,opt,name=dataset_id,json=datasetId,proto3" json:"dataset_id,omitempty"` + HasDatapackId bool `protobuf:"varint,8,opt,name=has_datapack_id,json=hasDatapackId,proto3" json:"has_datapack_id,omitempty"` + HasDatasetId bool `protobuf:"varint,9,opt,name=has_dataset_id,json=hasDatasetId,proto3" json:"has_dataset_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmittedExecutionItem) Reset() { + *x = SubmittedExecutionItem{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmittedExecutionItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmittedExecutionItem) ProtoMessage() {} + +func (x *SubmittedExecutionItem) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmittedExecutionItem.ProtoReflect.Descriptor instead. +func (*SubmittedExecutionItem) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{4} +} + +func (x *SubmittedExecutionItem) GetIndex() int64 { + if x != nil { + return x.Index + } + return 0 +} + +func (x *SubmittedExecutionItem) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +func (x *SubmittedExecutionItem) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *SubmittedExecutionItem) GetAlgorithmId() int64 { + if x != nil { + return x.AlgorithmId + } + return 0 +} + +func (x *SubmittedExecutionItem) GetAlgorithmVersionId() int64 { + if x != nil { + return x.AlgorithmVersionId + } + return 0 +} + +func (x *SubmittedExecutionItem) GetDatapackId() int64 { + if x != nil { + return x.DatapackId + } + return 0 +} + +func (x *SubmittedExecutionItem) GetDatasetId() int64 { + if x != nil { + return x.DatasetId + } + return 0 +} + +func (x *SubmittedExecutionItem) GetHasDatapackId() bool { + if x != nil { + return x.HasDatapackId + } + return false +} + +func (x *SubmittedExecutionItem) GetHasDatasetId() bool { + if x != nil { + return x.HasDatasetId + } + return false +} + +type SubmitFaultInjectionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupId string `protobuf:"bytes,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ProjectId int64 `protobuf:"varint,3,opt,name=project_id,json=projectId,proto3" json:"project_id,omitempty"` + Body *structpb.Struct `protobuf:"bytes,10,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitFaultInjectionRequest) Reset() { + *x = SubmitFaultInjectionRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitFaultInjectionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitFaultInjectionRequest) ProtoMessage() {} + +func (x *SubmitFaultInjectionRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitFaultInjectionRequest.ProtoReflect.Descriptor instead. +func (*SubmitFaultInjectionRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{5} +} + +func (x *SubmitFaultInjectionRequest) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +func (x *SubmitFaultInjectionRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *SubmitFaultInjectionRequest) GetProjectId() int64 { + if x != nil { + return x.ProjectId + } + return 0 +} + +func (x *SubmitFaultInjectionRequest) GetBody() *structpb.Struct { + if x != nil { + return x.Body + } + return nil +} + +type SubmitFaultInjectionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupId string `protobuf:"bytes,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + Items []*SubmittedInjectionItem `protobuf:"bytes,2,rep,name=items,proto3" json:"items,omitempty"` + OriginalCount int64 `protobuf:"varint,3,opt,name=original_count,json=originalCount,proto3" json:"original_count,omitempty"` + Warnings *InjectionWarnings `protobuf:"bytes,4,opt,name=warnings,proto3" json:"warnings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitFaultInjectionResponse) Reset() { + *x = SubmitFaultInjectionResponse{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitFaultInjectionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitFaultInjectionResponse) ProtoMessage() {} + +func (x *SubmitFaultInjectionResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitFaultInjectionResponse.ProtoReflect.Descriptor instead. +func (*SubmitFaultInjectionResponse) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{6} +} + +func (x *SubmitFaultInjectionResponse) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +func (x *SubmitFaultInjectionResponse) GetItems() []*SubmittedInjectionItem { + if x != nil { + return x.Items + } + return nil +} + +func (x *SubmitFaultInjectionResponse) GetOriginalCount() int64 { + if x != nil { + return x.OriginalCount + } + return 0 +} + +func (x *SubmitFaultInjectionResponse) GetWarnings() *InjectionWarnings { + if x != nil { + return x.Warnings + } + return nil +} + +type SubmittedInjectionItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + Index int64 `protobuf:"varint,1,opt,name=index,proto3" json:"index,omitempty"` + TraceId string `protobuf:"bytes,2,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` + TaskId string `protobuf:"bytes,3,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmittedInjectionItem) Reset() { + *x = SubmittedInjectionItem{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmittedInjectionItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmittedInjectionItem) ProtoMessage() {} + +func (x *SubmittedInjectionItem) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmittedInjectionItem.ProtoReflect.Descriptor instead. +func (*SubmittedInjectionItem) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{7} +} + +func (x *SubmittedInjectionItem) GetIndex() int64 { + if x != nil { + return x.Index + } + return 0 +} + +func (x *SubmittedInjectionItem) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +func (x *SubmittedInjectionItem) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +type InjectionWarnings struct { + state protoimpl.MessageState `protogen:"open.v1"` + DuplicateServicesInBatch []string `protobuf:"bytes,1,rep,name=duplicate_services_in_batch,json=duplicateServicesInBatch,proto3" json:"duplicate_services_in_batch,omitempty"` + DuplicateBatchesInRequest []int64 `protobuf:"varint,2,rep,packed,name=duplicate_batches_in_request,json=duplicateBatchesInRequest,proto3" json:"duplicate_batches_in_request,omitempty"` + BatchesExistInDatabase []int64 `protobuf:"varint,3,rep,packed,name=batches_exist_in_database,json=batchesExistInDatabase,proto3" json:"batches_exist_in_database,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InjectionWarnings) Reset() { + *x = InjectionWarnings{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InjectionWarnings) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InjectionWarnings) ProtoMessage() {} + +func (x *InjectionWarnings) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InjectionWarnings.ProtoReflect.Descriptor instead. +func (*InjectionWarnings) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{8} +} + +func (x *InjectionWarnings) GetDuplicateServicesInBatch() []string { + if x != nil { + return x.DuplicateServicesInBatch + } + return nil +} + +func (x *InjectionWarnings) GetDuplicateBatchesInRequest() []int64 { + if x != nil { + return x.DuplicateBatchesInRequest + } + return nil +} + +func (x *InjectionWarnings) GetBatchesExistInDatabase() []int64 { + if x != nil { + return x.BatchesExistInDatabase + } + return nil +} + +type SubmitDatapackBuildingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupId string `protobuf:"bytes,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ProjectId int64 `protobuf:"varint,3,opt,name=project_id,json=projectId,proto3" json:"project_id,omitempty"` + Body *structpb.Struct `protobuf:"bytes,10,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitDatapackBuildingRequest) Reset() { + *x = SubmitDatapackBuildingRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitDatapackBuildingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitDatapackBuildingRequest) ProtoMessage() {} + +func (x *SubmitDatapackBuildingRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitDatapackBuildingRequest.ProtoReflect.Descriptor instead. +func (*SubmitDatapackBuildingRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{9} +} + +func (x *SubmitDatapackBuildingRequest) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +func (x *SubmitDatapackBuildingRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *SubmitDatapackBuildingRequest) GetProjectId() int64 { + if x != nil { + return x.ProjectId + } + return 0 +} + +func (x *SubmitDatapackBuildingRequest) GetBody() *structpb.Struct { + if x != nil { + return x.Body + } + return nil +} + +type SubmitDatapackBuildingResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupId string `protobuf:"bytes,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + Items []*SubmittedBuildingItem `protobuf:"bytes,2,rep,name=items,proto3" json:"items,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitDatapackBuildingResponse) Reset() { + *x = SubmitDatapackBuildingResponse{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitDatapackBuildingResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitDatapackBuildingResponse) ProtoMessage() {} + +func (x *SubmitDatapackBuildingResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmitDatapackBuildingResponse.ProtoReflect.Descriptor instead. +func (*SubmitDatapackBuildingResponse) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{10} +} + +func (x *SubmitDatapackBuildingResponse) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +func (x *SubmitDatapackBuildingResponse) GetItems() []*SubmittedBuildingItem { + if x != nil { + return x.Items + } + return nil +} + +type SubmittedBuildingItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + Index int64 `protobuf:"varint,1,opt,name=index,proto3" json:"index,omitempty"` + TraceId string `protobuf:"bytes,2,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` + TaskId string `protobuf:"bytes,3,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmittedBuildingItem) Reset() { + *x = SubmittedBuildingItem{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmittedBuildingItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmittedBuildingItem) ProtoMessage() {} + +func (x *SubmittedBuildingItem) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubmittedBuildingItem.ProtoReflect.Descriptor instead. +func (*SubmittedBuildingItem) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{11} +} + +func (x *SubmittedBuildingItem) GetIndex() int64 { + if x != nil { + return x.Index + } + return 0 +} + +func (x *SubmittedBuildingItem) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +func (x *SubmittedBuildingItem) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +type CancelTaskRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CancelTaskRequest) Reset() { + *x = CancelTaskRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CancelTaskRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelTaskRequest) ProtoMessage() {} + +func (x *CancelTaskRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelTaskRequest.ProtoReflect.Descriptor instead. +func (*CancelTaskRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{12} +} + +func (x *CancelTaskRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +type CancelTaskResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cancelled bool `protobuf:"varint,1,opt,name=cancelled,proto3" json:"cancelled,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CancelTaskResponse) Reset() { + *x = CancelTaskResponse{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CancelTaskResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelTaskResponse) ProtoMessage() {} + +func (x *CancelTaskResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelTaskResponse.ProtoReflect.Descriptor instead. +func (*CancelTaskResponse) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{13} +} + +func (x *CancelTaskResponse) GetCancelled() bool { + if x != nil { + return x.Cancelled + } + return false +} + +type MutationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Body *structpb.Struct `protobuf:"bytes,1,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MutationRequest) Reset() { + *x = MutationRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MutationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MutationRequest) ProtoMessage() {} + +func (x *MutationRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MutationRequest.ProtoReflect.Descriptor instead. +func (*MutationRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{14} +} + +func (x *MutationRequest) GetBody() *structpb.Struct { + if x != nil { + return x.Body + } + return nil +} + +type GetExecutionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExecutionId int64 `protobuf:"varint,1,opt,name=execution_id,json=executionId,proto3" json:"execution_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetExecutionRequest) Reset() { + *x = GetExecutionRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetExecutionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetExecutionRequest) ProtoMessage() {} + +func (x *GetExecutionRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetExecutionRequest.ProtoReflect.Descriptor instead. +func (*GetExecutionRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{15} +} + +func (x *GetExecutionRequest) GetExecutionId() int64 { + if x != nil { + return x.ExecutionId + } + return 0 +} + +type ListProjectStatisticsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProjectIds []int64 `protobuf:"varint,1,rep,packed,name=project_ids,json=projectIds,proto3" json:"project_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListProjectStatisticsRequest) Reset() { + *x = ListProjectStatisticsRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListProjectStatisticsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListProjectStatisticsRequest) ProtoMessage() {} + +func (x *ListProjectStatisticsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListProjectStatisticsRequest.ProtoReflect.Descriptor instead. +func (*ListProjectStatisticsRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{16} +} + +func (x *ListProjectStatisticsRequest) GetProjectIds() []int64 { + if x != nil { + return x.ProjectIds + } + return nil +} + +type GetTaskRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTaskRequest) Reset() { + *x = GetTaskRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTaskRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTaskRequest) ProtoMessage() {} + +func (x *GetTaskRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTaskRequest.ProtoReflect.Descriptor instead. +func (*GetTaskRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{17} +} + +func (x *GetTaskRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +type PollTaskLogsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + AfterUnixNano int64 `protobuf:"varint,2,opt,name=after_unix_nano,json=afterUnixNano,proto3" json:"after_unix_nano,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PollTaskLogsRequest) Reset() { + *x = PollTaskLogsRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PollTaskLogsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PollTaskLogsRequest) ProtoMessage() {} + +func (x *PollTaskLogsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PollTaskLogsRequest.ProtoReflect.Descriptor instead. +func (*PollTaskLogsRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{18} +} + +func (x *PollTaskLogsRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *PollTaskLogsRequest) GetAfterUnixNano() int64 { + if x != nil { + return x.AfterUnixNano + } + return 0 +} + +type ListTasksRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Query *structpb.Struct `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTasksRequest) Reset() { + *x = ListTasksRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTasksRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTasksRequest) ProtoMessage() {} + +func (x *ListTasksRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTasksRequest.ProtoReflect.Descriptor instead. +func (*ListTasksRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{19} +} + +func (x *ListTasksRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type GetTraceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TraceId string `protobuf:"bytes,1,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTraceRequest) Reset() { + *x = GetTraceRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTraceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTraceRequest) ProtoMessage() {} + +func (x *GetTraceRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTraceRequest.ProtoReflect.Descriptor instead. +func (*GetTraceRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{20} +} + +func (x *GetTraceRequest) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +type ListTracesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Query *structpb.Struct `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTracesRequest) Reset() { + *x = ListTracesRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTracesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTracesRequest) ProtoMessage() {} + +func (x *ListTracesRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTracesRequest.ProtoReflect.Descriptor instead. +func (*ListTracesRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{21} +} + +func (x *ListTracesRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type GetGroupStatsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupId string `protobuf:"bytes,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetGroupStatsRequest) Reset() { + *x = GetGroupStatsRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetGroupStatsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetGroupStatsRequest) ProtoMessage() {} + +func (x *GetGroupStatsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetGroupStatsRequest.ProtoReflect.Descriptor instead. +func (*GetGroupStatsRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{22} +} + +func (x *GetGroupStatsRequest) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +type GetTraceStreamStateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TraceId string `protobuf:"bytes,1,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTraceStreamStateRequest) Reset() { + *x = GetTraceStreamStateRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTraceStreamStateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTraceStreamStateRequest) ProtoMessage() {} + +func (x *GetTraceStreamStateRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTraceStreamStateRequest.ProtoReflect.Descriptor instead. +func (*GetTraceStreamStateRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{23} +} + +func (x *GetTraceStreamStateRequest) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +type GetGroupStreamStateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + GroupId string `protobuf:"bytes,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetGroupStreamStateRequest) Reset() { + *x = GetGroupStreamStateRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetGroupStreamStateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetGroupStreamStateRequest) ProtoMessage() {} + +func (x *GetGroupStreamStateRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetGroupStreamStateRequest.ProtoReflect.Descriptor instead. +func (*GetGroupStreamStateRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{24} +} + +func (x *GetGroupStreamStateRequest) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +type ReadStreamMessagesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + StreamKey string `protobuf:"bytes,1,opt,name=stream_key,json=streamKey,proto3" json:"stream_key,omitempty"` + LastId string `protobuf:"bytes,2,opt,name=last_id,json=lastId,proto3" json:"last_id,omitempty"` + Count int64 `protobuf:"varint,3,opt,name=count,proto3" json:"count,omitempty"` + BlockMillis int64 `protobuf:"varint,4,opt,name=block_millis,json=blockMillis,proto3" json:"block_millis,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadStreamMessagesRequest) Reset() { + *x = ReadStreamMessagesRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadStreamMessagesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadStreamMessagesRequest) ProtoMessage() {} + +func (x *ReadStreamMessagesRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadStreamMessagesRequest.ProtoReflect.Descriptor instead. +func (*ReadStreamMessagesRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{25} +} + +func (x *ReadStreamMessagesRequest) GetStreamKey() string { + if x != nil { + return x.StreamKey + } + return "" +} + +func (x *ReadStreamMessagesRequest) GetLastId() string { + if x != nil { + return x.LastId + } + return "" +} + +func (x *ReadStreamMessagesRequest) GetCount() int64 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *ReadStreamMessagesRequest) GetBlockMillis() int64 { + if x != nil { + return x.BlockMillis + } + return 0 +} + +type ListDeadLetterTasksRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Limit int64 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDeadLetterTasksRequest) Reset() { + *x = ListDeadLetterTasksRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDeadLetterTasksRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDeadLetterTasksRequest) ProtoMessage() {} + +func (x *ListDeadLetterTasksRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDeadLetterTasksRequest.ProtoReflect.Descriptor instead. +func (*ListDeadLetterTasksRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{26} +} + +func (x *ListDeadLetterTasksRequest) GetLimit() int64 { + if x != nil { + return x.Limit + } + return 0 +} + +type RetryTaskRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RetryTaskRequest) Reset() { + *x = RetryTaskRequest{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RetryTaskRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RetryTaskRequest) ProtoMessage() {} + +func (x *RetryTaskRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RetryTaskRequest.ProtoReflect.Descriptor instead. +func (*RetryTaskRequest) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{27} +} + +func (x *RetryTaskRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +type RetryTaskResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"` + Queue string `protobuf:"bytes,2,opt,name=queue,proto3" json:"queue,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RetryTaskResponse) Reset() { + *x = RetryTaskResponse{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RetryTaskResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RetryTaskResponse) ProtoMessage() {} + +func (x *RetryTaskResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RetryTaskResponse.ProtoReflect.Descriptor instead. +func (*RetryTaskResponse) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{28} +} + +func (x *RetryTaskResponse) GetAccepted() bool { + if x != nil { + return x.Accepted + } + return false +} + +func (x *RetryTaskResponse) GetQueue() string { + if x != nil { + return x.Queue + } + return "" +} + +type StructResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data *structpb.Struct `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StructResponse) Reset() { + *x = StructResponse{} + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StructResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StructResponse) ProtoMessage() {} + +func (x *StructResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_orchestrator_v1_orchestrator_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StructResponse.ProtoReflect.Descriptor instead. +func (*StructResponse) Descriptor() ([]byte, []int) { + return file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP(), []int{29} +} + +func (x *StructResponse) GetData() *structpb.Struct { + if x != nil { + return x.Data + } + return nil +} + +var File_proto_orchestrator_v1_orchestrator_proto protoreflect.FileDescriptor + +const file_proto_orchestrator_v1_orchestrator_proto_rawDesc = "" + + "\n" + + "(proto/orchestrator/v1/orchestrator.proto\x12\x0forchestrator.v1\x1a\x1cgoogle/protobuf/struct.proto\"\r\n" + + "\vPingRequest\"~\n" + + "\fPingResponse\x12\x18\n" + + "\aservice\x18\x01 \x01(\tR\aservice\x12\x15\n" + + "\x06app_id\x18\x02 \x01(\tR\x05appId\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\x12%\n" + + "\x0etimestamp_unix\x18\x04 \x01(\x03R\rtimestampUnix\"y\n" + + "\x16SubmitExecutionRequest\x12\x19\n" + + "\bgroup_id\x18\x01 \x01(\tR\agroupId\x12\x17\n" + + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12+\n" + + "\x04body\x18\n" + + " \x01(\v2\x17.google.protobuf.StructR\x04body\"s\n" + + "\x17SubmitExecutionResponse\x12\x19\n" + + "\bgroup_id\x18\x01 \x01(\tR\agroupId\x12=\n" + + "\x05items\x18\x02 \x03(\v2'.orchestrator.v1.SubmittedExecutionItemR\x05items\"\xc5\x02\n" + + "\x16SubmittedExecutionItem\x12\x14\n" + + "\x05index\x18\x01 \x01(\x03R\x05index\x12\x19\n" + + "\btrace_id\x18\x02 \x01(\tR\atraceId\x12\x17\n" + + "\atask_id\x18\x03 \x01(\tR\x06taskId\x12!\n" + + "\falgorithm_id\x18\x04 \x01(\x03R\valgorithmId\x120\n" + + "\x14algorithm_version_id\x18\x05 \x01(\x03R\x12algorithmVersionId\x12\x1f\n" + + "\vdatapack_id\x18\x06 \x01(\x03R\n" + + "datapackId\x12\x1d\n" + + "\n" + + "dataset_id\x18\a \x01(\x03R\tdatasetId\x12&\n" + + "\x0fhas_datapack_id\x18\b \x01(\bR\rhasDatapackId\x12$\n" + + "\x0ehas_dataset_id\x18\t \x01(\bR\fhasDatasetId\"\x9d\x01\n" + + "\x1bSubmitFaultInjectionRequest\x12\x19\n" + + "\bgroup_id\x18\x01 \x01(\tR\agroupId\x12\x17\n" + + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x1d\n" + + "\n" + + "project_id\x18\x03 \x01(\x03R\tprojectId\x12+\n" + + "\x04body\x18\n" + + " \x01(\v2\x17.google.protobuf.StructR\x04body\"\xdf\x01\n" + + "\x1cSubmitFaultInjectionResponse\x12\x19\n" + + "\bgroup_id\x18\x01 \x01(\tR\agroupId\x12=\n" + + "\x05items\x18\x02 \x03(\v2'.orchestrator.v1.SubmittedInjectionItemR\x05items\x12%\n" + + "\x0eoriginal_count\x18\x03 \x01(\x03R\roriginalCount\x12>\n" + + "\bwarnings\x18\x04 \x01(\v2\".orchestrator.v1.InjectionWarningsR\bwarnings\"b\n" + + "\x16SubmittedInjectionItem\x12\x14\n" + + "\x05index\x18\x01 \x01(\x03R\x05index\x12\x19\n" + + "\btrace_id\x18\x02 \x01(\tR\atraceId\x12\x17\n" + + "\atask_id\x18\x03 \x01(\tR\x06taskId\"\xce\x01\n" + + "\x11InjectionWarnings\x12=\n" + + "\x1bduplicate_services_in_batch\x18\x01 \x03(\tR\x18duplicateServicesInBatch\x12?\n" + + "\x1cduplicate_batches_in_request\x18\x02 \x03(\x03R\x19duplicateBatchesInRequest\x129\n" + + "\x19batches_exist_in_database\x18\x03 \x03(\x03R\x16batchesExistInDatabase\"\x9f\x01\n" + + "\x1dSubmitDatapackBuildingRequest\x12\x19\n" + + "\bgroup_id\x18\x01 \x01(\tR\agroupId\x12\x17\n" + + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x1d\n" + + "\n" + + "project_id\x18\x03 \x01(\x03R\tprojectId\x12+\n" + + "\x04body\x18\n" + + " \x01(\v2\x17.google.protobuf.StructR\x04body\"y\n" + + "\x1eSubmitDatapackBuildingResponse\x12\x19\n" + + "\bgroup_id\x18\x01 \x01(\tR\agroupId\x12<\n" + + "\x05items\x18\x02 \x03(\v2&.orchestrator.v1.SubmittedBuildingItemR\x05items\"a\n" + + "\x15SubmittedBuildingItem\x12\x14\n" + + "\x05index\x18\x01 \x01(\x03R\x05index\x12\x19\n" + + "\btrace_id\x18\x02 \x01(\tR\atraceId\x12\x17\n" + + "\atask_id\x18\x03 \x01(\tR\x06taskId\",\n" + + "\x11CancelTaskRequest\x12\x17\n" + + "\atask_id\x18\x01 \x01(\tR\x06taskId\"2\n" + + "\x12CancelTaskResponse\x12\x1c\n" + + "\tcancelled\x18\x01 \x01(\bR\tcancelled\">\n" + + "\x0fMutationRequest\x12+\n" + + "\x04body\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x04body\"8\n" + + "\x13GetExecutionRequest\x12!\n" + + "\fexecution_id\x18\x01 \x01(\x03R\vexecutionId\"?\n" + + "\x1cListProjectStatisticsRequest\x12\x1f\n" + + "\vproject_ids\x18\x01 \x03(\x03R\n" + + "projectIds\")\n" + + "\x0eGetTaskRequest\x12\x17\n" + + "\atask_id\x18\x01 \x01(\tR\x06taskId\"V\n" + + "\x13PollTaskLogsRequest\x12\x17\n" + + "\atask_id\x18\x01 \x01(\tR\x06taskId\x12&\n" + + "\x0fafter_unix_nano\x18\x02 \x01(\x03R\rafterUnixNano\"A\n" + + "\x10ListTasksRequest\x12-\n" + + "\x05query\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x05query\",\n" + + "\x0fGetTraceRequest\x12\x19\n" + + "\btrace_id\x18\x01 \x01(\tR\atraceId\"B\n" + + "\x11ListTracesRequest\x12-\n" + + "\x05query\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x05query\"1\n" + + "\x14GetGroupStatsRequest\x12\x19\n" + + "\bgroup_id\x18\x01 \x01(\tR\agroupId\"7\n" + + "\x1aGetTraceStreamStateRequest\x12\x19\n" + + "\btrace_id\x18\x01 \x01(\tR\atraceId\"7\n" + + "\x1aGetGroupStreamStateRequest\x12\x19\n" + + "\bgroup_id\x18\x01 \x01(\tR\agroupId\"\x8c\x01\n" + + "\x19ReadStreamMessagesRequest\x12\x1d\n" + + "\n" + + "stream_key\x18\x01 \x01(\tR\tstreamKey\x12\x17\n" + + "\alast_id\x18\x02 \x01(\tR\x06lastId\x12\x14\n" + + "\x05count\x18\x03 \x01(\x03R\x05count\x12!\n" + + "\fblock_millis\x18\x04 \x01(\x03R\vblockMillis\"2\n" + + "\x1aListDeadLetterTasksRequest\x12\x14\n" + + "\x05limit\x18\x01 \x01(\x03R\x05limit\"+\n" + + "\x10RetryTaskRequest\x12\x17\n" + + "\atask_id\x18\x01 \x01(\tR\x06taskId\"E\n" + + "\x11RetryTaskResponse\x12\x1a\n" + + "\baccepted\x18\x01 \x01(\bR\baccepted\x12\x14\n" + + "\x05queue\x18\x02 \x01(\tR\x05queue\"=\n" + + "\x0eStructResponse\x12+\n" + + "\x04data\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x04data2\xc0\x15\n" + + "\x13OrchestratorService\x12C\n" + + "\x04Ping\x12\x1c.orchestrator.v1.PingRequest\x1a\x1d.orchestrator.v1.PingResponse\x12d\n" + + "\x0fSubmitExecution\x12'.orchestrator.v1.SubmitExecutionRequest\x1a(.orchestrator.v1.SubmitExecutionResponse\x12s\n" + + "\x14SubmitFaultInjection\x12,.orchestrator.v1.SubmitFaultInjectionRequest\x1a-.orchestrator.v1.SubmitFaultInjectionResponse\x12y\n" + + "\x16SubmitDatapackBuilding\x12..orchestrator.v1.SubmitDatapackBuildingRequest\x1a/.orchestrator.v1.SubmitDatapackBuildingResponse\x12T\n" + + "\x0fCreateExecution\x12 .orchestrator.v1.MutationRequest\x1a\x1f.orchestrator.v1.StructResponse\x12T\n" + + "\x0fCreateInjection\x12 .orchestrator.v1.MutationRequest\x1a\x1f.orchestrator.v1.StructResponse\x12Y\n" + + "\x14UpdateExecutionState\x12 .orchestrator.v1.MutationRequest\x1a\x1f.orchestrator.v1.StructResponse\x12Y\n" + + "\x14UpdateInjectionState\x12 .orchestrator.v1.MutationRequest\x1a\x1f.orchestrator.v1.StructResponse\x12^\n" + + "\x19UpdateInjectionTimestamps\x12 .orchestrator.v1.MutationRequest\x1a\x1f.orchestrator.v1.StructResponse\x12X\n" + + "\x13GetInjectionMetrics\x12 .orchestrator.v1.MutationRequest\x1a\x1f.orchestrator.v1.StructResponse\x12X\n" + + "\x13GetExecutionMetrics\x12 .orchestrator.v1.MutationRequest\x1a\x1f.orchestrator.v1.StructResponse\x12U\n" + + "\n" + + "CancelTask\x12\".orchestrator.v1.CancelTaskRequest\x1a#.orchestrator.v1.CancelTaskResponse\x12U\n" + + "\fGetExecution\x12$.orchestrator.v1.GetExecutionRequest\x1a\x1f.orchestrator.v1.StructResponse\x12g\n" + + "\x15ListProjectStatistics\x12-.orchestrator.v1.ListProjectStatisticsRequest\x1a\x1f.orchestrator.v1.StructResponse\x12g\n" + + "\"ListEvaluationExecutionsByDatapack\x12 .orchestrator.v1.MutationRequest\x1a\x1f.orchestrator.v1.StructResponse\x12f\n" + + "!ListEvaluationExecutionsByDataset\x12 .orchestrator.v1.MutationRequest\x1a\x1f.orchestrator.v1.StructResponse\x12K\n" + + "\aGetTask\x12\x1f.orchestrator.v1.GetTaskRequest\x1a\x1f.orchestrator.v1.StructResponse\x12U\n" + + "\fPollTaskLogs\x12$.orchestrator.v1.PollTaskLogsRequest\x1a\x1f.orchestrator.v1.StructResponse\x12O\n" + + "\tListTasks\x12!.orchestrator.v1.ListTasksRequest\x1a\x1f.orchestrator.v1.StructResponse\x12M\n" + + "\bGetTrace\x12 .orchestrator.v1.GetTraceRequest\x1a\x1f.orchestrator.v1.StructResponse\x12Q\n" + + "\n" + + "ListTraces\x12\".orchestrator.v1.ListTracesRequest\x1a\x1f.orchestrator.v1.StructResponse\x12W\n" + + "\rGetGroupStats\x12%.orchestrator.v1.GetGroupStatsRequest\x1a\x1f.orchestrator.v1.StructResponse\x12c\n" + + "\x13GetTraceStreamState\x12+.orchestrator.v1.GetTraceStreamStateRequest\x1a\x1f.orchestrator.v1.StructResponse\x12f\n" + + "\x17ReadTraceStreamMessages\x12*.orchestrator.v1.ReadStreamMessagesRequest\x1a\x1f.orchestrator.v1.StructResponse\x12c\n" + + "\x13GetGroupStreamState\x12+.orchestrator.v1.GetGroupStreamStateRequest\x1a\x1f.orchestrator.v1.StructResponse\x12f\n" + + "\x17ReadGroupStreamMessages\x12*.orchestrator.v1.ReadStreamMessagesRequest\x1a\x1f.orchestrator.v1.StructResponse\x12m\n" + + "\x1eReadNotificationStreamMessages\x12*.orchestrator.v1.ReadStreamMessagesRequest\x1a\x1f.orchestrator.v1.StructResponse\x12c\n" + + "\x13ListDeadLetterTasks\x12+.orchestrator.v1.ListDeadLetterTasksRequest\x1a\x1f.orchestrator.v1.StructResponse\x12R\n" + + "\tRetryTask\x12!.orchestrator.v1.RetryTaskRequest\x1a\".orchestrator.v1.RetryTaskResponseB,Z*aegis/proto/orchestrator/v1;orchestratorv1b\x06proto3" + +var ( + file_proto_orchestrator_v1_orchestrator_proto_rawDescOnce sync.Once + file_proto_orchestrator_v1_orchestrator_proto_rawDescData []byte +) + +func file_proto_orchestrator_v1_orchestrator_proto_rawDescGZIP() []byte { + file_proto_orchestrator_v1_orchestrator_proto_rawDescOnce.Do(func() { + file_proto_orchestrator_v1_orchestrator_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_orchestrator_v1_orchestrator_proto_rawDesc), len(file_proto_orchestrator_v1_orchestrator_proto_rawDesc))) + }) + return file_proto_orchestrator_v1_orchestrator_proto_rawDescData +} + +var file_proto_orchestrator_v1_orchestrator_proto_msgTypes = make([]protoimpl.MessageInfo, 30) +var file_proto_orchestrator_v1_orchestrator_proto_goTypes = []any{ + (*PingRequest)(nil), // 0: orchestrator.v1.PingRequest + (*PingResponse)(nil), // 1: orchestrator.v1.PingResponse + (*SubmitExecutionRequest)(nil), // 2: orchestrator.v1.SubmitExecutionRequest + (*SubmitExecutionResponse)(nil), // 3: orchestrator.v1.SubmitExecutionResponse + (*SubmittedExecutionItem)(nil), // 4: orchestrator.v1.SubmittedExecutionItem + (*SubmitFaultInjectionRequest)(nil), // 5: orchestrator.v1.SubmitFaultInjectionRequest + (*SubmitFaultInjectionResponse)(nil), // 6: orchestrator.v1.SubmitFaultInjectionResponse + (*SubmittedInjectionItem)(nil), // 7: orchestrator.v1.SubmittedInjectionItem + (*InjectionWarnings)(nil), // 8: orchestrator.v1.InjectionWarnings + (*SubmitDatapackBuildingRequest)(nil), // 9: orchestrator.v1.SubmitDatapackBuildingRequest + (*SubmitDatapackBuildingResponse)(nil), // 10: orchestrator.v1.SubmitDatapackBuildingResponse + (*SubmittedBuildingItem)(nil), // 11: orchestrator.v1.SubmittedBuildingItem + (*CancelTaskRequest)(nil), // 12: orchestrator.v1.CancelTaskRequest + (*CancelTaskResponse)(nil), // 13: orchestrator.v1.CancelTaskResponse + (*MutationRequest)(nil), // 14: orchestrator.v1.MutationRequest + (*GetExecutionRequest)(nil), // 15: orchestrator.v1.GetExecutionRequest + (*ListProjectStatisticsRequest)(nil), // 16: orchestrator.v1.ListProjectStatisticsRequest + (*GetTaskRequest)(nil), // 17: orchestrator.v1.GetTaskRequest + (*PollTaskLogsRequest)(nil), // 18: orchestrator.v1.PollTaskLogsRequest + (*ListTasksRequest)(nil), // 19: orchestrator.v1.ListTasksRequest + (*GetTraceRequest)(nil), // 20: orchestrator.v1.GetTraceRequest + (*ListTracesRequest)(nil), // 21: orchestrator.v1.ListTracesRequest + (*GetGroupStatsRequest)(nil), // 22: orchestrator.v1.GetGroupStatsRequest + (*GetTraceStreamStateRequest)(nil), // 23: orchestrator.v1.GetTraceStreamStateRequest + (*GetGroupStreamStateRequest)(nil), // 24: orchestrator.v1.GetGroupStreamStateRequest + (*ReadStreamMessagesRequest)(nil), // 25: orchestrator.v1.ReadStreamMessagesRequest + (*ListDeadLetterTasksRequest)(nil), // 26: orchestrator.v1.ListDeadLetterTasksRequest + (*RetryTaskRequest)(nil), // 27: orchestrator.v1.RetryTaskRequest + (*RetryTaskResponse)(nil), // 28: orchestrator.v1.RetryTaskResponse + (*StructResponse)(nil), // 29: orchestrator.v1.StructResponse + (*structpb.Struct)(nil), // 30: google.protobuf.Struct +} +var file_proto_orchestrator_v1_orchestrator_proto_depIdxs = []int32{ + 30, // 0: orchestrator.v1.SubmitExecutionRequest.body:type_name -> google.protobuf.Struct + 4, // 1: orchestrator.v1.SubmitExecutionResponse.items:type_name -> orchestrator.v1.SubmittedExecutionItem + 30, // 2: orchestrator.v1.SubmitFaultInjectionRequest.body:type_name -> google.protobuf.Struct + 7, // 3: orchestrator.v1.SubmitFaultInjectionResponse.items:type_name -> orchestrator.v1.SubmittedInjectionItem + 8, // 4: orchestrator.v1.SubmitFaultInjectionResponse.warnings:type_name -> orchestrator.v1.InjectionWarnings + 30, // 5: orchestrator.v1.SubmitDatapackBuildingRequest.body:type_name -> google.protobuf.Struct + 11, // 6: orchestrator.v1.SubmitDatapackBuildingResponse.items:type_name -> orchestrator.v1.SubmittedBuildingItem + 30, // 7: orchestrator.v1.MutationRequest.body:type_name -> google.protobuf.Struct + 30, // 8: orchestrator.v1.ListTasksRequest.query:type_name -> google.protobuf.Struct + 30, // 9: orchestrator.v1.ListTracesRequest.query:type_name -> google.protobuf.Struct + 30, // 10: orchestrator.v1.StructResponse.data:type_name -> google.protobuf.Struct + 0, // 11: orchestrator.v1.OrchestratorService.Ping:input_type -> orchestrator.v1.PingRequest + 2, // 12: orchestrator.v1.OrchestratorService.SubmitExecution:input_type -> orchestrator.v1.SubmitExecutionRequest + 5, // 13: orchestrator.v1.OrchestratorService.SubmitFaultInjection:input_type -> orchestrator.v1.SubmitFaultInjectionRequest + 9, // 14: orchestrator.v1.OrchestratorService.SubmitDatapackBuilding:input_type -> orchestrator.v1.SubmitDatapackBuildingRequest + 14, // 15: orchestrator.v1.OrchestratorService.CreateExecution:input_type -> orchestrator.v1.MutationRequest + 14, // 16: orchestrator.v1.OrchestratorService.CreateInjection:input_type -> orchestrator.v1.MutationRequest + 14, // 17: orchestrator.v1.OrchestratorService.UpdateExecutionState:input_type -> orchestrator.v1.MutationRequest + 14, // 18: orchestrator.v1.OrchestratorService.UpdateInjectionState:input_type -> orchestrator.v1.MutationRequest + 14, // 19: orchestrator.v1.OrchestratorService.UpdateInjectionTimestamps:input_type -> orchestrator.v1.MutationRequest + 14, // 20: orchestrator.v1.OrchestratorService.GetInjectionMetrics:input_type -> orchestrator.v1.MutationRequest + 14, // 21: orchestrator.v1.OrchestratorService.GetExecutionMetrics:input_type -> orchestrator.v1.MutationRequest + 12, // 22: orchestrator.v1.OrchestratorService.CancelTask:input_type -> orchestrator.v1.CancelTaskRequest + 15, // 23: orchestrator.v1.OrchestratorService.GetExecution:input_type -> orchestrator.v1.GetExecutionRequest + 16, // 24: orchestrator.v1.OrchestratorService.ListProjectStatistics:input_type -> orchestrator.v1.ListProjectStatisticsRequest + 14, // 25: orchestrator.v1.OrchestratorService.ListEvaluationExecutionsByDatapack:input_type -> orchestrator.v1.MutationRequest + 14, // 26: orchestrator.v1.OrchestratorService.ListEvaluationExecutionsByDataset:input_type -> orchestrator.v1.MutationRequest + 17, // 27: orchestrator.v1.OrchestratorService.GetTask:input_type -> orchestrator.v1.GetTaskRequest + 18, // 28: orchestrator.v1.OrchestratorService.PollTaskLogs:input_type -> orchestrator.v1.PollTaskLogsRequest + 19, // 29: orchestrator.v1.OrchestratorService.ListTasks:input_type -> orchestrator.v1.ListTasksRequest + 20, // 30: orchestrator.v1.OrchestratorService.GetTrace:input_type -> orchestrator.v1.GetTraceRequest + 21, // 31: orchestrator.v1.OrchestratorService.ListTraces:input_type -> orchestrator.v1.ListTracesRequest + 22, // 32: orchestrator.v1.OrchestratorService.GetGroupStats:input_type -> orchestrator.v1.GetGroupStatsRequest + 23, // 33: orchestrator.v1.OrchestratorService.GetTraceStreamState:input_type -> orchestrator.v1.GetTraceStreamStateRequest + 25, // 34: orchestrator.v1.OrchestratorService.ReadTraceStreamMessages:input_type -> orchestrator.v1.ReadStreamMessagesRequest + 24, // 35: orchestrator.v1.OrchestratorService.GetGroupStreamState:input_type -> orchestrator.v1.GetGroupStreamStateRequest + 25, // 36: orchestrator.v1.OrchestratorService.ReadGroupStreamMessages:input_type -> orchestrator.v1.ReadStreamMessagesRequest + 25, // 37: orchestrator.v1.OrchestratorService.ReadNotificationStreamMessages:input_type -> orchestrator.v1.ReadStreamMessagesRequest + 26, // 38: orchestrator.v1.OrchestratorService.ListDeadLetterTasks:input_type -> orchestrator.v1.ListDeadLetterTasksRequest + 27, // 39: orchestrator.v1.OrchestratorService.RetryTask:input_type -> orchestrator.v1.RetryTaskRequest + 1, // 40: orchestrator.v1.OrchestratorService.Ping:output_type -> orchestrator.v1.PingResponse + 3, // 41: orchestrator.v1.OrchestratorService.SubmitExecution:output_type -> orchestrator.v1.SubmitExecutionResponse + 6, // 42: orchestrator.v1.OrchestratorService.SubmitFaultInjection:output_type -> orchestrator.v1.SubmitFaultInjectionResponse + 10, // 43: orchestrator.v1.OrchestratorService.SubmitDatapackBuilding:output_type -> orchestrator.v1.SubmitDatapackBuildingResponse + 29, // 44: orchestrator.v1.OrchestratorService.CreateExecution:output_type -> orchestrator.v1.StructResponse + 29, // 45: orchestrator.v1.OrchestratorService.CreateInjection:output_type -> orchestrator.v1.StructResponse + 29, // 46: orchestrator.v1.OrchestratorService.UpdateExecutionState:output_type -> orchestrator.v1.StructResponse + 29, // 47: orchestrator.v1.OrchestratorService.UpdateInjectionState:output_type -> orchestrator.v1.StructResponse + 29, // 48: orchestrator.v1.OrchestratorService.UpdateInjectionTimestamps:output_type -> orchestrator.v1.StructResponse + 29, // 49: orchestrator.v1.OrchestratorService.GetInjectionMetrics:output_type -> orchestrator.v1.StructResponse + 29, // 50: orchestrator.v1.OrchestratorService.GetExecutionMetrics:output_type -> orchestrator.v1.StructResponse + 13, // 51: orchestrator.v1.OrchestratorService.CancelTask:output_type -> orchestrator.v1.CancelTaskResponse + 29, // 52: orchestrator.v1.OrchestratorService.GetExecution:output_type -> orchestrator.v1.StructResponse + 29, // 53: orchestrator.v1.OrchestratorService.ListProjectStatistics:output_type -> orchestrator.v1.StructResponse + 29, // 54: orchestrator.v1.OrchestratorService.ListEvaluationExecutionsByDatapack:output_type -> orchestrator.v1.StructResponse + 29, // 55: orchestrator.v1.OrchestratorService.ListEvaluationExecutionsByDataset:output_type -> orchestrator.v1.StructResponse + 29, // 56: orchestrator.v1.OrchestratorService.GetTask:output_type -> orchestrator.v1.StructResponse + 29, // 57: orchestrator.v1.OrchestratorService.PollTaskLogs:output_type -> orchestrator.v1.StructResponse + 29, // 58: orchestrator.v1.OrchestratorService.ListTasks:output_type -> orchestrator.v1.StructResponse + 29, // 59: orchestrator.v1.OrchestratorService.GetTrace:output_type -> orchestrator.v1.StructResponse + 29, // 60: orchestrator.v1.OrchestratorService.ListTraces:output_type -> orchestrator.v1.StructResponse + 29, // 61: orchestrator.v1.OrchestratorService.GetGroupStats:output_type -> orchestrator.v1.StructResponse + 29, // 62: orchestrator.v1.OrchestratorService.GetTraceStreamState:output_type -> orchestrator.v1.StructResponse + 29, // 63: orchestrator.v1.OrchestratorService.ReadTraceStreamMessages:output_type -> orchestrator.v1.StructResponse + 29, // 64: orchestrator.v1.OrchestratorService.GetGroupStreamState:output_type -> orchestrator.v1.StructResponse + 29, // 65: orchestrator.v1.OrchestratorService.ReadGroupStreamMessages:output_type -> orchestrator.v1.StructResponse + 29, // 66: orchestrator.v1.OrchestratorService.ReadNotificationStreamMessages:output_type -> orchestrator.v1.StructResponse + 29, // 67: orchestrator.v1.OrchestratorService.ListDeadLetterTasks:output_type -> orchestrator.v1.StructResponse + 28, // 68: orchestrator.v1.OrchestratorService.RetryTask:output_type -> orchestrator.v1.RetryTaskResponse + 40, // [40:69] is the sub-list for method output_type + 11, // [11:40] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_proto_orchestrator_v1_orchestrator_proto_init() } +func file_proto_orchestrator_v1_orchestrator_proto_init() { + if File_proto_orchestrator_v1_orchestrator_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_orchestrator_v1_orchestrator_proto_rawDesc), len(file_proto_orchestrator_v1_orchestrator_proto_rawDesc)), + NumEnums: 0, + NumMessages: 30, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_orchestrator_v1_orchestrator_proto_goTypes, + DependencyIndexes: file_proto_orchestrator_v1_orchestrator_proto_depIdxs, + MessageInfos: file_proto_orchestrator_v1_orchestrator_proto_msgTypes, + }.Build() + File_proto_orchestrator_v1_orchestrator_proto = out.File + file_proto_orchestrator_v1_orchestrator_proto_goTypes = nil + file_proto_orchestrator_v1_orchestrator_proto_depIdxs = nil +} diff --git a/src/proto/orchestrator/v1/orchestrator.proto b/src/proto/orchestrator/v1/orchestrator.proto new file mode 100644 index 00000000..9dd96070 --- /dev/null +++ b/src/proto/orchestrator/v1/orchestrator.proto @@ -0,0 +1,192 @@ +syntax = "proto3"; + +package orchestrator.v1; + +option go_package = "aegis/proto/orchestrator/v1;orchestratorv1"; + +import "google/protobuf/struct.proto"; + +service OrchestratorService { + rpc Ping(PingRequest) returns (PingResponse); + rpc SubmitExecution(SubmitExecutionRequest) returns (SubmitExecutionResponse); + rpc SubmitFaultInjection(SubmitFaultInjectionRequest) returns (SubmitFaultInjectionResponse); + rpc SubmitDatapackBuilding(SubmitDatapackBuildingRequest) returns (SubmitDatapackBuildingResponse); + rpc CreateExecution(MutationRequest) returns (StructResponse); + rpc CreateInjection(MutationRequest) returns (StructResponse); + rpc UpdateExecutionState(MutationRequest) returns (StructResponse); + rpc UpdateInjectionState(MutationRequest) returns (StructResponse); + rpc UpdateInjectionTimestamps(MutationRequest) returns (StructResponse); + rpc GetInjectionMetrics(MutationRequest) returns (StructResponse); + rpc GetExecutionMetrics(MutationRequest) returns (StructResponse); + rpc CancelTask(CancelTaskRequest) returns (CancelTaskResponse); + rpc GetExecution(GetExecutionRequest) returns (StructResponse); + rpc ListProjectStatistics(ListProjectStatisticsRequest) returns (StructResponse); + rpc ListEvaluationExecutionsByDatapack(MutationRequest) returns (StructResponse); + rpc ListEvaluationExecutionsByDataset(MutationRequest) returns (StructResponse); + rpc GetTask(GetTaskRequest) returns (StructResponse); + rpc PollTaskLogs(PollTaskLogsRequest) returns (StructResponse); + rpc ListTasks(ListTasksRequest) returns (StructResponse); + rpc GetTrace(GetTraceRequest) returns (StructResponse); + rpc ListTraces(ListTracesRequest) returns (StructResponse); + rpc GetGroupStats(GetGroupStatsRequest) returns (StructResponse); + rpc GetTraceStreamState(GetTraceStreamStateRequest) returns (StructResponse); + rpc ReadTraceStreamMessages(ReadStreamMessagesRequest) returns (StructResponse); + rpc GetGroupStreamState(GetGroupStreamStateRequest) returns (StructResponse); + rpc ReadGroupStreamMessages(ReadStreamMessagesRequest) returns (StructResponse); + rpc ReadNotificationStreamMessages(ReadStreamMessagesRequest) returns (StructResponse); + rpc ListDeadLetterTasks(ListDeadLetterTasksRequest) returns (StructResponse); + rpc RetryTask(RetryTaskRequest) returns (RetryTaskResponse); +} + +message PingRequest {} + +message PingResponse { + string service = 1; + string app_id = 2; + string status = 3; + int64 timestamp_unix = 4; +} + +message SubmitExecutionRequest { + string group_id = 1; + int64 user_id = 2; + google.protobuf.Struct body = 10; +} + +message SubmitExecutionResponse { + string group_id = 1; + repeated SubmittedExecutionItem items = 2; +} + +message SubmittedExecutionItem { + int64 index = 1; + string trace_id = 2; + string task_id = 3; + int64 algorithm_id = 4; + int64 algorithm_version_id = 5; + int64 datapack_id = 6; + int64 dataset_id = 7; + bool has_datapack_id = 8; + bool has_dataset_id = 9; +} + +message SubmitFaultInjectionRequest { + string group_id = 1; + int64 user_id = 2; + int64 project_id = 3; + google.protobuf.Struct body = 10; +} + +message SubmitFaultInjectionResponse { + string group_id = 1; + repeated SubmittedInjectionItem items = 2; + int64 original_count = 3; + InjectionWarnings warnings = 4; +} + +message SubmittedInjectionItem { + int64 index = 1; + string trace_id = 2; + string task_id = 3; +} + +message InjectionWarnings { + repeated string duplicate_services_in_batch = 1; + repeated int64 duplicate_batches_in_request = 2; + repeated int64 batches_exist_in_database = 3; +} + +message SubmitDatapackBuildingRequest { + string group_id = 1; + int64 user_id = 2; + int64 project_id = 3; + google.protobuf.Struct body = 10; +} + +message SubmitDatapackBuildingResponse { + string group_id = 1; + repeated SubmittedBuildingItem items = 2; +} + +message SubmittedBuildingItem { + int64 index = 1; + string trace_id = 2; + string task_id = 3; +} + +message CancelTaskRequest { + string task_id = 1; +} + +message CancelTaskResponse { + bool cancelled = 1; +} + +message MutationRequest { + google.protobuf.Struct body = 1; +} + +message GetExecutionRequest { + int64 execution_id = 1; +} + +message ListProjectStatisticsRequest { + repeated int64 project_ids = 1; +} + +message GetTaskRequest { + string task_id = 1; +} + +message PollTaskLogsRequest { + string task_id = 1; + int64 after_unix_nano = 2; +} + +message ListTasksRequest { + google.protobuf.Struct query = 1; +} + +message GetTraceRequest { + string trace_id = 1; +} + +message ListTracesRequest { + google.protobuf.Struct query = 1; +} + +message GetGroupStatsRequest { + string group_id = 1; +} + +message GetTraceStreamStateRequest { + string trace_id = 1; +} + +message GetGroupStreamStateRequest { + string group_id = 1; +} + +message ReadStreamMessagesRequest { + string stream_key = 1; + string last_id = 2; + int64 count = 3; + int64 block_millis = 4; +} + +message ListDeadLetterTasksRequest { + int64 limit = 1; +} + +message RetryTaskRequest { + string task_id = 1; +} + +message RetryTaskResponse { + bool accepted = 1; + string queue = 2; +} + +message StructResponse { + google.protobuf.Struct data = 1; +} diff --git a/src/proto/orchestrator/v1/orchestrator_grpc.pb.go b/src/proto/orchestrator/v1/orchestrator_grpc.pb.go new file mode 100644 index 00000000..ea1fc5b5 --- /dev/null +++ b/src/proto/orchestrator/v1/orchestrator_grpc.pb.go @@ -0,0 +1,1185 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v5.29.3 +// source: proto/orchestrator/v1/orchestrator.proto + +package orchestratorv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// 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 ( + OrchestratorService_Ping_FullMethodName = "/orchestrator.v1.OrchestratorService/Ping" + OrchestratorService_SubmitExecution_FullMethodName = "/orchestrator.v1.OrchestratorService/SubmitExecution" + OrchestratorService_SubmitFaultInjection_FullMethodName = "/orchestrator.v1.OrchestratorService/SubmitFaultInjection" + OrchestratorService_SubmitDatapackBuilding_FullMethodName = "/orchestrator.v1.OrchestratorService/SubmitDatapackBuilding" + OrchestratorService_CreateExecution_FullMethodName = "/orchestrator.v1.OrchestratorService/CreateExecution" + OrchestratorService_CreateInjection_FullMethodName = "/orchestrator.v1.OrchestratorService/CreateInjection" + OrchestratorService_UpdateExecutionState_FullMethodName = "/orchestrator.v1.OrchestratorService/UpdateExecutionState" + OrchestratorService_UpdateInjectionState_FullMethodName = "/orchestrator.v1.OrchestratorService/UpdateInjectionState" + OrchestratorService_UpdateInjectionTimestamps_FullMethodName = "/orchestrator.v1.OrchestratorService/UpdateInjectionTimestamps" + OrchestratorService_GetInjectionMetrics_FullMethodName = "/orchestrator.v1.OrchestratorService/GetInjectionMetrics" + OrchestratorService_GetExecutionMetrics_FullMethodName = "/orchestrator.v1.OrchestratorService/GetExecutionMetrics" + OrchestratorService_CancelTask_FullMethodName = "/orchestrator.v1.OrchestratorService/CancelTask" + OrchestratorService_GetExecution_FullMethodName = "/orchestrator.v1.OrchestratorService/GetExecution" + OrchestratorService_ListProjectStatistics_FullMethodName = "/orchestrator.v1.OrchestratorService/ListProjectStatistics" + OrchestratorService_ListEvaluationExecutionsByDatapack_FullMethodName = "/orchestrator.v1.OrchestratorService/ListEvaluationExecutionsByDatapack" + OrchestratorService_ListEvaluationExecutionsByDataset_FullMethodName = "/orchestrator.v1.OrchestratorService/ListEvaluationExecutionsByDataset" + OrchestratorService_GetTask_FullMethodName = "/orchestrator.v1.OrchestratorService/GetTask" + OrchestratorService_PollTaskLogs_FullMethodName = "/orchestrator.v1.OrchestratorService/PollTaskLogs" + OrchestratorService_ListTasks_FullMethodName = "/orchestrator.v1.OrchestratorService/ListTasks" + OrchestratorService_GetTrace_FullMethodName = "/orchestrator.v1.OrchestratorService/GetTrace" + OrchestratorService_ListTraces_FullMethodName = "/orchestrator.v1.OrchestratorService/ListTraces" + OrchestratorService_GetGroupStats_FullMethodName = "/orchestrator.v1.OrchestratorService/GetGroupStats" + OrchestratorService_GetTraceStreamState_FullMethodName = "/orchestrator.v1.OrchestratorService/GetTraceStreamState" + OrchestratorService_ReadTraceStreamMessages_FullMethodName = "/orchestrator.v1.OrchestratorService/ReadTraceStreamMessages" + OrchestratorService_GetGroupStreamState_FullMethodName = "/orchestrator.v1.OrchestratorService/GetGroupStreamState" + OrchestratorService_ReadGroupStreamMessages_FullMethodName = "/orchestrator.v1.OrchestratorService/ReadGroupStreamMessages" + OrchestratorService_ReadNotificationStreamMessages_FullMethodName = "/orchestrator.v1.OrchestratorService/ReadNotificationStreamMessages" + OrchestratorService_ListDeadLetterTasks_FullMethodName = "/orchestrator.v1.OrchestratorService/ListDeadLetterTasks" + OrchestratorService_RetryTask_FullMethodName = "/orchestrator.v1.OrchestratorService/RetryTask" +) + +// OrchestratorServiceClient is the client API for OrchestratorService 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. +type OrchestratorServiceClient interface { + Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) + SubmitExecution(ctx context.Context, in *SubmitExecutionRequest, opts ...grpc.CallOption) (*SubmitExecutionResponse, error) + SubmitFaultInjection(ctx context.Context, in *SubmitFaultInjectionRequest, opts ...grpc.CallOption) (*SubmitFaultInjectionResponse, error) + SubmitDatapackBuilding(ctx context.Context, in *SubmitDatapackBuildingRequest, opts ...grpc.CallOption) (*SubmitDatapackBuildingResponse, error) + CreateExecution(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) + CreateInjection(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) + UpdateExecutionState(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) + UpdateInjectionState(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) + UpdateInjectionTimestamps(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) + GetInjectionMetrics(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) + GetExecutionMetrics(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) + CancelTask(ctx context.Context, in *CancelTaskRequest, opts ...grpc.CallOption) (*CancelTaskResponse, error) + GetExecution(ctx context.Context, in *GetExecutionRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListProjectStatistics(ctx context.Context, in *ListProjectStatisticsRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListEvaluationExecutionsByDatapack(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListEvaluationExecutionsByDataset(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) + GetTask(ctx context.Context, in *GetTaskRequest, opts ...grpc.CallOption) (*StructResponse, error) + PollTaskLogs(ctx context.Context, in *PollTaskLogsRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListTasks(ctx context.Context, in *ListTasksRequest, opts ...grpc.CallOption) (*StructResponse, error) + GetTrace(ctx context.Context, in *GetTraceRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListTraces(ctx context.Context, in *ListTracesRequest, opts ...grpc.CallOption) (*StructResponse, error) + GetGroupStats(ctx context.Context, in *GetGroupStatsRequest, opts ...grpc.CallOption) (*StructResponse, error) + GetTraceStreamState(ctx context.Context, in *GetTraceStreamStateRequest, opts ...grpc.CallOption) (*StructResponse, error) + ReadTraceStreamMessages(ctx context.Context, in *ReadStreamMessagesRequest, opts ...grpc.CallOption) (*StructResponse, error) + GetGroupStreamState(ctx context.Context, in *GetGroupStreamStateRequest, opts ...grpc.CallOption) (*StructResponse, error) + ReadGroupStreamMessages(ctx context.Context, in *ReadStreamMessagesRequest, opts ...grpc.CallOption) (*StructResponse, error) + ReadNotificationStreamMessages(ctx context.Context, in *ReadStreamMessagesRequest, opts ...grpc.CallOption) (*StructResponse, error) + ListDeadLetterTasks(ctx context.Context, in *ListDeadLetterTasksRequest, opts ...grpc.CallOption) (*StructResponse, error) + RetryTask(ctx context.Context, in *RetryTaskRequest, opts ...grpc.CallOption) (*RetryTaskResponse, error) +} + +type orchestratorServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewOrchestratorServiceClient(cc grpc.ClientConnInterface) OrchestratorServiceClient { + return &orchestratorServiceClient{cc} +} + +func (c *orchestratorServiceClient) Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PingResponse) + err := c.cc.Invoke(ctx, OrchestratorService_Ping_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) SubmitExecution(ctx context.Context, in *SubmitExecutionRequest, opts ...grpc.CallOption) (*SubmitExecutionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SubmitExecutionResponse) + err := c.cc.Invoke(ctx, OrchestratorService_SubmitExecution_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) SubmitFaultInjection(ctx context.Context, in *SubmitFaultInjectionRequest, opts ...grpc.CallOption) (*SubmitFaultInjectionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SubmitFaultInjectionResponse) + err := c.cc.Invoke(ctx, OrchestratorService_SubmitFaultInjection_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) SubmitDatapackBuilding(ctx context.Context, in *SubmitDatapackBuildingRequest, opts ...grpc.CallOption) (*SubmitDatapackBuildingResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SubmitDatapackBuildingResponse) + err := c.cc.Invoke(ctx, OrchestratorService_SubmitDatapackBuilding_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) CreateExecution(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_CreateExecution_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) CreateInjection(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_CreateInjection_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) UpdateExecutionState(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_UpdateExecutionState_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) UpdateInjectionState(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_UpdateInjectionState_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) UpdateInjectionTimestamps(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_UpdateInjectionTimestamps_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) GetInjectionMetrics(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_GetInjectionMetrics_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) GetExecutionMetrics(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_GetExecutionMetrics_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) CancelTask(ctx context.Context, in *CancelTaskRequest, opts ...grpc.CallOption) (*CancelTaskResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CancelTaskResponse) + err := c.cc.Invoke(ctx, OrchestratorService_CancelTask_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) GetExecution(ctx context.Context, in *GetExecutionRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_GetExecution_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) ListProjectStatistics(ctx context.Context, in *ListProjectStatisticsRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_ListProjectStatistics_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) ListEvaluationExecutionsByDatapack(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_ListEvaluationExecutionsByDatapack_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) ListEvaluationExecutionsByDataset(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_ListEvaluationExecutionsByDataset_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) GetTask(ctx context.Context, in *GetTaskRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_GetTask_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) PollTaskLogs(ctx context.Context, in *PollTaskLogsRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_PollTaskLogs_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) ListTasks(ctx context.Context, in *ListTasksRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_ListTasks_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) GetTrace(ctx context.Context, in *GetTraceRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_GetTrace_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) ListTraces(ctx context.Context, in *ListTracesRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_ListTraces_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) GetGroupStats(ctx context.Context, in *GetGroupStatsRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_GetGroupStats_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) GetTraceStreamState(ctx context.Context, in *GetTraceStreamStateRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_GetTraceStreamState_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) ReadTraceStreamMessages(ctx context.Context, in *ReadStreamMessagesRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_ReadTraceStreamMessages_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) GetGroupStreamState(ctx context.Context, in *GetGroupStreamStateRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_GetGroupStreamState_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) ReadGroupStreamMessages(ctx context.Context, in *ReadStreamMessagesRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_ReadGroupStreamMessages_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) ReadNotificationStreamMessages(ctx context.Context, in *ReadStreamMessagesRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_ReadNotificationStreamMessages_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) ListDeadLetterTasks(ctx context.Context, in *ListDeadLetterTasksRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, OrchestratorService_ListDeadLetterTasks_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *orchestratorServiceClient) RetryTask(ctx context.Context, in *RetryTaskRequest, opts ...grpc.CallOption) (*RetryTaskResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RetryTaskResponse) + err := c.cc.Invoke(ctx, OrchestratorService_RetryTask_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// OrchestratorServiceServer is the server API for OrchestratorService service. +// All implementations must embed UnimplementedOrchestratorServiceServer +// for forward compatibility. +type OrchestratorServiceServer interface { + Ping(context.Context, *PingRequest) (*PingResponse, error) + SubmitExecution(context.Context, *SubmitExecutionRequest) (*SubmitExecutionResponse, error) + SubmitFaultInjection(context.Context, *SubmitFaultInjectionRequest) (*SubmitFaultInjectionResponse, error) + SubmitDatapackBuilding(context.Context, *SubmitDatapackBuildingRequest) (*SubmitDatapackBuildingResponse, error) + CreateExecution(context.Context, *MutationRequest) (*StructResponse, error) + CreateInjection(context.Context, *MutationRequest) (*StructResponse, error) + UpdateExecutionState(context.Context, *MutationRequest) (*StructResponse, error) + UpdateInjectionState(context.Context, *MutationRequest) (*StructResponse, error) + UpdateInjectionTimestamps(context.Context, *MutationRequest) (*StructResponse, error) + GetInjectionMetrics(context.Context, *MutationRequest) (*StructResponse, error) + GetExecutionMetrics(context.Context, *MutationRequest) (*StructResponse, error) + CancelTask(context.Context, *CancelTaskRequest) (*CancelTaskResponse, error) + GetExecution(context.Context, *GetExecutionRequest) (*StructResponse, error) + ListProjectStatistics(context.Context, *ListProjectStatisticsRequest) (*StructResponse, error) + ListEvaluationExecutionsByDatapack(context.Context, *MutationRequest) (*StructResponse, error) + ListEvaluationExecutionsByDataset(context.Context, *MutationRequest) (*StructResponse, error) + GetTask(context.Context, *GetTaskRequest) (*StructResponse, error) + PollTaskLogs(context.Context, *PollTaskLogsRequest) (*StructResponse, error) + ListTasks(context.Context, *ListTasksRequest) (*StructResponse, error) + GetTrace(context.Context, *GetTraceRequest) (*StructResponse, error) + ListTraces(context.Context, *ListTracesRequest) (*StructResponse, error) + GetGroupStats(context.Context, *GetGroupStatsRequest) (*StructResponse, error) + GetTraceStreamState(context.Context, *GetTraceStreamStateRequest) (*StructResponse, error) + ReadTraceStreamMessages(context.Context, *ReadStreamMessagesRequest) (*StructResponse, error) + GetGroupStreamState(context.Context, *GetGroupStreamStateRequest) (*StructResponse, error) + ReadGroupStreamMessages(context.Context, *ReadStreamMessagesRequest) (*StructResponse, error) + ReadNotificationStreamMessages(context.Context, *ReadStreamMessagesRequest) (*StructResponse, error) + ListDeadLetterTasks(context.Context, *ListDeadLetterTasksRequest) (*StructResponse, error) + RetryTask(context.Context, *RetryTaskRequest) (*RetryTaskResponse, error) + mustEmbedUnimplementedOrchestratorServiceServer() +} + +// UnimplementedOrchestratorServiceServer 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 UnimplementedOrchestratorServiceServer struct{} + +func (UnimplementedOrchestratorServiceServer) Ping(context.Context, *PingRequest) (*PingResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Ping not implemented") +} +func (UnimplementedOrchestratorServiceServer) SubmitExecution(context.Context, *SubmitExecutionRequest) (*SubmitExecutionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SubmitExecution not implemented") +} +func (UnimplementedOrchestratorServiceServer) SubmitFaultInjection(context.Context, *SubmitFaultInjectionRequest) (*SubmitFaultInjectionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SubmitFaultInjection not implemented") +} +func (UnimplementedOrchestratorServiceServer) SubmitDatapackBuilding(context.Context, *SubmitDatapackBuildingRequest) (*SubmitDatapackBuildingResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SubmitDatapackBuilding not implemented") +} +func (UnimplementedOrchestratorServiceServer) CreateExecution(context.Context, *MutationRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateExecution not implemented") +} +func (UnimplementedOrchestratorServiceServer) CreateInjection(context.Context, *MutationRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateInjection not implemented") +} +func (UnimplementedOrchestratorServiceServer) UpdateExecutionState(context.Context, *MutationRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateExecutionState not implemented") +} +func (UnimplementedOrchestratorServiceServer) UpdateInjectionState(context.Context, *MutationRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateInjectionState not implemented") +} +func (UnimplementedOrchestratorServiceServer) UpdateInjectionTimestamps(context.Context, *MutationRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateInjectionTimestamps not implemented") +} +func (UnimplementedOrchestratorServiceServer) GetInjectionMetrics(context.Context, *MutationRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetInjectionMetrics not implemented") +} +func (UnimplementedOrchestratorServiceServer) GetExecutionMetrics(context.Context, *MutationRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetExecutionMetrics not implemented") +} +func (UnimplementedOrchestratorServiceServer) CancelTask(context.Context, *CancelTaskRequest) (*CancelTaskResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CancelTask not implemented") +} +func (UnimplementedOrchestratorServiceServer) GetExecution(context.Context, *GetExecutionRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetExecution not implemented") +} +func (UnimplementedOrchestratorServiceServer) ListProjectStatistics(context.Context, *ListProjectStatisticsRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListProjectStatistics not implemented") +} +func (UnimplementedOrchestratorServiceServer) ListEvaluationExecutionsByDatapack(context.Context, *MutationRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListEvaluationExecutionsByDatapack not implemented") +} +func (UnimplementedOrchestratorServiceServer) ListEvaluationExecutionsByDataset(context.Context, *MutationRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListEvaluationExecutionsByDataset not implemented") +} +func (UnimplementedOrchestratorServiceServer) GetTask(context.Context, *GetTaskRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetTask not implemented") +} +func (UnimplementedOrchestratorServiceServer) PollTaskLogs(context.Context, *PollTaskLogsRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method PollTaskLogs not implemented") +} +func (UnimplementedOrchestratorServiceServer) ListTasks(context.Context, *ListTasksRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListTasks not implemented") +} +func (UnimplementedOrchestratorServiceServer) GetTrace(context.Context, *GetTraceRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetTrace not implemented") +} +func (UnimplementedOrchestratorServiceServer) ListTraces(context.Context, *ListTracesRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListTraces not implemented") +} +func (UnimplementedOrchestratorServiceServer) GetGroupStats(context.Context, *GetGroupStatsRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetGroupStats not implemented") +} +func (UnimplementedOrchestratorServiceServer) GetTraceStreamState(context.Context, *GetTraceStreamStateRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetTraceStreamState not implemented") +} +func (UnimplementedOrchestratorServiceServer) ReadTraceStreamMessages(context.Context, *ReadStreamMessagesRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ReadTraceStreamMessages not implemented") +} +func (UnimplementedOrchestratorServiceServer) GetGroupStreamState(context.Context, *GetGroupStreamStateRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetGroupStreamState not implemented") +} +func (UnimplementedOrchestratorServiceServer) ReadGroupStreamMessages(context.Context, *ReadStreamMessagesRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ReadGroupStreamMessages not implemented") +} +func (UnimplementedOrchestratorServiceServer) ReadNotificationStreamMessages(context.Context, *ReadStreamMessagesRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ReadNotificationStreamMessages not implemented") +} +func (UnimplementedOrchestratorServiceServer) ListDeadLetterTasks(context.Context, *ListDeadLetterTasksRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListDeadLetterTasks not implemented") +} +func (UnimplementedOrchestratorServiceServer) RetryTask(context.Context, *RetryTaskRequest) (*RetryTaskResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RetryTask not implemented") +} +func (UnimplementedOrchestratorServiceServer) mustEmbedUnimplementedOrchestratorServiceServer() {} +func (UnimplementedOrchestratorServiceServer) testEmbeddedByValue() {} + +// UnsafeOrchestratorServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to OrchestratorServiceServer will +// result in compilation errors. +type UnsafeOrchestratorServiceServer interface { + mustEmbedUnimplementedOrchestratorServiceServer() +} + +func RegisterOrchestratorServiceServer(s grpc.ServiceRegistrar, srv OrchestratorServiceServer) { + // If the following call panics, it indicates UnimplementedOrchestratorServiceServer 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(&OrchestratorService_ServiceDesc, srv) +} + +func _OrchestratorService_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).Ping(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_Ping_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).Ping(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_SubmitExecution_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SubmitExecutionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).SubmitExecution(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_SubmitExecution_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).SubmitExecution(ctx, req.(*SubmitExecutionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_SubmitFaultInjection_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SubmitFaultInjectionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).SubmitFaultInjection(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_SubmitFaultInjection_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).SubmitFaultInjection(ctx, req.(*SubmitFaultInjectionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_SubmitDatapackBuilding_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SubmitDatapackBuildingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).SubmitDatapackBuilding(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_SubmitDatapackBuilding_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).SubmitDatapackBuilding(ctx, req.(*SubmitDatapackBuildingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_CreateExecution_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).CreateExecution(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_CreateExecution_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).CreateExecution(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_CreateInjection_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).CreateInjection(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_CreateInjection_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).CreateInjection(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_UpdateExecutionState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).UpdateExecutionState(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_UpdateExecutionState_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).UpdateExecutionState(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_UpdateInjectionState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).UpdateInjectionState(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_UpdateInjectionState_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).UpdateInjectionState(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_UpdateInjectionTimestamps_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).UpdateInjectionTimestamps(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_UpdateInjectionTimestamps_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).UpdateInjectionTimestamps(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_GetInjectionMetrics_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).GetInjectionMetrics(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_GetInjectionMetrics_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).GetInjectionMetrics(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_GetExecutionMetrics_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).GetExecutionMetrics(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_GetExecutionMetrics_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).GetExecutionMetrics(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_CancelTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CancelTaskRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).CancelTask(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_CancelTask_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).CancelTask(ctx, req.(*CancelTaskRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_GetExecution_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetExecutionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).GetExecution(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_GetExecution_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).GetExecution(ctx, req.(*GetExecutionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_ListProjectStatistics_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListProjectStatisticsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).ListProjectStatistics(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_ListProjectStatistics_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).ListProjectStatistics(ctx, req.(*ListProjectStatisticsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_ListEvaluationExecutionsByDatapack_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).ListEvaluationExecutionsByDatapack(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_ListEvaluationExecutionsByDatapack_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).ListEvaluationExecutionsByDatapack(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_ListEvaluationExecutionsByDataset_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).ListEvaluationExecutionsByDataset(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_ListEvaluationExecutionsByDataset_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).ListEvaluationExecutionsByDataset(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_GetTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetTaskRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).GetTask(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_GetTask_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).GetTask(ctx, req.(*GetTaskRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_PollTaskLogs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PollTaskLogsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).PollTaskLogs(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_PollTaskLogs_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).PollTaskLogs(ctx, req.(*PollTaskLogsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_ListTasks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListTasksRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).ListTasks(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_ListTasks_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).ListTasks(ctx, req.(*ListTasksRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_GetTrace_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetTraceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).GetTrace(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_GetTrace_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).GetTrace(ctx, req.(*GetTraceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_ListTraces_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListTracesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).ListTraces(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_ListTraces_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).ListTraces(ctx, req.(*ListTracesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_GetGroupStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetGroupStatsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).GetGroupStats(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_GetGroupStats_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).GetGroupStats(ctx, req.(*GetGroupStatsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_GetTraceStreamState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetTraceStreamStateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).GetTraceStreamState(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_GetTraceStreamState_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).GetTraceStreamState(ctx, req.(*GetTraceStreamStateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_ReadTraceStreamMessages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReadStreamMessagesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).ReadTraceStreamMessages(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_ReadTraceStreamMessages_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).ReadTraceStreamMessages(ctx, req.(*ReadStreamMessagesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_GetGroupStreamState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetGroupStreamStateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).GetGroupStreamState(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_GetGroupStreamState_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).GetGroupStreamState(ctx, req.(*GetGroupStreamStateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_ReadGroupStreamMessages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReadStreamMessagesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).ReadGroupStreamMessages(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_ReadGroupStreamMessages_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).ReadGroupStreamMessages(ctx, req.(*ReadStreamMessagesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_ReadNotificationStreamMessages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReadStreamMessagesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).ReadNotificationStreamMessages(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_ReadNotificationStreamMessages_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).ReadNotificationStreamMessages(ctx, req.(*ReadStreamMessagesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_ListDeadLetterTasks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListDeadLetterTasksRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).ListDeadLetterTasks(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_ListDeadLetterTasks_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).ListDeadLetterTasks(ctx, req.(*ListDeadLetterTasksRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OrchestratorService_RetryTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RetryTaskRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrchestratorServiceServer).RetryTask(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrchestratorService_RetryTask_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrchestratorServiceServer).RetryTask(ctx, req.(*RetryTaskRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// OrchestratorService_ServiceDesc is the grpc.ServiceDesc for OrchestratorService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var OrchestratorService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "orchestrator.v1.OrchestratorService", + HandlerType: (*OrchestratorServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Ping", + Handler: _OrchestratorService_Ping_Handler, + }, + { + MethodName: "SubmitExecution", + Handler: _OrchestratorService_SubmitExecution_Handler, + }, + { + MethodName: "SubmitFaultInjection", + Handler: _OrchestratorService_SubmitFaultInjection_Handler, + }, + { + MethodName: "SubmitDatapackBuilding", + Handler: _OrchestratorService_SubmitDatapackBuilding_Handler, + }, + { + MethodName: "CreateExecution", + Handler: _OrchestratorService_CreateExecution_Handler, + }, + { + MethodName: "CreateInjection", + Handler: _OrchestratorService_CreateInjection_Handler, + }, + { + MethodName: "UpdateExecutionState", + Handler: _OrchestratorService_UpdateExecutionState_Handler, + }, + { + MethodName: "UpdateInjectionState", + Handler: _OrchestratorService_UpdateInjectionState_Handler, + }, + { + MethodName: "UpdateInjectionTimestamps", + Handler: _OrchestratorService_UpdateInjectionTimestamps_Handler, + }, + { + MethodName: "GetInjectionMetrics", + Handler: _OrchestratorService_GetInjectionMetrics_Handler, + }, + { + MethodName: "GetExecutionMetrics", + Handler: _OrchestratorService_GetExecutionMetrics_Handler, + }, + { + MethodName: "CancelTask", + Handler: _OrchestratorService_CancelTask_Handler, + }, + { + MethodName: "GetExecution", + Handler: _OrchestratorService_GetExecution_Handler, + }, + { + MethodName: "ListProjectStatistics", + Handler: _OrchestratorService_ListProjectStatistics_Handler, + }, + { + MethodName: "ListEvaluationExecutionsByDatapack", + Handler: _OrchestratorService_ListEvaluationExecutionsByDatapack_Handler, + }, + { + MethodName: "ListEvaluationExecutionsByDataset", + Handler: _OrchestratorService_ListEvaluationExecutionsByDataset_Handler, + }, + { + MethodName: "GetTask", + Handler: _OrchestratorService_GetTask_Handler, + }, + { + MethodName: "PollTaskLogs", + Handler: _OrchestratorService_PollTaskLogs_Handler, + }, + { + MethodName: "ListTasks", + Handler: _OrchestratorService_ListTasks_Handler, + }, + { + MethodName: "GetTrace", + Handler: _OrchestratorService_GetTrace_Handler, + }, + { + MethodName: "ListTraces", + Handler: _OrchestratorService_ListTraces_Handler, + }, + { + MethodName: "GetGroupStats", + Handler: _OrchestratorService_GetGroupStats_Handler, + }, + { + MethodName: "GetTraceStreamState", + Handler: _OrchestratorService_GetTraceStreamState_Handler, + }, + { + MethodName: "ReadTraceStreamMessages", + Handler: _OrchestratorService_ReadTraceStreamMessages_Handler, + }, + { + MethodName: "GetGroupStreamState", + Handler: _OrchestratorService_GetGroupStreamState_Handler, + }, + { + MethodName: "ReadGroupStreamMessages", + Handler: _OrchestratorService_ReadGroupStreamMessages_Handler, + }, + { + MethodName: "ReadNotificationStreamMessages", + Handler: _OrchestratorService_ReadNotificationStreamMessages_Handler, + }, + { + MethodName: "ListDeadLetterTasks", + Handler: _OrchestratorService_ListDeadLetterTasks_Handler, + }, + { + MethodName: "RetryTask", + Handler: _OrchestratorService_RetryTask_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto/orchestrator/v1/orchestrator.proto", +} diff --git a/src/proto/resource/v1/resource.pb.go b/src/proto/resource/v1/resource.pb.go new file mode 100644 index 00000000..67722dd0 --- /dev/null +++ b/src/proto/resource/v1/resource.pb.go @@ -0,0 +1,976 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: proto/resource/v1/resource.proto + +package resourcev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type PingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingRequest) Reset() { + *x = PingRequest{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingRequest) ProtoMessage() {} + +func (x *PingRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. +func (*PingRequest) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{0} +} + +type PingResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Service string `protobuf:"bytes,1,opt,name=service,proto3" json:"service,omitempty"` + AppId string `protobuf:"bytes,2,opt,name=app_id,json=appId,proto3" json:"app_id,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + TimestampUnix int64 `protobuf:"varint,4,opt,name=timestamp_unix,json=timestampUnix,proto3" json:"timestamp_unix,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingResponse) Reset() { + *x = PingResponse{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingResponse) ProtoMessage() {} + +func (x *PingResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingResponse.ProtoReflect.Descriptor instead. +func (*PingResponse) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{1} +} + +func (x *PingResponse) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *PingResponse) GetAppId() string { + if x != nil { + return x.AppId + } + return "" +} + +func (x *PingResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *PingResponse) GetTimestampUnix() int64 { + if x != nil { + return x.TimestampUnix + } + return 0 +} + +type ListProjectsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Query *structpb.Struct `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListProjectsRequest) Reset() { + *x = ListProjectsRequest{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListProjectsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListProjectsRequest) ProtoMessage() {} + +func (x *ListProjectsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListProjectsRequest.ProtoReflect.Descriptor instead. +func (*ListProjectsRequest) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{2} +} + +func (x *ListProjectsRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type ListContainersRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Query *structpb.Struct `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListContainersRequest) Reset() { + *x = ListContainersRequest{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListContainersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListContainersRequest) ProtoMessage() {} + +func (x *ListContainersRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListContainersRequest.ProtoReflect.Descriptor instead. +func (*ListContainersRequest) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{3} +} + +func (x *ListContainersRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type ListDatasetsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Query *structpb.Struct `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDatasetsRequest) Reset() { + *x = ListDatasetsRequest{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDatasetsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDatasetsRequest) ProtoMessage() {} + +func (x *ListDatasetsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDatasetsRequest.ProtoReflect.Descriptor instead. +func (*ListDatasetsRequest) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{4} +} + +func (x *ListDatasetsRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type ListDatapackEvaluationsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Query *structpb.Struct `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDatapackEvaluationsRequest) Reset() { + *x = ListDatapackEvaluationsRequest{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDatapackEvaluationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDatapackEvaluationsRequest) ProtoMessage() {} + +func (x *ListDatapackEvaluationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDatapackEvaluationsRequest.ProtoReflect.Descriptor instead. +func (*ListDatapackEvaluationsRequest) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{5} +} + +func (x *ListDatapackEvaluationsRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +func (x *ListDatapackEvaluationsRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +type ListDatasetEvaluationsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Query *structpb.Struct `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDatasetEvaluationsRequest) Reset() { + *x = ListDatasetEvaluationsRequest{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDatasetEvaluationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDatasetEvaluationsRequest) ProtoMessage() {} + +func (x *ListDatasetEvaluationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDatasetEvaluationsRequest.ProtoReflect.Descriptor instead. +func (*ListDatasetEvaluationsRequest) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{6} +} + +func (x *ListDatasetEvaluationsRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +func (x *ListDatasetEvaluationsRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +type ListEvaluationsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Query *structpb.Struct `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListEvaluationsRequest) Reset() { + *x = ListEvaluationsRequest{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListEvaluationsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListEvaluationsRequest) ProtoMessage() {} + +func (x *ListEvaluationsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListEvaluationsRequest.ProtoReflect.Descriptor instead. +func (*ListEvaluationsRequest) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{7} +} + +func (x *ListEvaluationsRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type MutationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Body *structpb.Struct `protobuf:"bytes,1,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MutationRequest) Reset() { + *x = MutationRequest{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MutationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MutationRequest) ProtoMessage() {} + +func (x *MutationRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MutationRequest.ProtoReflect.Descriptor instead. +func (*MutationRequest) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{8} +} + +func (x *MutationRequest) GetBody() *structpb.Struct { + if x != nil { + return x.Body + } + return nil +} + +type QueryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Query *structpb.Struct `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QueryRequest) Reset() { + *x = QueryRequest{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QueryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryRequest) ProtoMessage() {} + +func (x *QueryRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryRequest.ProtoReflect.Descriptor instead. +func (*QueryRequest) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{9} +} + +func (x *QueryRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type GetResourceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetResourceRequest) Reset() { + *x = GetResourceRequest{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetResourceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetResourceRequest) ProtoMessage() {} + +func (x *GetResourceRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetResourceRequest.ProtoReflect.Descriptor instead. +func (*GetResourceRequest) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{10} +} + +func (x *GetResourceRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +type UpdateByIDRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Body *structpb.Struct `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateByIDRequest) Reset() { + *x = UpdateByIDRequest{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateByIDRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateByIDRequest) ProtoMessage() {} + +func (x *UpdateByIDRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateByIDRequest.ProtoReflect.Descriptor instead. +func (*UpdateByIDRequest) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{11} +} + +func (x *UpdateByIDRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *UpdateByIDRequest) GetBody() *structpb.Struct { + if x != nil { + return x.Body + } + return nil +} + +type BatchDeleteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ids []int64 `protobuf:"varint,1,rep,packed,name=ids,proto3" json:"ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BatchDeleteRequest) Reset() { + *x = BatchDeleteRequest{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BatchDeleteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BatchDeleteRequest) ProtoMessage() {} + +func (x *BatchDeleteRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BatchDeleteRequest.ProtoReflect.Descriptor instead. +func (*BatchDeleteRequest) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{12} +} + +func (x *BatchDeleteRequest) GetIds() []int64 { + if x != nil { + return x.Ids + } + return nil +} + +type IDQueryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Query *structpb.Struct `protobuf:"bytes,2,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IDQueryRequest) Reset() { + *x = IDQueryRequest{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IDQueryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IDQueryRequest) ProtoMessage() {} + +func (x *IDQueryRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IDQueryRequest.ProtoReflect.Descriptor instead. +func (*IDQueryRequest) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{13} +} + +func (x *IDQueryRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *IDQueryRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type ResourceItemResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data *structpb.Struct `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResourceItemResponse) Reset() { + *x = ResourceItemResponse{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceItemResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceItemResponse) ProtoMessage() {} + +func (x *ResourceItemResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceItemResponse.ProtoReflect.Descriptor instead. +func (*ResourceItemResponse) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{14} +} + +func (x *ResourceItemResponse) GetData() *structpb.Struct { + if x != nil { + return x.Data + } + return nil +} + +type ResourceListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data *structpb.Struct `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResourceListResponse) Reset() { + *x = ResourceListResponse{} + mi := &file_proto_resource_v1_resource_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceListResponse) ProtoMessage() {} + +func (x *ResourceListResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_resource_v1_resource_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceListResponse.ProtoReflect.Descriptor instead. +func (*ResourceListResponse) Descriptor() ([]byte, []int) { + return file_proto_resource_v1_resource_proto_rawDescGZIP(), []int{15} +} + +func (x *ResourceListResponse) GetData() *structpb.Struct { + if x != nil { + return x.Data + } + return nil +} + +var File_proto_resource_v1_resource_proto protoreflect.FileDescriptor + +const file_proto_resource_v1_resource_proto_rawDesc = "" + + "\n" + + " proto/resource/v1/resource.proto\x12\vresource.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\r\n" + + "\vPingRequest\"~\n" + + "\fPingResponse\x12\x18\n" + + "\aservice\x18\x01 \x01(\tR\aservice\x12\x15\n" + + "\x06app_id\x18\x02 \x01(\tR\x05appId\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\x12%\n" + + "\x0etimestamp_unix\x18\x04 \x01(\x03R\rtimestampUnix\"D\n" + + "\x13ListProjectsRequest\x12-\n" + + "\x05query\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x05query\"F\n" + + "\x15ListContainersRequest\x12-\n" + + "\x05query\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x05query\"D\n" + + "\x13ListDatasetsRequest\x12-\n" + + "\x05query\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x05query\"h\n" + + "\x1eListDatapackEvaluationsRequest\x12-\n" + + "\x05query\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x05query\x12\x17\n" + + "\auser_id\x18\x02 \x01(\x03R\x06userId\"g\n" + + "\x1dListDatasetEvaluationsRequest\x12-\n" + + "\x05query\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x05query\x12\x17\n" + + "\auser_id\x18\x02 \x01(\x03R\x06userId\"G\n" + + "\x16ListEvaluationsRequest\x12-\n" + + "\x05query\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x05query\">\n" + + "\x0fMutationRequest\x12+\n" + + "\x04body\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x04body\"=\n" + + "\fQueryRequest\x12-\n" + + "\x05query\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x05query\"$\n" + + "\x12GetResourceRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\"P\n" + + "\x11UpdateByIDRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\x12+\n" + + "\x04body\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x04body\"&\n" + + "\x12BatchDeleteRequest\x12\x10\n" + + "\x03ids\x18\x01 \x03(\x03R\x03ids\"O\n" + + "\x0eIDQueryRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\x12-\n" + + "\x05query\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x05query\"C\n" + + "\x14ResourceItemResponse\x12+\n" + + "\x04data\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x04data\"C\n" + + "\x14ResourceListResponse\x12+\n" + + "\x04data\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x04data2\xce\x10\n" + + "\x0fResourceService\x12;\n" + + "\x04Ping\x12\x18.resource.v1.PingRequest\x1a\x19.resource.v1.PingResponse\x12S\n" + + "\fListProjects\x12 .resource.v1.ListProjectsRequest\x1a!.resource.v1.ResourceListResponse\x12P\n" + + "\n" + + "GetProject\x12\x1f.resource.v1.GetResourceRequest\x1a!.resource.v1.ResourceItemResponse\x12W\n" + + "\x0eListContainers\x12\".resource.v1.ListContainersRequest\x1a!.resource.v1.ResourceListResponse\x12R\n" + + "\fGetContainer\x12\x1f.resource.v1.GetResourceRequest\x1a!.resource.v1.ResourceItemResponse\x12S\n" + + "\fListDatasets\x12 .resource.v1.ListDatasetsRequest\x1a!.resource.v1.ResourceListResponse\x12P\n" + + "\n" + + "GetDataset\x12\x1f.resource.v1.GetResourceRequest\x1a!.resource.v1.ResourceItemResponse\x12N\n" + + "\vCreateLabel\x12\x1c.resource.v1.MutationRequest\x1a!.resource.v1.ResourceItemResponse\x12N\n" + + "\bGetLabel\x12\x1f.resource.v1.GetResourceRequest\x1a!.resource.v1.ResourceItemResponse\x12J\n" + + "\n" + + "ListLabels\x12\x19.resource.v1.QueryRequest\x1a!.resource.v1.ResourceListResponse\x12P\n" + + "\vUpdateLabel\x12\x1e.resource.v1.UpdateByIDRequest\x1a!.resource.v1.ResourceItemResponse\x12F\n" + + "\vDeleteLabel\x12\x1f.resource.v1.GetResourceRequest\x1a\x16.google.protobuf.Empty\x12L\n" + + "\x11BatchDeleteLabels\x12\x1f.resource.v1.BatchDeleteRequest\x1a\x16.google.protobuf.Empty\x12P\n" + + "\x10ListChaosSystems\x12\x19.resource.v1.QueryRequest\x1a!.resource.v1.ResourceListResponse\x12T\n" + + "\x0eGetChaosSystem\x12\x1f.resource.v1.GetResourceRequest\x1a!.resource.v1.ResourceItemResponse\x12T\n" + + "\x11CreateChaosSystem\x12\x1c.resource.v1.MutationRequest\x1a!.resource.v1.ResourceItemResponse\x12V\n" + + "\x11UpdateChaosSystem\x12\x1e.resource.v1.UpdateByIDRequest\x1a!.resource.v1.ResourceItemResponse\x12L\n" + + "\x11DeleteChaosSystem\x12\x1f.resource.v1.GetResourceRequest\x1a\x16.google.protobuf.Empty\x12S\n" + + "\x19UpsertChaosSystemMetadata\x12\x1e.resource.v1.UpdateByIDRequest\x1a\x16.google.protobuf.Empty\x12Y\n" + + "\x17ListChaosSystemMetadata\x12\x1b.resource.v1.IDQueryRequest\x1a!.resource.v1.ResourceItemResponse\x12o\n" + + "\x1dListDatapackEvaluationResults\x12+.resource.v1.ListDatapackEvaluationsRequest\x1a!.resource.v1.ResourceItemResponse\x12m\n" + + "\x1cListDatasetEvaluationResults\x12*.resource.v1.ListDatasetEvaluationsRequest\x1a!.resource.v1.ResourceItemResponse\x12Y\n" + + "\x0fListEvaluations\x12#.resource.v1.ListEvaluationsRequest\x1a!.resource.v1.ResourceListResponse\x12S\n" + + "\rGetEvaluation\x12\x1f.resource.v1.GetResourceRequest\x1a!.resource.v1.ResourceItemResponse\x12K\n" + + "\x10DeleteEvaluation\x12\x1f.resource.v1.GetResourceRequest\x1a\x16.google.protobuf.EmptyB$Z\"aegis/proto/resource/v1;resourcev1b\x06proto3" + +var ( + file_proto_resource_v1_resource_proto_rawDescOnce sync.Once + file_proto_resource_v1_resource_proto_rawDescData []byte +) + +func file_proto_resource_v1_resource_proto_rawDescGZIP() []byte { + file_proto_resource_v1_resource_proto_rawDescOnce.Do(func() { + file_proto_resource_v1_resource_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_resource_v1_resource_proto_rawDesc), len(file_proto_resource_v1_resource_proto_rawDesc))) + }) + return file_proto_resource_v1_resource_proto_rawDescData +} + +var file_proto_resource_v1_resource_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_proto_resource_v1_resource_proto_goTypes = []any{ + (*PingRequest)(nil), // 0: resource.v1.PingRequest + (*PingResponse)(nil), // 1: resource.v1.PingResponse + (*ListProjectsRequest)(nil), // 2: resource.v1.ListProjectsRequest + (*ListContainersRequest)(nil), // 3: resource.v1.ListContainersRequest + (*ListDatasetsRequest)(nil), // 4: resource.v1.ListDatasetsRequest + (*ListDatapackEvaluationsRequest)(nil), // 5: resource.v1.ListDatapackEvaluationsRequest + (*ListDatasetEvaluationsRequest)(nil), // 6: resource.v1.ListDatasetEvaluationsRequest + (*ListEvaluationsRequest)(nil), // 7: resource.v1.ListEvaluationsRequest + (*MutationRequest)(nil), // 8: resource.v1.MutationRequest + (*QueryRequest)(nil), // 9: resource.v1.QueryRequest + (*GetResourceRequest)(nil), // 10: resource.v1.GetResourceRequest + (*UpdateByIDRequest)(nil), // 11: resource.v1.UpdateByIDRequest + (*BatchDeleteRequest)(nil), // 12: resource.v1.BatchDeleteRequest + (*IDQueryRequest)(nil), // 13: resource.v1.IDQueryRequest + (*ResourceItemResponse)(nil), // 14: resource.v1.ResourceItemResponse + (*ResourceListResponse)(nil), // 15: resource.v1.ResourceListResponse + (*structpb.Struct)(nil), // 16: google.protobuf.Struct + (*emptypb.Empty)(nil), // 17: google.protobuf.Empty +} +var file_proto_resource_v1_resource_proto_depIdxs = []int32{ + 16, // 0: resource.v1.ListProjectsRequest.query:type_name -> google.protobuf.Struct + 16, // 1: resource.v1.ListContainersRequest.query:type_name -> google.protobuf.Struct + 16, // 2: resource.v1.ListDatasetsRequest.query:type_name -> google.protobuf.Struct + 16, // 3: resource.v1.ListDatapackEvaluationsRequest.query:type_name -> google.protobuf.Struct + 16, // 4: resource.v1.ListDatasetEvaluationsRequest.query:type_name -> google.protobuf.Struct + 16, // 5: resource.v1.ListEvaluationsRequest.query:type_name -> google.protobuf.Struct + 16, // 6: resource.v1.MutationRequest.body:type_name -> google.protobuf.Struct + 16, // 7: resource.v1.QueryRequest.query:type_name -> google.protobuf.Struct + 16, // 8: resource.v1.UpdateByIDRequest.body:type_name -> google.protobuf.Struct + 16, // 9: resource.v1.IDQueryRequest.query:type_name -> google.protobuf.Struct + 16, // 10: resource.v1.ResourceItemResponse.data:type_name -> google.protobuf.Struct + 16, // 11: resource.v1.ResourceListResponse.data:type_name -> google.protobuf.Struct + 0, // 12: resource.v1.ResourceService.Ping:input_type -> resource.v1.PingRequest + 2, // 13: resource.v1.ResourceService.ListProjects:input_type -> resource.v1.ListProjectsRequest + 10, // 14: resource.v1.ResourceService.GetProject:input_type -> resource.v1.GetResourceRequest + 3, // 15: resource.v1.ResourceService.ListContainers:input_type -> resource.v1.ListContainersRequest + 10, // 16: resource.v1.ResourceService.GetContainer:input_type -> resource.v1.GetResourceRequest + 4, // 17: resource.v1.ResourceService.ListDatasets:input_type -> resource.v1.ListDatasetsRequest + 10, // 18: resource.v1.ResourceService.GetDataset:input_type -> resource.v1.GetResourceRequest + 8, // 19: resource.v1.ResourceService.CreateLabel:input_type -> resource.v1.MutationRequest + 10, // 20: resource.v1.ResourceService.GetLabel:input_type -> resource.v1.GetResourceRequest + 9, // 21: resource.v1.ResourceService.ListLabels:input_type -> resource.v1.QueryRequest + 11, // 22: resource.v1.ResourceService.UpdateLabel:input_type -> resource.v1.UpdateByIDRequest + 10, // 23: resource.v1.ResourceService.DeleteLabel:input_type -> resource.v1.GetResourceRequest + 12, // 24: resource.v1.ResourceService.BatchDeleteLabels:input_type -> resource.v1.BatchDeleteRequest + 9, // 25: resource.v1.ResourceService.ListChaosSystems:input_type -> resource.v1.QueryRequest + 10, // 26: resource.v1.ResourceService.GetChaosSystem:input_type -> resource.v1.GetResourceRequest + 8, // 27: resource.v1.ResourceService.CreateChaosSystem:input_type -> resource.v1.MutationRequest + 11, // 28: resource.v1.ResourceService.UpdateChaosSystem:input_type -> resource.v1.UpdateByIDRequest + 10, // 29: resource.v1.ResourceService.DeleteChaosSystem:input_type -> resource.v1.GetResourceRequest + 11, // 30: resource.v1.ResourceService.UpsertChaosSystemMetadata:input_type -> resource.v1.UpdateByIDRequest + 13, // 31: resource.v1.ResourceService.ListChaosSystemMetadata:input_type -> resource.v1.IDQueryRequest + 5, // 32: resource.v1.ResourceService.ListDatapackEvaluationResults:input_type -> resource.v1.ListDatapackEvaluationsRequest + 6, // 33: resource.v1.ResourceService.ListDatasetEvaluationResults:input_type -> resource.v1.ListDatasetEvaluationsRequest + 7, // 34: resource.v1.ResourceService.ListEvaluations:input_type -> resource.v1.ListEvaluationsRequest + 10, // 35: resource.v1.ResourceService.GetEvaluation:input_type -> resource.v1.GetResourceRequest + 10, // 36: resource.v1.ResourceService.DeleteEvaluation:input_type -> resource.v1.GetResourceRequest + 1, // 37: resource.v1.ResourceService.Ping:output_type -> resource.v1.PingResponse + 15, // 38: resource.v1.ResourceService.ListProjects:output_type -> resource.v1.ResourceListResponse + 14, // 39: resource.v1.ResourceService.GetProject:output_type -> resource.v1.ResourceItemResponse + 15, // 40: resource.v1.ResourceService.ListContainers:output_type -> resource.v1.ResourceListResponse + 14, // 41: resource.v1.ResourceService.GetContainer:output_type -> resource.v1.ResourceItemResponse + 15, // 42: resource.v1.ResourceService.ListDatasets:output_type -> resource.v1.ResourceListResponse + 14, // 43: resource.v1.ResourceService.GetDataset:output_type -> resource.v1.ResourceItemResponse + 14, // 44: resource.v1.ResourceService.CreateLabel:output_type -> resource.v1.ResourceItemResponse + 14, // 45: resource.v1.ResourceService.GetLabel:output_type -> resource.v1.ResourceItemResponse + 15, // 46: resource.v1.ResourceService.ListLabels:output_type -> resource.v1.ResourceListResponse + 14, // 47: resource.v1.ResourceService.UpdateLabel:output_type -> resource.v1.ResourceItemResponse + 17, // 48: resource.v1.ResourceService.DeleteLabel:output_type -> google.protobuf.Empty + 17, // 49: resource.v1.ResourceService.BatchDeleteLabels:output_type -> google.protobuf.Empty + 15, // 50: resource.v1.ResourceService.ListChaosSystems:output_type -> resource.v1.ResourceListResponse + 14, // 51: resource.v1.ResourceService.GetChaosSystem:output_type -> resource.v1.ResourceItemResponse + 14, // 52: resource.v1.ResourceService.CreateChaosSystem:output_type -> resource.v1.ResourceItemResponse + 14, // 53: resource.v1.ResourceService.UpdateChaosSystem:output_type -> resource.v1.ResourceItemResponse + 17, // 54: resource.v1.ResourceService.DeleteChaosSystem:output_type -> google.protobuf.Empty + 17, // 55: resource.v1.ResourceService.UpsertChaosSystemMetadata:output_type -> google.protobuf.Empty + 14, // 56: resource.v1.ResourceService.ListChaosSystemMetadata:output_type -> resource.v1.ResourceItemResponse + 14, // 57: resource.v1.ResourceService.ListDatapackEvaluationResults:output_type -> resource.v1.ResourceItemResponse + 14, // 58: resource.v1.ResourceService.ListDatasetEvaluationResults:output_type -> resource.v1.ResourceItemResponse + 15, // 59: resource.v1.ResourceService.ListEvaluations:output_type -> resource.v1.ResourceListResponse + 14, // 60: resource.v1.ResourceService.GetEvaluation:output_type -> resource.v1.ResourceItemResponse + 17, // 61: resource.v1.ResourceService.DeleteEvaluation:output_type -> google.protobuf.Empty + 37, // [37:62] is the sub-list for method output_type + 12, // [12:37] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name +} + +func init() { file_proto_resource_v1_resource_proto_init() } +func file_proto_resource_v1_resource_proto_init() { + if File_proto_resource_v1_resource_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_resource_v1_resource_proto_rawDesc), len(file_proto_resource_v1_resource_proto_rawDesc)), + NumEnums: 0, + NumMessages: 16, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_resource_v1_resource_proto_goTypes, + DependencyIndexes: file_proto_resource_v1_resource_proto_depIdxs, + MessageInfos: file_proto_resource_v1_resource_proto_msgTypes, + }.Build() + File_proto_resource_v1_resource_proto = out.File + file_proto_resource_v1_resource_proto_goTypes = nil + file_proto_resource_v1_resource_proto_depIdxs = nil +} diff --git a/src/proto/resource/v1/resource.proto b/src/proto/resource/v1/resource.proto new file mode 100644 index 00000000..c6c2d654 --- /dev/null +++ b/src/proto/resource/v1/resource.proto @@ -0,0 +1,105 @@ +syntax = "proto3"; + +package resource.v1; + +option go_package = "aegis/proto/resource/v1;resourcev1"; + +import "google/protobuf/empty.proto"; +import "google/protobuf/struct.proto"; + +service ResourceService { + rpc Ping(PingRequest) returns (PingResponse); + rpc ListProjects(ListProjectsRequest) returns (ResourceListResponse); + rpc GetProject(GetResourceRequest) returns (ResourceItemResponse); + rpc ListContainers(ListContainersRequest) returns (ResourceListResponse); + rpc GetContainer(GetResourceRequest) returns (ResourceItemResponse); + rpc ListDatasets(ListDatasetsRequest) returns (ResourceListResponse); + rpc GetDataset(GetResourceRequest) returns (ResourceItemResponse); + rpc CreateLabel(MutationRequest) returns (ResourceItemResponse); + rpc GetLabel(GetResourceRequest) returns (ResourceItemResponse); + rpc ListLabels(QueryRequest) returns (ResourceListResponse); + rpc UpdateLabel(UpdateByIDRequest) returns (ResourceItemResponse); + rpc DeleteLabel(GetResourceRequest) returns (google.protobuf.Empty); + rpc BatchDeleteLabels(BatchDeleteRequest) returns (google.protobuf.Empty); + rpc ListChaosSystems(QueryRequest) returns (ResourceListResponse); + rpc GetChaosSystem(GetResourceRequest) returns (ResourceItemResponse); + rpc CreateChaosSystem(MutationRequest) returns (ResourceItemResponse); + rpc UpdateChaosSystem(UpdateByIDRequest) returns (ResourceItemResponse); + rpc DeleteChaosSystem(GetResourceRequest) returns (google.protobuf.Empty); + rpc UpsertChaosSystemMetadata(UpdateByIDRequest) returns (google.protobuf.Empty); + rpc ListChaosSystemMetadata(IDQueryRequest) returns (ResourceItemResponse); + rpc ListDatapackEvaluationResults(ListDatapackEvaluationsRequest) returns (ResourceItemResponse); + rpc ListDatasetEvaluationResults(ListDatasetEvaluationsRequest) returns (ResourceItemResponse); + rpc ListEvaluations(ListEvaluationsRequest) returns (ResourceListResponse); + rpc GetEvaluation(GetResourceRequest) returns (ResourceItemResponse); + rpc DeleteEvaluation(GetResourceRequest) returns (google.protobuf.Empty); +} + +message PingRequest {} + +message PingResponse { + string service = 1; + string app_id = 2; + string status = 3; + int64 timestamp_unix = 4; +} + +message ListProjectsRequest { + google.protobuf.Struct query = 1; +} + +message ListContainersRequest { + google.protobuf.Struct query = 1; +} + +message ListDatasetsRequest { + google.protobuf.Struct query = 1; +} + +message ListDatapackEvaluationsRequest { + google.protobuf.Struct query = 1; + int64 user_id = 2; +} + +message ListDatasetEvaluationsRequest { + google.protobuf.Struct query = 1; + int64 user_id = 2; +} + +message ListEvaluationsRequest { + google.protobuf.Struct query = 1; +} + +message MutationRequest { + google.protobuf.Struct body = 1; +} + +message QueryRequest { + google.protobuf.Struct query = 1; +} + +message GetResourceRequest { + int64 id = 1; +} + +message UpdateByIDRequest { + int64 id = 1; + google.protobuf.Struct body = 2; +} + +message BatchDeleteRequest { + repeated int64 ids = 1; +} + +message IDQueryRequest { + int64 id = 1; + google.protobuf.Struct query = 2; +} + +message ResourceItemResponse { + google.protobuf.Struct data = 1; +} + +message ResourceListResponse { + google.protobuf.Struct data = 1; +} diff --git a/src/proto/resource/v1/resource_grpc.pb.go b/src/proto/resource/v1/resource_grpc.pb.go new file mode 100644 index 00000000..57ca7b83 --- /dev/null +++ b/src/proto/resource/v1/resource_grpc.pb.go @@ -0,0 +1,1034 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v5.29.3 +// source: proto/resource/v1/resource.proto + +package resourcev1 + +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 ( + ResourceService_Ping_FullMethodName = "/resource.v1.ResourceService/Ping" + ResourceService_ListProjects_FullMethodName = "/resource.v1.ResourceService/ListProjects" + ResourceService_GetProject_FullMethodName = "/resource.v1.ResourceService/GetProject" + ResourceService_ListContainers_FullMethodName = "/resource.v1.ResourceService/ListContainers" + ResourceService_GetContainer_FullMethodName = "/resource.v1.ResourceService/GetContainer" + ResourceService_ListDatasets_FullMethodName = "/resource.v1.ResourceService/ListDatasets" + ResourceService_GetDataset_FullMethodName = "/resource.v1.ResourceService/GetDataset" + ResourceService_CreateLabel_FullMethodName = "/resource.v1.ResourceService/CreateLabel" + ResourceService_GetLabel_FullMethodName = "/resource.v1.ResourceService/GetLabel" + ResourceService_ListLabels_FullMethodName = "/resource.v1.ResourceService/ListLabels" + ResourceService_UpdateLabel_FullMethodName = "/resource.v1.ResourceService/UpdateLabel" + ResourceService_DeleteLabel_FullMethodName = "/resource.v1.ResourceService/DeleteLabel" + ResourceService_BatchDeleteLabels_FullMethodName = "/resource.v1.ResourceService/BatchDeleteLabels" + ResourceService_ListChaosSystems_FullMethodName = "/resource.v1.ResourceService/ListChaosSystems" + ResourceService_GetChaosSystem_FullMethodName = "/resource.v1.ResourceService/GetChaosSystem" + ResourceService_CreateChaosSystem_FullMethodName = "/resource.v1.ResourceService/CreateChaosSystem" + ResourceService_UpdateChaosSystem_FullMethodName = "/resource.v1.ResourceService/UpdateChaosSystem" + ResourceService_DeleteChaosSystem_FullMethodName = "/resource.v1.ResourceService/DeleteChaosSystem" + ResourceService_UpsertChaosSystemMetadata_FullMethodName = "/resource.v1.ResourceService/UpsertChaosSystemMetadata" + ResourceService_ListChaosSystemMetadata_FullMethodName = "/resource.v1.ResourceService/ListChaosSystemMetadata" + ResourceService_ListDatapackEvaluationResults_FullMethodName = "/resource.v1.ResourceService/ListDatapackEvaluationResults" + ResourceService_ListDatasetEvaluationResults_FullMethodName = "/resource.v1.ResourceService/ListDatasetEvaluationResults" + ResourceService_ListEvaluations_FullMethodName = "/resource.v1.ResourceService/ListEvaluations" + ResourceService_GetEvaluation_FullMethodName = "/resource.v1.ResourceService/GetEvaluation" + ResourceService_DeleteEvaluation_FullMethodName = "/resource.v1.ResourceService/DeleteEvaluation" +) + +// ResourceServiceClient is the client API for ResourceService 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. +type ResourceServiceClient interface { + Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) + ListProjects(ctx context.Context, in *ListProjectsRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) + GetProject(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) + GetContainer(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + ListDatasets(ctx context.Context, in *ListDatasetsRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) + GetDataset(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + CreateLabel(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + GetLabel(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + ListLabels(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) + UpdateLabel(ctx context.Context, in *UpdateByIDRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + DeleteLabel(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + BatchDeleteLabels(ctx context.Context, in *BatchDeleteRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + ListChaosSystems(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) + GetChaosSystem(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + CreateChaosSystem(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + UpdateChaosSystem(ctx context.Context, in *UpdateByIDRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + DeleteChaosSystem(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + UpsertChaosSystemMetadata(ctx context.Context, in *UpdateByIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + ListChaosSystemMetadata(ctx context.Context, in *IDQueryRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + ListDatapackEvaluationResults(ctx context.Context, in *ListDatapackEvaluationsRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + ListDatasetEvaluationResults(ctx context.Context, in *ListDatasetEvaluationsRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + ListEvaluations(ctx context.Context, in *ListEvaluationsRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) + GetEvaluation(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + DeleteEvaluation(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) +} + +type resourceServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewResourceServiceClient(cc grpc.ClientConnInterface) ResourceServiceClient { + return &resourceServiceClient{cc} +} + +func (c *resourceServiceClient) Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PingResponse) + err := c.cc.Invoke(ctx, ResourceService_Ping_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) ListProjects(ctx context.Context, in *ListProjectsRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceListResponse) + err := c.cc.Invoke(ctx, ResourceService_ListProjects_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) GetProject(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, ResourceService_GetProject_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) ListContainers(ctx context.Context, in *ListContainersRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceListResponse) + err := c.cc.Invoke(ctx, ResourceService_ListContainers_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) GetContainer(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, ResourceService_GetContainer_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) ListDatasets(ctx context.Context, in *ListDatasetsRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceListResponse) + err := c.cc.Invoke(ctx, ResourceService_ListDatasets_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) GetDataset(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, ResourceService_GetDataset_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) CreateLabel(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, ResourceService_CreateLabel_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) GetLabel(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, ResourceService_GetLabel_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) ListLabels(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceListResponse) + err := c.cc.Invoke(ctx, ResourceService_ListLabels_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) UpdateLabel(ctx context.Context, in *UpdateByIDRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, ResourceService_UpdateLabel_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) DeleteLabel(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, ResourceService_DeleteLabel_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) BatchDeleteLabels(ctx context.Context, in *BatchDeleteRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, ResourceService_BatchDeleteLabels_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) ListChaosSystems(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceListResponse) + err := c.cc.Invoke(ctx, ResourceService_ListChaosSystems_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) GetChaosSystem(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, ResourceService_GetChaosSystem_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) CreateChaosSystem(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, ResourceService_CreateChaosSystem_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) UpdateChaosSystem(ctx context.Context, in *UpdateByIDRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, ResourceService_UpdateChaosSystem_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) DeleteChaosSystem(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, ResourceService_DeleteChaosSystem_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) UpsertChaosSystemMetadata(ctx context.Context, in *UpdateByIDRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, ResourceService_UpsertChaosSystemMetadata_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) ListChaosSystemMetadata(ctx context.Context, in *IDQueryRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, ResourceService_ListChaosSystemMetadata_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) ListDatapackEvaluationResults(ctx context.Context, in *ListDatapackEvaluationsRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, ResourceService_ListDatapackEvaluationResults_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) ListDatasetEvaluationResults(ctx context.Context, in *ListDatasetEvaluationsRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, ResourceService_ListDatasetEvaluationResults_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) ListEvaluations(ctx context.Context, in *ListEvaluationsRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceListResponse) + err := c.cc.Invoke(ctx, ResourceService_ListEvaluations_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) GetEvaluation(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, ResourceService_GetEvaluation_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resourceServiceClient) DeleteEvaluation(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, ResourceService_DeleteEvaluation_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ResourceServiceServer is the server API for ResourceService service. +// All implementations must embed UnimplementedResourceServiceServer +// for forward compatibility. +type ResourceServiceServer interface { + Ping(context.Context, *PingRequest) (*PingResponse, error) + ListProjects(context.Context, *ListProjectsRequest) (*ResourceListResponse, error) + GetProject(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) + ListContainers(context.Context, *ListContainersRequest) (*ResourceListResponse, error) + GetContainer(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) + ListDatasets(context.Context, *ListDatasetsRequest) (*ResourceListResponse, error) + GetDataset(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) + CreateLabel(context.Context, *MutationRequest) (*ResourceItemResponse, error) + GetLabel(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) + ListLabels(context.Context, *QueryRequest) (*ResourceListResponse, error) + UpdateLabel(context.Context, *UpdateByIDRequest) (*ResourceItemResponse, error) + DeleteLabel(context.Context, *GetResourceRequest) (*emptypb.Empty, error) + BatchDeleteLabels(context.Context, *BatchDeleteRequest) (*emptypb.Empty, error) + ListChaosSystems(context.Context, *QueryRequest) (*ResourceListResponse, error) + GetChaosSystem(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) + CreateChaosSystem(context.Context, *MutationRequest) (*ResourceItemResponse, error) + UpdateChaosSystem(context.Context, *UpdateByIDRequest) (*ResourceItemResponse, error) + DeleteChaosSystem(context.Context, *GetResourceRequest) (*emptypb.Empty, error) + UpsertChaosSystemMetadata(context.Context, *UpdateByIDRequest) (*emptypb.Empty, error) + ListChaosSystemMetadata(context.Context, *IDQueryRequest) (*ResourceItemResponse, error) + ListDatapackEvaluationResults(context.Context, *ListDatapackEvaluationsRequest) (*ResourceItemResponse, error) + ListDatasetEvaluationResults(context.Context, *ListDatasetEvaluationsRequest) (*ResourceItemResponse, error) + ListEvaluations(context.Context, *ListEvaluationsRequest) (*ResourceListResponse, error) + GetEvaluation(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) + DeleteEvaluation(context.Context, *GetResourceRequest) (*emptypb.Empty, error) + mustEmbedUnimplementedResourceServiceServer() +} + +// UnimplementedResourceServiceServer 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 UnimplementedResourceServiceServer struct{} + +func (UnimplementedResourceServiceServer) Ping(context.Context, *PingRequest) (*PingResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Ping not implemented") +} +func (UnimplementedResourceServiceServer) ListProjects(context.Context, *ListProjectsRequest) (*ResourceListResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListProjects not implemented") +} +func (UnimplementedResourceServiceServer) GetProject(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetProject not implemented") +} +func (UnimplementedResourceServiceServer) ListContainers(context.Context, *ListContainersRequest) (*ResourceListResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListContainers not implemented") +} +func (UnimplementedResourceServiceServer) GetContainer(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetContainer not implemented") +} +func (UnimplementedResourceServiceServer) ListDatasets(context.Context, *ListDatasetsRequest) (*ResourceListResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListDatasets not implemented") +} +func (UnimplementedResourceServiceServer) GetDataset(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetDataset not implemented") +} +func (UnimplementedResourceServiceServer) CreateLabel(context.Context, *MutationRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateLabel not implemented") +} +func (UnimplementedResourceServiceServer) GetLabel(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetLabel not implemented") +} +func (UnimplementedResourceServiceServer) ListLabels(context.Context, *QueryRequest) (*ResourceListResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListLabels not implemented") +} +func (UnimplementedResourceServiceServer) UpdateLabel(context.Context, *UpdateByIDRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateLabel not implemented") +} +func (UnimplementedResourceServiceServer) DeleteLabel(context.Context, *GetResourceRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteLabel not implemented") +} +func (UnimplementedResourceServiceServer) BatchDeleteLabels(context.Context, *BatchDeleteRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method BatchDeleteLabels not implemented") +} +func (UnimplementedResourceServiceServer) ListChaosSystems(context.Context, *QueryRequest) (*ResourceListResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListChaosSystems not implemented") +} +func (UnimplementedResourceServiceServer) GetChaosSystem(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetChaosSystem not implemented") +} +func (UnimplementedResourceServiceServer) CreateChaosSystem(context.Context, *MutationRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateChaosSystem not implemented") +} +func (UnimplementedResourceServiceServer) UpdateChaosSystem(context.Context, *UpdateByIDRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateChaosSystem not implemented") +} +func (UnimplementedResourceServiceServer) DeleteChaosSystem(context.Context, *GetResourceRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteChaosSystem not implemented") +} +func (UnimplementedResourceServiceServer) UpsertChaosSystemMetadata(context.Context, *UpdateByIDRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method UpsertChaosSystemMetadata not implemented") +} +func (UnimplementedResourceServiceServer) ListChaosSystemMetadata(context.Context, *IDQueryRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListChaosSystemMetadata not implemented") +} +func (UnimplementedResourceServiceServer) ListDatapackEvaluationResults(context.Context, *ListDatapackEvaluationsRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListDatapackEvaluationResults not implemented") +} +func (UnimplementedResourceServiceServer) ListDatasetEvaluationResults(context.Context, *ListDatasetEvaluationsRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListDatasetEvaluationResults not implemented") +} +func (UnimplementedResourceServiceServer) ListEvaluations(context.Context, *ListEvaluationsRequest) (*ResourceListResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListEvaluations not implemented") +} +func (UnimplementedResourceServiceServer) GetEvaluation(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetEvaluation not implemented") +} +func (UnimplementedResourceServiceServer) DeleteEvaluation(context.Context, *GetResourceRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteEvaluation not implemented") +} +func (UnimplementedResourceServiceServer) mustEmbedUnimplementedResourceServiceServer() {} +func (UnimplementedResourceServiceServer) testEmbeddedByValue() {} + +// UnsafeResourceServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ResourceServiceServer will +// result in compilation errors. +type UnsafeResourceServiceServer interface { + mustEmbedUnimplementedResourceServiceServer() +} + +func RegisterResourceServiceServer(s grpc.ServiceRegistrar, srv ResourceServiceServer) { + // If the following call panics, it indicates UnimplementedResourceServiceServer 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(&ResourceService_ServiceDesc, srv) +} + +func _ResourceService_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).Ping(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_Ping_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).Ping(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_ListProjects_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListProjectsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).ListProjects(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_ListProjects_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).ListProjects(ctx, req.(*ListProjectsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_GetProject_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).GetProject(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_GetProject_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).GetProject(ctx, req.(*GetResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_ListContainers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListContainersRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).ListContainers(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_ListContainers_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).ListContainers(ctx, req.(*ListContainersRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_GetContainer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).GetContainer(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_GetContainer_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).GetContainer(ctx, req.(*GetResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_ListDatasets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListDatasetsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).ListDatasets(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_ListDatasets_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).ListDatasets(ctx, req.(*ListDatasetsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_GetDataset_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).GetDataset(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_GetDataset_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).GetDataset(ctx, req.(*GetResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_CreateLabel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).CreateLabel(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_CreateLabel_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).CreateLabel(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_GetLabel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).GetLabel(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_GetLabel_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).GetLabel(ctx, req.(*GetResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_ListLabels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).ListLabels(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_ListLabels_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).ListLabels(ctx, req.(*QueryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_UpdateLabel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateByIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).UpdateLabel(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_UpdateLabel_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).UpdateLabel(ctx, req.(*UpdateByIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_DeleteLabel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).DeleteLabel(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_DeleteLabel_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).DeleteLabel(ctx, req.(*GetResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_BatchDeleteLabels_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(BatchDeleteRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).BatchDeleteLabels(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_BatchDeleteLabels_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).BatchDeleteLabels(ctx, req.(*BatchDeleteRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_ListChaosSystems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).ListChaosSystems(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_ListChaosSystems_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).ListChaosSystems(ctx, req.(*QueryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_GetChaosSystem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).GetChaosSystem(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_GetChaosSystem_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).GetChaosSystem(ctx, req.(*GetResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_CreateChaosSystem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).CreateChaosSystem(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_CreateChaosSystem_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).CreateChaosSystem(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_UpdateChaosSystem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateByIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).UpdateChaosSystem(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_UpdateChaosSystem_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).UpdateChaosSystem(ctx, req.(*UpdateByIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_DeleteChaosSystem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).DeleteChaosSystem(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_DeleteChaosSystem_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).DeleteChaosSystem(ctx, req.(*GetResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_UpsertChaosSystemMetadata_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateByIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).UpsertChaosSystemMetadata(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_UpsertChaosSystemMetadata_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).UpsertChaosSystemMetadata(ctx, req.(*UpdateByIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_ListChaosSystemMetadata_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IDQueryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).ListChaosSystemMetadata(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_ListChaosSystemMetadata_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).ListChaosSystemMetadata(ctx, req.(*IDQueryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_ListDatapackEvaluationResults_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListDatapackEvaluationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).ListDatapackEvaluationResults(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_ListDatapackEvaluationResults_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).ListDatapackEvaluationResults(ctx, req.(*ListDatapackEvaluationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_ListDatasetEvaluationResults_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListDatasetEvaluationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).ListDatasetEvaluationResults(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_ListDatasetEvaluationResults_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).ListDatasetEvaluationResults(ctx, req.(*ListDatasetEvaluationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_ListEvaluations_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListEvaluationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).ListEvaluations(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_ListEvaluations_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).ListEvaluations(ctx, req.(*ListEvaluationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_GetEvaluation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).GetEvaluation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_GetEvaluation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).GetEvaluation(ctx, req.(*GetResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResourceService_DeleteEvaluation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResourceServiceServer).DeleteEvaluation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResourceService_DeleteEvaluation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResourceServiceServer).DeleteEvaluation(ctx, req.(*GetResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ResourceService_ServiceDesc is the grpc.ServiceDesc for ResourceService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ResourceService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "resource.v1.ResourceService", + HandlerType: (*ResourceServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Ping", + Handler: _ResourceService_Ping_Handler, + }, + { + MethodName: "ListProjects", + Handler: _ResourceService_ListProjects_Handler, + }, + { + MethodName: "GetProject", + Handler: _ResourceService_GetProject_Handler, + }, + { + MethodName: "ListContainers", + Handler: _ResourceService_ListContainers_Handler, + }, + { + MethodName: "GetContainer", + Handler: _ResourceService_GetContainer_Handler, + }, + { + MethodName: "ListDatasets", + Handler: _ResourceService_ListDatasets_Handler, + }, + { + MethodName: "GetDataset", + Handler: _ResourceService_GetDataset_Handler, + }, + { + MethodName: "CreateLabel", + Handler: _ResourceService_CreateLabel_Handler, + }, + { + MethodName: "GetLabel", + Handler: _ResourceService_GetLabel_Handler, + }, + { + MethodName: "ListLabels", + Handler: _ResourceService_ListLabels_Handler, + }, + { + MethodName: "UpdateLabel", + Handler: _ResourceService_UpdateLabel_Handler, + }, + { + MethodName: "DeleteLabel", + Handler: _ResourceService_DeleteLabel_Handler, + }, + { + MethodName: "BatchDeleteLabels", + Handler: _ResourceService_BatchDeleteLabels_Handler, + }, + { + MethodName: "ListChaosSystems", + Handler: _ResourceService_ListChaosSystems_Handler, + }, + { + MethodName: "GetChaosSystem", + Handler: _ResourceService_GetChaosSystem_Handler, + }, + { + MethodName: "CreateChaosSystem", + Handler: _ResourceService_CreateChaosSystem_Handler, + }, + { + MethodName: "UpdateChaosSystem", + Handler: _ResourceService_UpdateChaosSystem_Handler, + }, + { + MethodName: "DeleteChaosSystem", + Handler: _ResourceService_DeleteChaosSystem_Handler, + }, + { + MethodName: "UpsertChaosSystemMetadata", + Handler: _ResourceService_UpsertChaosSystemMetadata_Handler, + }, + { + MethodName: "ListChaosSystemMetadata", + Handler: _ResourceService_ListChaosSystemMetadata_Handler, + }, + { + MethodName: "ListDatapackEvaluationResults", + Handler: _ResourceService_ListDatapackEvaluationResults_Handler, + }, + { + MethodName: "ListDatasetEvaluationResults", + Handler: _ResourceService_ListDatasetEvaluationResults_Handler, + }, + { + MethodName: "ListEvaluations", + Handler: _ResourceService_ListEvaluations_Handler, + }, + { + MethodName: "GetEvaluation", + Handler: _ResourceService_GetEvaluation_Handler, + }, + { + MethodName: "DeleteEvaluation", + Handler: _ResourceService_DeleteEvaluation_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto/resource/v1/resource.proto", +} diff --git a/src/proto/runtime/v1/runtime.pb.go b/src/proto/runtime/v1/runtime.pb.go new file mode 100644 index 00000000..a8df3539 --- /dev/null +++ b/src/proto/runtime/v1/runtime.pb.go @@ -0,0 +1,821 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: proto/runtime/v1/runtime.proto + +package runtimev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type PingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingRequest) Reset() { + *x = PingRequest{} + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingRequest) ProtoMessage() {} + +func (x *PingRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. +func (*PingRequest) Descriptor() ([]byte, []int) { + return file_proto_runtime_v1_runtime_proto_rawDescGZIP(), []int{0} +} + +type PingResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Service string `protobuf:"bytes,1,opt,name=service,proto3" json:"service,omitempty"` + AppId string `protobuf:"bytes,2,opt,name=app_id,json=appId,proto3" json:"app_id,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + TimestampUnix int64 `protobuf:"varint,4,opt,name=timestamp_unix,json=timestampUnix,proto3" json:"timestamp_unix,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingResponse) Reset() { + *x = PingResponse{} + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingResponse) ProtoMessage() {} + +func (x *PingResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingResponse.ProtoReflect.Descriptor instead. +func (*PingResponse) Descriptor() ([]byte, []int) { + return file_proto_runtime_v1_runtime_proto_rawDescGZIP(), []int{1} +} + +func (x *PingResponse) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *PingResponse) GetAppId() string { + if x != nil { + return x.AppId + } + return "" +} + +func (x *PingResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *PingResponse) GetTimestampUnix() int64 { + if x != nil { + return x.TimestampUnix + } + return 0 +} + +type RuntimeStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RuntimeStatusRequest) Reset() { + *x = RuntimeStatusRequest{} + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RuntimeStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RuntimeStatusRequest) ProtoMessage() {} + +func (x *RuntimeStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RuntimeStatusRequest.ProtoReflect.Descriptor instead. +func (*RuntimeStatusRequest) Descriptor() ([]byte, []int) { + return file_proto_runtime_v1_runtime_proto_rawDescGZIP(), []int{2} +} + +type RuntimeStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Service string `protobuf:"bytes,1,opt,name=service,proto3" json:"service,omitempty"` + Mode string `protobuf:"bytes,2,opt,name=mode,proto3" json:"mode,omitempty"` + AppId string `protobuf:"bytes,3,opt,name=app_id,json=appId,proto3" json:"app_id,omitempty"` + StartedAtUnix int64 `protobuf:"varint,4,opt,name=started_at_unix,json=startedAtUnix,proto3" json:"started_at_unix,omitempty"` + UptimeSeconds int64 `protobuf:"varint,5,opt,name=uptime_seconds,json=uptimeSeconds,proto3" json:"uptime_seconds,omitempty"` + DbAvailable bool `protobuf:"varint,10,opt,name=db_available,json=dbAvailable,proto3" json:"db_available,omitempty"` + DbHealthy bool `protobuf:"varint,11,opt,name=db_healthy,json=dbHealthy,proto3" json:"db_healthy,omitempty"` + DbError string `protobuf:"bytes,12,opt,name=db_error,json=dbError,proto3" json:"db_error,omitempty"` + RedisAvailable bool `protobuf:"varint,20,opt,name=redis_available,json=redisAvailable,proto3" json:"redis_available,omitempty"` + RedisHealthy bool `protobuf:"varint,21,opt,name=redis_healthy,json=redisHealthy,proto3" json:"redis_healthy,omitempty"` + RedisError string `protobuf:"bytes,22,opt,name=redis_error,json=redisError,proto3" json:"redis_error,omitempty"` + K8SAvailable bool `protobuf:"varint,30,opt,name=k8s_available,json=k8sAvailable,proto3" json:"k8s_available,omitempty"` + K8SHealthy bool `protobuf:"varint,31,opt,name=k8s_healthy,json=k8sHealthy,proto3" json:"k8s_healthy,omitempty"` + K8SError string `protobuf:"bytes,32,opt,name=k8s_error,json=k8sError,proto3" json:"k8s_error,omitempty"` + BuildkitAvailable bool `protobuf:"varint,40,opt,name=buildkit_available,json=buildkitAvailable,proto3" json:"buildkit_available,omitempty"` + BuildkitHealthy bool `protobuf:"varint,41,opt,name=buildkit_healthy,json=buildkitHealthy,proto3" json:"buildkit_healthy,omitempty"` + BuildkitError string `protobuf:"bytes,42,opt,name=buildkit_error,json=buildkitError,proto3" json:"buildkit_error,omitempty"` + HelmAvailable bool `protobuf:"varint,50,opt,name=helm_available,json=helmAvailable,proto3" json:"helm_available,omitempty"` + HelmHealthy bool `protobuf:"varint,51,opt,name=helm_healthy,json=helmHealthy,proto3" json:"helm_healthy,omitempty"` + HelmError string `protobuf:"bytes,52,opt,name=helm_error,json=helmError,proto3" json:"helm_error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RuntimeStatusResponse) Reset() { + *x = RuntimeStatusResponse{} + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RuntimeStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RuntimeStatusResponse) ProtoMessage() {} + +func (x *RuntimeStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RuntimeStatusResponse.ProtoReflect.Descriptor instead. +func (*RuntimeStatusResponse) Descriptor() ([]byte, []int) { + return file_proto_runtime_v1_runtime_proto_rawDescGZIP(), []int{3} +} + +func (x *RuntimeStatusResponse) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *RuntimeStatusResponse) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *RuntimeStatusResponse) GetAppId() string { + if x != nil { + return x.AppId + } + return "" +} + +func (x *RuntimeStatusResponse) GetStartedAtUnix() int64 { + if x != nil { + return x.StartedAtUnix + } + return 0 +} + +func (x *RuntimeStatusResponse) GetUptimeSeconds() int64 { + if x != nil { + return x.UptimeSeconds + } + return 0 +} + +func (x *RuntimeStatusResponse) GetDbAvailable() bool { + if x != nil { + return x.DbAvailable + } + return false +} + +func (x *RuntimeStatusResponse) GetDbHealthy() bool { + if x != nil { + return x.DbHealthy + } + return false +} + +func (x *RuntimeStatusResponse) GetDbError() string { + if x != nil { + return x.DbError + } + return "" +} + +func (x *RuntimeStatusResponse) GetRedisAvailable() bool { + if x != nil { + return x.RedisAvailable + } + return false +} + +func (x *RuntimeStatusResponse) GetRedisHealthy() bool { + if x != nil { + return x.RedisHealthy + } + return false +} + +func (x *RuntimeStatusResponse) GetRedisError() string { + if x != nil { + return x.RedisError + } + return "" +} + +func (x *RuntimeStatusResponse) GetK8SAvailable() bool { + if x != nil { + return x.K8SAvailable + } + return false +} + +func (x *RuntimeStatusResponse) GetK8SHealthy() bool { + if x != nil { + return x.K8SHealthy + } + return false +} + +func (x *RuntimeStatusResponse) GetK8SError() string { + if x != nil { + return x.K8SError + } + return "" +} + +func (x *RuntimeStatusResponse) GetBuildkitAvailable() bool { + if x != nil { + return x.BuildkitAvailable + } + return false +} + +func (x *RuntimeStatusResponse) GetBuildkitHealthy() bool { + if x != nil { + return x.BuildkitHealthy + } + return false +} + +func (x *RuntimeStatusResponse) GetBuildkitError() string { + if x != nil { + return x.BuildkitError + } + return "" +} + +func (x *RuntimeStatusResponse) GetHelmAvailable() bool { + if x != nil { + return x.HelmAvailable + } + return false +} + +func (x *RuntimeStatusResponse) GetHelmHealthy() bool { + if x != nil { + return x.HelmHealthy + } + return false +} + +func (x *RuntimeStatusResponse) GetHelmError() string { + if x != nil { + return x.HelmError + } + return "" +} + +type QueueStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QueueStatusRequest) Reset() { + *x = QueueStatusRequest{} + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QueueStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueueStatusRequest) ProtoMessage() {} + +func (x *QueueStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueueStatusRequest.ProtoReflect.Descriptor instead. +func (*QueueStatusRequest) Descriptor() ([]byte, []int) { + return file_proto_runtime_v1_runtime_proto_rawDescGZIP(), []int{4} +} + +type QueueStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ReadyCount int64 `protobuf:"varint,1,opt,name=ready_count,json=readyCount,proto3" json:"ready_count,omitempty"` + DelayedCount int64 `protobuf:"varint,2,opt,name=delayed_count,json=delayedCount,proto3" json:"delayed_count,omitempty"` + DeadCount int64 `protobuf:"varint,3,opt,name=dead_count,json=deadCount,proto3" json:"dead_count,omitempty"` + IndexedCount int64 `protobuf:"varint,4,opt,name=indexed_count,json=indexedCount,proto3" json:"indexed_count,omitempty"` + ConcurrencyCount int64 `protobuf:"varint,5,opt,name=concurrency_count,json=concurrencyCount,proto3" json:"concurrency_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QueueStatusResponse) Reset() { + *x = QueueStatusResponse{} + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QueueStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueueStatusResponse) ProtoMessage() {} + +func (x *QueueStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueueStatusResponse.ProtoReflect.Descriptor instead. +func (*QueueStatusResponse) Descriptor() ([]byte, []int) { + return file_proto_runtime_v1_runtime_proto_rawDescGZIP(), []int{5} +} + +func (x *QueueStatusResponse) GetReadyCount() int64 { + if x != nil { + return x.ReadyCount + } + return 0 +} + +func (x *QueueStatusResponse) GetDelayedCount() int64 { + if x != nil { + return x.DelayedCount + } + return 0 +} + +func (x *QueueStatusResponse) GetDeadCount() int64 { + if x != nil { + return x.DeadCount + } + return 0 +} + +func (x *QueueStatusResponse) GetIndexedCount() int64 { + if x != nil { + return x.IndexedCount + } + return 0 +} + +func (x *QueueStatusResponse) GetConcurrencyCount() int64 { + if x != nil { + return x.ConcurrencyCount + } + return 0 +} + +type LimiterStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LimiterStatusRequest) Reset() { + *x = LimiterStatusRequest{} + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LimiterStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LimiterStatusRequest) ProtoMessage() {} + +func (x *LimiterStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LimiterStatusRequest.ProtoReflect.Descriptor instead. +func (*LimiterStatusRequest) Descriptor() ([]byte, []int) { + return file_proto_runtime_v1_runtime_proto_rawDescGZIP(), []int{6} +} + +type LimiterStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Items []*LimiterStatus `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LimiterStatusResponse) Reset() { + *x = LimiterStatusResponse{} + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LimiterStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LimiterStatusResponse) ProtoMessage() {} + +func (x *LimiterStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LimiterStatusResponse.ProtoReflect.Descriptor instead. +func (*LimiterStatusResponse) Descriptor() ([]byte, []int) { + return file_proto_runtime_v1_runtime_proto_rawDescGZIP(), []int{7} +} + +func (x *LimiterStatusResponse) GetItems() []*LimiterStatus { + if x != nil { + return x.Items + } + return nil +} + +type LimiterStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` + BucketKey string `protobuf:"bytes,2,opt,name=bucket_key,json=bucketKey,proto3" json:"bucket_key,omitempty"` + MaxTokens int64 `protobuf:"varint,3,opt,name=max_tokens,json=maxTokens,proto3" json:"max_tokens,omitempty"` + WaitTimeoutSeconds int64 `protobuf:"varint,4,opt,name=wait_timeout_seconds,json=waitTimeoutSeconds,proto3" json:"wait_timeout_seconds,omitempty"` + InUseTokens int64 `protobuf:"varint,5,opt,name=in_use_tokens,json=inUseTokens,proto3" json:"in_use_tokens,omitempty"` + Error string `protobuf:"bytes,6,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LimiterStatus) Reset() { + *x = LimiterStatus{} + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LimiterStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LimiterStatus) ProtoMessage() {} + +func (x *LimiterStatus) ProtoReflect() protoreflect.Message { + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LimiterStatus.ProtoReflect.Descriptor instead. +func (*LimiterStatus) Descriptor() ([]byte, []int) { + return file_proto_runtime_v1_runtime_proto_rawDescGZIP(), []int{8} +} + +func (x *LimiterStatus) GetServiceName() string { + if x != nil { + return x.ServiceName + } + return "" +} + +func (x *LimiterStatus) GetBucketKey() string { + if x != nil { + return x.BucketKey + } + return "" +} + +func (x *LimiterStatus) GetMaxTokens() int64 { + if x != nil { + return x.MaxTokens + } + return 0 +} + +func (x *LimiterStatus) GetWaitTimeoutSeconds() int64 { + if x != nil { + return x.WaitTimeoutSeconds + } + return 0 +} + +func (x *LimiterStatus) GetInUseTokens() int64 { + if x != nil { + return x.InUseTokens + } + return 0 +} + +func (x *LimiterStatus) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type StructResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data *structpb.Struct `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StructResponse) Reset() { + *x = StructResponse{} + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StructResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StructResponse) ProtoMessage() {} + +func (x *StructResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_runtime_v1_runtime_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StructResponse.ProtoReflect.Descriptor instead. +func (*StructResponse) Descriptor() ([]byte, []int) { + return file_proto_runtime_v1_runtime_proto_rawDescGZIP(), []int{9} +} + +func (x *StructResponse) GetData() *structpb.Struct { + if x != nil { + return x.Data + } + return nil +} + +var File_proto_runtime_v1_runtime_proto protoreflect.FileDescriptor + +const file_proto_runtime_v1_runtime_proto_rawDesc = "" + + "\n" + + "\x1eproto/runtime/v1/runtime.proto\x12\n" + + "runtime.v1\x1a\x1cgoogle/protobuf/struct.proto\"\r\n" + + "\vPingRequest\"~\n" + + "\fPingResponse\x12\x18\n" + + "\aservice\x18\x01 \x01(\tR\aservice\x12\x15\n" + + "\x06app_id\x18\x02 \x01(\tR\x05appId\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\x12%\n" + + "\x0etimestamp_unix\x18\x04 \x01(\x03R\rtimestampUnix\"\x16\n" + + "\x14RuntimeStatusRequest\"\xc4\x05\n" + + "\x15RuntimeStatusResponse\x12\x18\n" + + "\aservice\x18\x01 \x01(\tR\aservice\x12\x12\n" + + "\x04mode\x18\x02 \x01(\tR\x04mode\x12\x15\n" + + "\x06app_id\x18\x03 \x01(\tR\x05appId\x12&\n" + + "\x0fstarted_at_unix\x18\x04 \x01(\x03R\rstartedAtUnix\x12%\n" + + "\x0euptime_seconds\x18\x05 \x01(\x03R\ruptimeSeconds\x12!\n" + + "\fdb_available\x18\n" + + " \x01(\bR\vdbAvailable\x12\x1d\n" + + "\n" + + "db_healthy\x18\v \x01(\bR\tdbHealthy\x12\x19\n" + + "\bdb_error\x18\f \x01(\tR\adbError\x12'\n" + + "\x0fredis_available\x18\x14 \x01(\bR\x0eredisAvailable\x12#\n" + + "\rredis_healthy\x18\x15 \x01(\bR\fredisHealthy\x12\x1f\n" + + "\vredis_error\x18\x16 \x01(\tR\n" + + "redisError\x12#\n" + + "\rk8s_available\x18\x1e \x01(\bR\fk8sAvailable\x12\x1f\n" + + "\vk8s_healthy\x18\x1f \x01(\bR\n" + + "k8sHealthy\x12\x1b\n" + + "\tk8s_error\x18 \x01(\tR\bk8sError\x12-\n" + + "\x12buildkit_available\x18( \x01(\bR\x11buildkitAvailable\x12)\n" + + "\x10buildkit_healthy\x18) \x01(\bR\x0fbuildkitHealthy\x12%\n" + + "\x0ebuildkit_error\x18* \x01(\tR\rbuildkitError\x12%\n" + + "\x0ehelm_available\x182 \x01(\bR\rhelmAvailable\x12!\n" + + "\fhelm_healthy\x183 \x01(\bR\vhelmHealthy\x12\x1d\n" + + "\n" + + "helm_error\x184 \x01(\tR\thelmError\"\x14\n" + + "\x12QueueStatusRequest\"\xcc\x01\n" + + "\x13QueueStatusResponse\x12\x1f\n" + + "\vready_count\x18\x01 \x01(\x03R\n" + + "readyCount\x12#\n" + + "\rdelayed_count\x18\x02 \x01(\x03R\fdelayedCount\x12\x1d\n" + + "\n" + + "dead_count\x18\x03 \x01(\x03R\tdeadCount\x12#\n" + + "\rindexed_count\x18\x04 \x01(\x03R\findexedCount\x12+\n" + + "\x11concurrency_count\x18\x05 \x01(\x03R\x10concurrencyCount\"\x16\n" + + "\x14LimiterStatusRequest\"H\n" + + "\x15LimiterStatusResponse\x12/\n" + + "\x05items\x18\x01 \x03(\v2\x19.runtime.v1.LimiterStatusR\x05items\"\xdc\x01\n" + + "\rLimiterStatus\x12!\n" + + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x1d\n" + + "\n" + + "bucket_key\x18\x02 \x01(\tR\tbucketKey\x12\x1d\n" + + "\n" + + "max_tokens\x18\x03 \x01(\x03R\tmaxTokens\x120\n" + + "\x14wait_timeout_seconds\x18\x04 \x01(\x03R\x12waitTimeoutSeconds\x12\"\n" + + "\rin_use_tokens\x18\x05 \x01(\x03R\vinUseTokens\x12\x14\n" + + "\x05error\x18\x06 \x01(\tR\x05error\"=\n" + + "\x0eStructResponse\x12+\n" + + "\x04data\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x04data2\xe1\x03\n" + + "\x0eRuntimeService\x129\n" + + "\x04Ping\x12\x17.runtime.v1.PingRequest\x1a\x18.runtime.v1.PingResponse\x12W\n" + + "\x10GetRuntimeStatus\x12 .runtime.v1.RuntimeStatusRequest\x1a!.runtime.v1.RuntimeStatusResponse\x12Q\n" + + "\x0eGetQueueStatus\x12\x1e.runtime.v1.QueueStatusRequest\x1a\x1f.runtime.v1.QueueStatusResponse\x12W\n" + + "\x10GetLimiterStatus\x12 .runtime.v1.LimiterStatusRequest\x1a!.runtime.v1.LimiterStatusResponse\x12H\n" + + "\x11GetNamespaceLocks\x12\x17.runtime.v1.PingRequest\x1a\x1a.runtime.v1.StructResponse\x12E\n" + + "\x0eGetQueuedTasks\x12\x17.runtime.v1.PingRequest\x1a\x1a.runtime.v1.StructResponseB\"Z aegis/proto/runtime/v1;runtimev1b\x06proto3" + +var ( + file_proto_runtime_v1_runtime_proto_rawDescOnce sync.Once + file_proto_runtime_v1_runtime_proto_rawDescData []byte +) + +func file_proto_runtime_v1_runtime_proto_rawDescGZIP() []byte { + file_proto_runtime_v1_runtime_proto_rawDescOnce.Do(func() { + file_proto_runtime_v1_runtime_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_runtime_v1_runtime_proto_rawDesc), len(file_proto_runtime_v1_runtime_proto_rawDesc))) + }) + return file_proto_runtime_v1_runtime_proto_rawDescData +} + +var file_proto_runtime_v1_runtime_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_proto_runtime_v1_runtime_proto_goTypes = []any{ + (*PingRequest)(nil), // 0: runtime.v1.PingRequest + (*PingResponse)(nil), // 1: runtime.v1.PingResponse + (*RuntimeStatusRequest)(nil), // 2: runtime.v1.RuntimeStatusRequest + (*RuntimeStatusResponse)(nil), // 3: runtime.v1.RuntimeStatusResponse + (*QueueStatusRequest)(nil), // 4: runtime.v1.QueueStatusRequest + (*QueueStatusResponse)(nil), // 5: runtime.v1.QueueStatusResponse + (*LimiterStatusRequest)(nil), // 6: runtime.v1.LimiterStatusRequest + (*LimiterStatusResponse)(nil), // 7: runtime.v1.LimiterStatusResponse + (*LimiterStatus)(nil), // 8: runtime.v1.LimiterStatus + (*StructResponse)(nil), // 9: runtime.v1.StructResponse + (*structpb.Struct)(nil), // 10: google.protobuf.Struct +} +var file_proto_runtime_v1_runtime_proto_depIdxs = []int32{ + 8, // 0: runtime.v1.LimiterStatusResponse.items:type_name -> runtime.v1.LimiterStatus + 10, // 1: runtime.v1.StructResponse.data:type_name -> google.protobuf.Struct + 0, // 2: runtime.v1.RuntimeService.Ping:input_type -> runtime.v1.PingRequest + 2, // 3: runtime.v1.RuntimeService.GetRuntimeStatus:input_type -> runtime.v1.RuntimeStatusRequest + 4, // 4: runtime.v1.RuntimeService.GetQueueStatus:input_type -> runtime.v1.QueueStatusRequest + 6, // 5: runtime.v1.RuntimeService.GetLimiterStatus:input_type -> runtime.v1.LimiterStatusRequest + 0, // 6: runtime.v1.RuntimeService.GetNamespaceLocks:input_type -> runtime.v1.PingRequest + 0, // 7: runtime.v1.RuntimeService.GetQueuedTasks:input_type -> runtime.v1.PingRequest + 1, // 8: runtime.v1.RuntimeService.Ping:output_type -> runtime.v1.PingResponse + 3, // 9: runtime.v1.RuntimeService.GetRuntimeStatus:output_type -> runtime.v1.RuntimeStatusResponse + 5, // 10: runtime.v1.RuntimeService.GetQueueStatus:output_type -> runtime.v1.QueueStatusResponse + 7, // 11: runtime.v1.RuntimeService.GetLimiterStatus:output_type -> runtime.v1.LimiterStatusResponse + 9, // 12: runtime.v1.RuntimeService.GetNamespaceLocks:output_type -> runtime.v1.StructResponse + 9, // 13: runtime.v1.RuntimeService.GetQueuedTasks:output_type -> runtime.v1.StructResponse + 8, // [8:14] is the sub-list for method output_type + 2, // [2:8] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_proto_runtime_v1_runtime_proto_init() } +func file_proto_runtime_v1_runtime_proto_init() { + if File_proto_runtime_v1_runtime_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_runtime_v1_runtime_proto_rawDesc), len(file_proto_runtime_v1_runtime_proto_rawDesc)), + NumEnums: 0, + NumMessages: 10, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_runtime_v1_runtime_proto_goTypes, + DependencyIndexes: file_proto_runtime_v1_runtime_proto_depIdxs, + MessageInfos: file_proto_runtime_v1_runtime_proto_msgTypes, + }.Build() + File_proto_runtime_v1_runtime_proto = out.File + file_proto_runtime_v1_runtime_proto_goTypes = nil + file_proto_runtime_v1_runtime_proto_depIdxs = nil +} diff --git a/src/proto/runtime/v1/runtime.proto b/src/proto/runtime/v1/runtime.proto new file mode 100644 index 00000000..2df0eb9e --- /dev/null +++ b/src/proto/runtime/v1/runtime.proto @@ -0,0 +1,84 @@ +syntax = "proto3"; + +package runtime.v1; + +option go_package = "aegis/proto/runtime/v1;runtimev1"; + +import "google/protobuf/struct.proto"; + +service RuntimeService { + rpc Ping(PingRequest) returns (PingResponse); + rpc GetRuntimeStatus(RuntimeStatusRequest) returns (RuntimeStatusResponse); + rpc GetQueueStatus(QueueStatusRequest) returns (QueueStatusResponse); + rpc GetLimiterStatus(LimiterStatusRequest) returns (LimiterStatusResponse); + rpc GetNamespaceLocks(PingRequest) returns (StructResponse); + rpc GetQueuedTasks(PingRequest) returns (StructResponse); +} + +message PingRequest {} + +message PingResponse { + string service = 1; + string app_id = 2; + string status = 3; + int64 timestamp_unix = 4; +} + +message RuntimeStatusRequest {} + +message RuntimeStatusResponse { + string service = 1; + string mode = 2; + string app_id = 3; + int64 started_at_unix = 4; + int64 uptime_seconds = 5; + + bool db_available = 10; + bool db_healthy = 11; + string db_error = 12; + + bool redis_available = 20; + bool redis_healthy = 21; + string redis_error = 22; + + bool k8s_available = 30; + bool k8s_healthy = 31; + string k8s_error = 32; + + bool buildkit_available = 40; + bool buildkit_healthy = 41; + string buildkit_error = 42; + + bool helm_available = 50; + bool helm_healthy = 51; + string helm_error = 52; +} + +message QueueStatusRequest {} + +message QueueStatusResponse { + int64 ready_count = 1; + int64 delayed_count = 2; + int64 dead_count = 3; + int64 indexed_count = 4; + int64 concurrency_count = 5; +} + +message LimiterStatusRequest {} + +message LimiterStatusResponse { + repeated LimiterStatus items = 1; +} + +message LimiterStatus { + string service_name = 1; + string bucket_key = 2; + int64 max_tokens = 3; + int64 wait_timeout_seconds = 4; + int64 in_use_tokens = 5; + string error = 6; +} + +message StructResponse { + google.protobuf.Struct data = 1; +} diff --git a/src/proto/runtime/v1/runtime_grpc.pb.go b/src/proto/runtime/v1/runtime_grpc.pb.go new file mode 100644 index 00000000..91960de3 --- /dev/null +++ b/src/proto/runtime/v1/runtime_grpc.pb.go @@ -0,0 +1,311 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v5.29.3 +// source: proto/runtime/v1/runtime.proto + +package runtimev1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// 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 ( + RuntimeService_Ping_FullMethodName = "/runtime.v1.RuntimeService/Ping" + RuntimeService_GetRuntimeStatus_FullMethodName = "/runtime.v1.RuntimeService/GetRuntimeStatus" + RuntimeService_GetQueueStatus_FullMethodName = "/runtime.v1.RuntimeService/GetQueueStatus" + RuntimeService_GetLimiterStatus_FullMethodName = "/runtime.v1.RuntimeService/GetLimiterStatus" + RuntimeService_GetNamespaceLocks_FullMethodName = "/runtime.v1.RuntimeService/GetNamespaceLocks" + RuntimeService_GetQueuedTasks_FullMethodName = "/runtime.v1.RuntimeService/GetQueuedTasks" +) + +// RuntimeServiceClient is the client API for RuntimeService 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. +type RuntimeServiceClient interface { + Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) + GetRuntimeStatus(ctx context.Context, in *RuntimeStatusRequest, opts ...grpc.CallOption) (*RuntimeStatusResponse, error) + GetQueueStatus(ctx context.Context, in *QueueStatusRequest, opts ...grpc.CallOption) (*QueueStatusResponse, error) + GetLimiterStatus(ctx context.Context, in *LimiterStatusRequest, opts ...grpc.CallOption) (*LimiterStatusResponse, error) + GetNamespaceLocks(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*StructResponse, error) + GetQueuedTasks(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*StructResponse, error) +} + +type runtimeServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewRuntimeServiceClient(cc grpc.ClientConnInterface) RuntimeServiceClient { + return &runtimeServiceClient{cc} +} + +func (c *runtimeServiceClient) Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PingResponse) + err := c.cc.Invoke(ctx, RuntimeService_Ping_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *runtimeServiceClient) GetRuntimeStatus(ctx context.Context, in *RuntimeStatusRequest, opts ...grpc.CallOption) (*RuntimeStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RuntimeStatusResponse) + err := c.cc.Invoke(ctx, RuntimeService_GetRuntimeStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *runtimeServiceClient) GetQueueStatus(ctx context.Context, in *QueueStatusRequest, opts ...grpc.CallOption) (*QueueStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(QueueStatusResponse) + err := c.cc.Invoke(ctx, RuntimeService_GetQueueStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *runtimeServiceClient) GetLimiterStatus(ctx context.Context, in *LimiterStatusRequest, opts ...grpc.CallOption) (*LimiterStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(LimiterStatusResponse) + err := c.cc.Invoke(ctx, RuntimeService_GetLimiterStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *runtimeServiceClient) GetNamespaceLocks(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, RuntimeService_GetNamespaceLocks_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *runtimeServiceClient) GetQueuedTasks(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*StructResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StructResponse) + err := c.cc.Invoke(ctx, RuntimeService_GetQueuedTasks_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// RuntimeServiceServer is the server API for RuntimeService service. +// All implementations must embed UnimplementedRuntimeServiceServer +// for forward compatibility. +type RuntimeServiceServer interface { + Ping(context.Context, *PingRequest) (*PingResponse, error) + GetRuntimeStatus(context.Context, *RuntimeStatusRequest) (*RuntimeStatusResponse, error) + GetQueueStatus(context.Context, *QueueStatusRequest) (*QueueStatusResponse, error) + GetLimiterStatus(context.Context, *LimiterStatusRequest) (*LimiterStatusResponse, error) + GetNamespaceLocks(context.Context, *PingRequest) (*StructResponse, error) + GetQueuedTasks(context.Context, *PingRequest) (*StructResponse, error) + mustEmbedUnimplementedRuntimeServiceServer() +} + +// UnimplementedRuntimeServiceServer 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 UnimplementedRuntimeServiceServer struct{} + +func (UnimplementedRuntimeServiceServer) Ping(context.Context, *PingRequest) (*PingResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Ping not implemented") +} +func (UnimplementedRuntimeServiceServer) GetRuntimeStatus(context.Context, *RuntimeStatusRequest) (*RuntimeStatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetRuntimeStatus not implemented") +} +func (UnimplementedRuntimeServiceServer) GetQueueStatus(context.Context, *QueueStatusRequest) (*QueueStatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetQueueStatus not implemented") +} +func (UnimplementedRuntimeServiceServer) GetLimiterStatus(context.Context, *LimiterStatusRequest) (*LimiterStatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetLimiterStatus not implemented") +} +func (UnimplementedRuntimeServiceServer) GetNamespaceLocks(context.Context, *PingRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetNamespaceLocks not implemented") +} +func (UnimplementedRuntimeServiceServer) GetQueuedTasks(context.Context, *PingRequest) (*StructResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetQueuedTasks not implemented") +} +func (UnimplementedRuntimeServiceServer) mustEmbedUnimplementedRuntimeServiceServer() {} +func (UnimplementedRuntimeServiceServer) testEmbeddedByValue() {} + +// UnsafeRuntimeServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to RuntimeServiceServer will +// result in compilation errors. +type UnsafeRuntimeServiceServer interface { + mustEmbedUnimplementedRuntimeServiceServer() +} + +func RegisterRuntimeServiceServer(s grpc.ServiceRegistrar, srv RuntimeServiceServer) { + // If the following call panics, it indicates UnimplementedRuntimeServiceServer 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(&RuntimeService_ServiceDesc, srv) +} + +func _RuntimeService_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RuntimeServiceServer).Ping(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RuntimeService_Ping_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RuntimeServiceServer).Ping(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RuntimeService_GetRuntimeStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RuntimeStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RuntimeServiceServer).GetRuntimeStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RuntimeService_GetRuntimeStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RuntimeServiceServer).GetRuntimeStatus(ctx, req.(*RuntimeStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RuntimeService_GetQueueStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueueStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RuntimeServiceServer).GetQueueStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RuntimeService_GetQueueStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RuntimeServiceServer).GetQueueStatus(ctx, req.(*QueueStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RuntimeService_GetLimiterStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LimiterStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RuntimeServiceServer).GetLimiterStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RuntimeService_GetLimiterStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RuntimeServiceServer).GetLimiterStatus(ctx, req.(*LimiterStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RuntimeService_GetNamespaceLocks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RuntimeServiceServer).GetNamespaceLocks(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RuntimeService_GetNamespaceLocks_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RuntimeServiceServer).GetNamespaceLocks(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _RuntimeService_GetQueuedTasks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RuntimeServiceServer).GetQueuedTasks(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RuntimeService_GetQueuedTasks_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RuntimeServiceServer).GetQueuedTasks(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// RuntimeService_ServiceDesc is the grpc.ServiceDesc for RuntimeService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var RuntimeService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "runtime.v1.RuntimeService", + HandlerType: (*RuntimeServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Ping", + Handler: _RuntimeService_Ping_Handler, + }, + { + MethodName: "GetRuntimeStatus", + Handler: _RuntimeService_GetRuntimeStatus_Handler, + }, + { + MethodName: "GetQueueStatus", + Handler: _RuntimeService_GetQueueStatus_Handler, + }, + { + MethodName: "GetLimiterStatus", + Handler: _RuntimeService_GetLimiterStatus_Handler, + }, + { + MethodName: "GetNamespaceLocks", + Handler: _RuntimeService_GetNamespaceLocks_Handler, + }, + { + MethodName: "GetQueuedTasks", + Handler: _RuntimeService_GetQueuedTasks_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto/runtime/v1/runtime.proto", +} diff --git a/src/proto/system/v1/system.pb.go b/src/proto/system/v1/system.pb.go new file mode 100644 index 00000000..e72870f3 --- /dev/null +++ b/src/proto/system/v1/system.pb.go @@ -0,0 +1,466 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: proto/system/v1/system.proto + +package systemv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type PingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingRequest) Reset() { + *x = PingRequest{} + mi := &file_proto_system_v1_system_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingRequest) ProtoMessage() {} + +func (x *PingRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_system_v1_system_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. +func (*PingRequest) Descriptor() ([]byte, []int) { + return file_proto_system_v1_system_proto_rawDescGZIP(), []int{0} +} + +type PingResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Service string `protobuf:"bytes,1,opt,name=service,proto3" json:"service,omitempty"` + AppId string `protobuf:"bytes,2,opt,name=app_id,json=appId,proto3" json:"app_id,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + TimestampUnix int64 `protobuf:"varint,4,opt,name=timestamp_unix,json=timestampUnix,proto3" json:"timestamp_unix,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingResponse) Reset() { + *x = PingResponse{} + mi := &file_proto_system_v1_system_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingResponse) ProtoMessage() {} + +func (x *PingResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_system_v1_system_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingResponse.ProtoReflect.Descriptor instead. +func (*PingResponse) Descriptor() ([]byte, []int) { + return file_proto_system_v1_system_proto_rawDescGZIP(), []int{1} +} + +func (x *PingResponse) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *PingResponse) GetAppId() string { + if x != nil { + return x.AppId + } + return "" +} + +func (x *PingResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *PingResponse) GetTimestampUnix() int64 { + if x != nil { + return x.TimestampUnix + } + return 0 +} + +type ListConfigsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Query *structpb.Struct `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListConfigsRequest) Reset() { + *x = ListConfigsRequest{} + mi := &file_proto_system_v1_system_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListConfigsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListConfigsRequest) ProtoMessage() {} + +func (x *ListConfigsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_system_v1_system_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListConfigsRequest.ProtoReflect.Descriptor instead. +func (*ListConfigsRequest) Descriptor() ([]byte, []int) { + return file_proto_system_v1_system_proto_rawDescGZIP(), []int{2} +} + +func (x *ListConfigsRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type ListAuditLogsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Query *structpb.Struct `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAuditLogsRequest) Reset() { + *x = ListAuditLogsRequest{} + mi := &file_proto_system_v1_system_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAuditLogsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAuditLogsRequest) ProtoMessage() {} + +func (x *ListAuditLogsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_system_v1_system_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAuditLogsRequest.ProtoReflect.Descriptor instead. +func (*ListAuditLogsRequest) Descriptor() ([]byte, []int) { + return file_proto_system_v1_system_proto_rawDescGZIP(), []int{3} +} + +func (x *ListAuditLogsRequest) GetQuery() *structpb.Struct { + if x != nil { + return x.Query + } + return nil +} + +type GetResourceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetResourceRequest) Reset() { + *x = GetResourceRequest{} + mi := &file_proto_system_v1_system_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetResourceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetResourceRequest) ProtoMessage() {} + +func (x *GetResourceRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_system_v1_system_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetResourceRequest.ProtoReflect.Descriptor instead. +func (*GetResourceRequest) Descriptor() ([]byte, []int) { + return file_proto_system_v1_system_proto_rawDescGZIP(), []int{4} +} + +func (x *GetResourceRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +type ResourceItemResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data *structpb.Struct `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResourceItemResponse) Reset() { + *x = ResourceItemResponse{} + mi := &file_proto_system_v1_system_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceItemResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceItemResponse) ProtoMessage() {} + +func (x *ResourceItemResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_system_v1_system_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceItemResponse.ProtoReflect.Descriptor instead. +func (*ResourceItemResponse) Descriptor() ([]byte, []int) { + return file_proto_system_v1_system_proto_rawDescGZIP(), []int{5} +} + +func (x *ResourceItemResponse) GetData() *structpb.Struct { + if x != nil { + return x.Data + } + return nil +} + +type ResourceListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data *structpb.Struct `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResourceListResponse) Reset() { + *x = ResourceListResponse{} + mi := &file_proto_system_v1_system_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceListResponse) ProtoMessage() {} + +func (x *ResourceListResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_system_v1_system_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceListResponse.ProtoReflect.Descriptor instead. +func (*ResourceListResponse) Descriptor() ([]byte, []int) { + return file_proto_system_v1_system_proto_rawDescGZIP(), []int{6} +} + +func (x *ResourceListResponse) GetData() *structpb.Struct { + if x != nil { + return x.Data + } + return nil +} + +var File_proto_system_v1_system_proto protoreflect.FileDescriptor + +const file_proto_system_v1_system_proto_rawDesc = "" + + "\n" + + "\x1cproto/system/v1/system.proto\x12\tsystem.v1\x1a\x1cgoogle/protobuf/struct.proto\"\r\n" + + "\vPingRequest\"~\n" + + "\fPingResponse\x12\x18\n" + + "\aservice\x18\x01 \x01(\tR\aservice\x12\x15\n" + + "\x06app_id\x18\x02 \x01(\tR\x05appId\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\x12%\n" + + "\x0etimestamp_unix\x18\x04 \x01(\x03R\rtimestampUnix\"C\n" + + "\x12ListConfigsRequest\x12-\n" + + "\x05query\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x05query\"E\n" + + "\x14ListAuditLogsRequest\x12-\n" + + "\x05query\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x05query\"$\n" + + "\x12GetResourceRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\"C\n" + + "\x14ResourceItemResponse\x12+\n" + + "\x04data\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x04data\"C\n" + + "\x14ResourceListResponse\x12+\n" + + "\x04data\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x04data2\x99\a\n" + + "\rSystemService\x127\n" + + "\x04Ping\x12\x16.system.v1.PingRequest\x1a\x17.system.v1.PingResponse\x12D\n" + + "\tGetHealth\x12\x16.system.v1.PingRequest\x1a\x1f.system.v1.ResourceItemResponse\x12E\n" + + "\n" + + "GetMetrics\x12\x16.system.v1.PingRequest\x1a\x1f.system.v1.ResourceItemResponse\x12H\n" + + "\rGetSystemInfo\x12\x16.system.v1.PingRequest\x1a\x1f.system.v1.ResourceItemResponse\x12M\n" + + "\vListConfigs\x12\x1d.system.v1.ListConfigsRequest\x1a\x1f.system.v1.ResourceListResponse\x12K\n" + + "\tGetConfig\x12\x1d.system.v1.GetResourceRequest\x1a\x1f.system.v1.ResourceItemResponse\x12Q\n" + + "\rListAuditLogs\x12\x1f.system.v1.ListAuditLogsRequest\x1a\x1f.system.v1.ResourceListResponse\x12M\n" + + "\vGetAuditLog\x12\x1d.system.v1.GetResourceRequest\x1a\x1f.system.v1.ResourceItemResponse\x12M\n" + + "\x12ListNamespaceLocks\x12\x16.system.v1.PingRequest\x1a\x1f.system.v1.ResourceItemResponse\x12J\n" + + "\x0fListQueuedTasks\x12\x16.system.v1.PingRequest\x1a\x1f.system.v1.ResourceItemResponse\x12K\n" + + "\x10GetSystemMetrics\x12\x16.system.v1.PingRequest\x1a\x1f.system.v1.ResourceItemResponse\x12R\n" + + "\x17GetSystemMetricsHistory\x12\x16.system.v1.PingRequest\x1a\x1f.system.v1.ResourceItemResponseB Z\x1eaegis/proto/system/v1;systemv1b\x06proto3" + +var ( + file_proto_system_v1_system_proto_rawDescOnce sync.Once + file_proto_system_v1_system_proto_rawDescData []byte +) + +func file_proto_system_v1_system_proto_rawDescGZIP() []byte { + file_proto_system_v1_system_proto_rawDescOnce.Do(func() { + file_proto_system_v1_system_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_system_v1_system_proto_rawDesc), len(file_proto_system_v1_system_proto_rawDesc))) + }) + return file_proto_system_v1_system_proto_rawDescData +} + +var file_proto_system_v1_system_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_proto_system_v1_system_proto_goTypes = []any{ + (*PingRequest)(nil), // 0: system.v1.PingRequest + (*PingResponse)(nil), // 1: system.v1.PingResponse + (*ListConfigsRequest)(nil), // 2: system.v1.ListConfigsRequest + (*ListAuditLogsRequest)(nil), // 3: system.v1.ListAuditLogsRequest + (*GetResourceRequest)(nil), // 4: system.v1.GetResourceRequest + (*ResourceItemResponse)(nil), // 5: system.v1.ResourceItemResponse + (*ResourceListResponse)(nil), // 6: system.v1.ResourceListResponse + (*structpb.Struct)(nil), // 7: google.protobuf.Struct +} +var file_proto_system_v1_system_proto_depIdxs = []int32{ + 7, // 0: system.v1.ListConfigsRequest.query:type_name -> google.protobuf.Struct + 7, // 1: system.v1.ListAuditLogsRequest.query:type_name -> google.protobuf.Struct + 7, // 2: system.v1.ResourceItemResponse.data:type_name -> google.protobuf.Struct + 7, // 3: system.v1.ResourceListResponse.data:type_name -> google.protobuf.Struct + 0, // 4: system.v1.SystemService.Ping:input_type -> system.v1.PingRequest + 0, // 5: system.v1.SystemService.GetHealth:input_type -> system.v1.PingRequest + 0, // 6: system.v1.SystemService.GetMetrics:input_type -> system.v1.PingRequest + 0, // 7: system.v1.SystemService.GetSystemInfo:input_type -> system.v1.PingRequest + 2, // 8: system.v1.SystemService.ListConfigs:input_type -> system.v1.ListConfigsRequest + 4, // 9: system.v1.SystemService.GetConfig:input_type -> system.v1.GetResourceRequest + 3, // 10: system.v1.SystemService.ListAuditLogs:input_type -> system.v1.ListAuditLogsRequest + 4, // 11: system.v1.SystemService.GetAuditLog:input_type -> system.v1.GetResourceRequest + 0, // 12: system.v1.SystemService.ListNamespaceLocks:input_type -> system.v1.PingRequest + 0, // 13: system.v1.SystemService.ListQueuedTasks:input_type -> system.v1.PingRequest + 0, // 14: system.v1.SystemService.GetSystemMetrics:input_type -> system.v1.PingRequest + 0, // 15: system.v1.SystemService.GetSystemMetricsHistory:input_type -> system.v1.PingRequest + 1, // 16: system.v1.SystemService.Ping:output_type -> system.v1.PingResponse + 5, // 17: system.v1.SystemService.GetHealth:output_type -> system.v1.ResourceItemResponse + 5, // 18: system.v1.SystemService.GetMetrics:output_type -> system.v1.ResourceItemResponse + 5, // 19: system.v1.SystemService.GetSystemInfo:output_type -> system.v1.ResourceItemResponse + 6, // 20: system.v1.SystemService.ListConfigs:output_type -> system.v1.ResourceListResponse + 5, // 21: system.v1.SystemService.GetConfig:output_type -> system.v1.ResourceItemResponse + 6, // 22: system.v1.SystemService.ListAuditLogs:output_type -> system.v1.ResourceListResponse + 5, // 23: system.v1.SystemService.GetAuditLog:output_type -> system.v1.ResourceItemResponse + 5, // 24: system.v1.SystemService.ListNamespaceLocks:output_type -> system.v1.ResourceItemResponse + 5, // 25: system.v1.SystemService.ListQueuedTasks:output_type -> system.v1.ResourceItemResponse + 5, // 26: system.v1.SystemService.GetSystemMetrics:output_type -> system.v1.ResourceItemResponse + 5, // 27: system.v1.SystemService.GetSystemMetricsHistory:output_type -> system.v1.ResourceItemResponse + 16, // [16:28] is the sub-list for method output_type + 4, // [4:16] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_proto_system_v1_system_proto_init() } +func file_proto_system_v1_system_proto_init() { + if File_proto_system_v1_system_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_system_v1_system_proto_rawDesc), len(file_proto_system_v1_system_proto_rawDesc)), + NumEnums: 0, + NumMessages: 7, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_system_v1_system_proto_goTypes, + DependencyIndexes: file_proto_system_v1_system_proto_depIdxs, + MessageInfos: file_proto_system_v1_system_proto_msgTypes, + }.Build() + File_proto_system_v1_system_proto = out.File + file_proto_system_v1_system_proto_goTypes = nil + file_proto_system_v1_system_proto_depIdxs = nil +} diff --git a/src/proto/system/v1/system.proto b/src/proto/system/v1/system.proto new file mode 100644 index 00000000..2918a6cb --- /dev/null +++ b/src/proto/system/v1/system.proto @@ -0,0 +1,51 @@ +syntax = "proto3"; + +package system.v1; + +option go_package = "aegis/proto/system/v1;systemv1"; + +import "google/protobuf/struct.proto"; + +service SystemService { + rpc Ping(PingRequest) returns (PingResponse); + rpc GetHealth(PingRequest) returns (ResourceItemResponse); + rpc GetMetrics(PingRequest) returns (ResourceItemResponse); + rpc GetSystemInfo(PingRequest) returns (ResourceItemResponse); + rpc ListConfigs(ListConfigsRequest) returns (ResourceListResponse); + rpc GetConfig(GetResourceRequest) returns (ResourceItemResponse); + rpc ListAuditLogs(ListAuditLogsRequest) returns (ResourceListResponse); + rpc GetAuditLog(GetResourceRequest) returns (ResourceItemResponse); + rpc ListNamespaceLocks(PingRequest) returns (ResourceItemResponse); + rpc ListQueuedTasks(PingRequest) returns (ResourceItemResponse); + rpc GetSystemMetrics(PingRequest) returns (ResourceItemResponse); + rpc GetSystemMetricsHistory(PingRequest) returns (ResourceItemResponse); +} + +message PingRequest {} + +message PingResponse { + string service = 1; + string app_id = 2; + string status = 3; + int64 timestamp_unix = 4; +} + +message ListConfigsRequest { + google.protobuf.Struct query = 1; +} + +message ListAuditLogsRequest { + google.protobuf.Struct query = 1; +} + +message GetResourceRequest { + int64 id = 1; +} + +message ResourceItemResponse { + google.protobuf.Struct data = 1; +} + +message ResourceListResponse { + google.protobuf.Struct data = 1; +} diff --git a/src/proto/system/v1/system_grpc.pb.go b/src/proto/system/v1/system_grpc.pb.go new file mode 100644 index 00000000..f132bc37 --- /dev/null +++ b/src/proto/system/v1/system_grpc.pb.go @@ -0,0 +1,539 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v5.29.3 +// source: proto/system/v1/system.proto + +package systemv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// 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 ( + SystemService_Ping_FullMethodName = "/system.v1.SystemService/Ping" + SystemService_GetHealth_FullMethodName = "/system.v1.SystemService/GetHealth" + SystemService_GetMetrics_FullMethodName = "/system.v1.SystemService/GetMetrics" + SystemService_GetSystemInfo_FullMethodName = "/system.v1.SystemService/GetSystemInfo" + SystemService_ListConfigs_FullMethodName = "/system.v1.SystemService/ListConfigs" + SystemService_GetConfig_FullMethodName = "/system.v1.SystemService/GetConfig" + SystemService_ListAuditLogs_FullMethodName = "/system.v1.SystemService/ListAuditLogs" + SystemService_GetAuditLog_FullMethodName = "/system.v1.SystemService/GetAuditLog" + SystemService_ListNamespaceLocks_FullMethodName = "/system.v1.SystemService/ListNamespaceLocks" + SystemService_ListQueuedTasks_FullMethodName = "/system.v1.SystemService/ListQueuedTasks" + SystemService_GetSystemMetrics_FullMethodName = "/system.v1.SystemService/GetSystemMetrics" + SystemService_GetSystemMetricsHistory_FullMethodName = "/system.v1.SystemService/GetSystemMetricsHistory" +) + +// SystemServiceClient is the client API for SystemService 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. +type SystemServiceClient interface { + Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) + GetHealth(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + GetMetrics(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + GetSystemInfo(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + ListConfigs(ctx context.Context, in *ListConfigsRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) + GetConfig(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + ListAuditLogs(ctx context.Context, in *ListAuditLogsRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) + GetAuditLog(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + ListNamespaceLocks(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + ListQueuedTasks(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + GetSystemMetrics(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) + GetSystemMetricsHistory(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) +} + +type systemServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewSystemServiceClient(cc grpc.ClientConnInterface) SystemServiceClient { + return &systemServiceClient{cc} +} + +func (c *systemServiceClient) Ping(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*PingResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PingResponse) + err := c.cc.Invoke(ctx, SystemService_Ping_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *systemServiceClient) GetHealth(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, SystemService_GetHealth_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *systemServiceClient) GetMetrics(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, SystemService_GetMetrics_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *systemServiceClient) GetSystemInfo(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, SystemService_GetSystemInfo_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *systemServiceClient) ListConfigs(ctx context.Context, in *ListConfigsRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceListResponse) + err := c.cc.Invoke(ctx, SystemService_ListConfigs_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *systemServiceClient) GetConfig(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, SystemService_GetConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *systemServiceClient) ListAuditLogs(ctx context.Context, in *ListAuditLogsRequest, opts ...grpc.CallOption) (*ResourceListResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceListResponse) + err := c.cc.Invoke(ctx, SystemService_ListAuditLogs_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *systemServiceClient) GetAuditLog(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, SystemService_GetAuditLog_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *systemServiceClient) ListNamespaceLocks(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, SystemService_ListNamespaceLocks_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *systemServiceClient) ListQueuedTasks(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, SystemService_ListQueuedTasks_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *systemServiceClient) GetSystemMetrics(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, SystemService_GetSystemMetrics_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *systemServiceClient) GetSystemMetricsHistory(ctx context.Context, in *PingRequest, opts ...grpc.CallOption) (*ResourceItemResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ResourceItemResponse) + err := c.cc.Invoke(ctx, SystemService_GetSystemMetricsHistory_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// SystemServiceServer is the server API for SystemService service. +// All implementations must embed UnimplementedSystemServiceServer +// for forward compatibility. +type SystemServiceServer interface { + Ping(context.Context, *PingRequest) (*PingResponse, error) + GetHealth(context.Context, *PingRequest) (*ResourceItemResponse, error) + GetMetrics(context.Context, *PingRequest) (*ResourceItemResponse, error) + GetSystemInfo(context.Context, *PingRequest) (*ResourceItemResponse, error) + ListConfigs(context.Context, *ListConfigsRequest) (*ResourceListResponse, error) + GetConfig(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) + ListAuditLogs(context.Context, *ListAuditLogsRequest) (*ResourceListResponse, error) + GetAuditLog(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) + ListNamespaceLocks(context.Context, *PingRequest) (*ResourceItemResponse, error) + ListQueuedTasks(context.Context, *PingRequest) (*ResourceItemResponse, error) + GetSystemMetrics(context.Context, *PingRequest) (*ResourceItemResponse, error) + GetSystemMetricsHistory(context.Context, *PingRequest) (*ResourceItemResponse, error) + mustEmbedUnimplementedSystemServiceServer() +} + +// UnimplementedSystemServiceServer 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 UnimplementedSystemServiceServer struct{} + +func (UnimplementedSystemServiceServer) Ping(context.Context, *PingRequest) (*PingResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Ping not implemented") +} +func (UnimplementedSystemServiceServer) GetHealth(context.Context, *PingRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetHealth not implemented") +} +func (UnimplementedSystemServiceServer) GetMetrics(context.Context, *PingRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetMetrics not implemented") +} +func (UnimplementedSystemServiceServer) GetSystemInfo(context.Context, *PingRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSystemInfo not implemented") +} +func (UnimplementedSystemServiceServer) ListConfigs(context.Context, *ListConfigsRequest) (*ResourceListResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListConfigs not implemented") +} +func (UnimplementedSystemServiceServer) GetConfig(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetConfig not implemented") +} +func (UnimplementedSystemServiceServer) ListAuditLogs(context.Context, *ListAuditLogsRequest) (*ResourceListResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListAuditLogs not implemented") +} +func (UnimplementedSystemServiceServer) GetAuditLog(context.Context, *GetResourceRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetAuditLog not implemented") +} +func (UnimplementedSystemServiceServer) ListNamespaceLocks(context.Context, *PingRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListNamespaceLocks not implemented") +} +func (UnimplementedSystemServiceServer) ListQueuedTasks(context.Context, *PingRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListQueuedTasks not implemented") +} +func (UnimplementedSystemServiceServer) GetSystemMetrics(context.Context, *PingRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSystemMetrics not implemented") +} +func (UnimplementedSystemServiceServer) GetSystemMetricsHistory(context.Context, *PingRequest) (*ResourceItemResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSystemMetricsHistory not implemented") +} +func (UnimplementedSystemServiceServer) mustEmbedUnimplementedSystemServiceServer() {} +func (UnimplementedSystemServiceServer) testEmbeddedByValue() {} + +// UnsafeSystemServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to SystemServiceServer will +// result in compilation errors. +type UnsafeSystemServiceServer interface { + mustEmbedUnimplementedSystemServiceServer() +} + +func RegisterSystemServiceServer(s grpc.ServiceRegistrar, srv SystemServiceServer) { + // If the following call panics, it indicates UnimplementedSystemServiceServer 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(&SystemService_ServiceDesc, srv) +} + +func _SystemService_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SystemServiceServer).Ping(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SystemService_Ping_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SystemServiceServer).Ping(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SystemService_GetHealth_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SystemServiceServer).GetHealth(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SystemService_GetHealth_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SystemServiceServer).GetHealth(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SystemService_GetMetrics_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SystemServiceServer).GetMetrics(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SystemService_GetMetrics_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SystemServiceServer).GetMetrics(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SystemService_GetSystemInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SystemServiceServer).GetSystemInfo(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SystemService_GetSystemInfo_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SystemServiceServer).GetSystemInfo(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SystemService_ListConfigs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListConfigsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SystemServiceServer).ListConfigs(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SystemService_ListConfigs_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SystemServiceServer).ListConfigs(ctx, req.(*ListConfigsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SystemService_GetConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SystemServiceServer).GetConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SystemService_GetConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SystemServiceServer).GetConfig(ctx, req.(*GetResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SystemService_ListAuditLogs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListAuditLogsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SystemServiceServer).ListAuditLogs(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SystemService_ListAuditLogs_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SystemServiceServer).ListAuditLogs(ctx, req.(*ListAuditLogsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SystemService_GetAuditLog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetResourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SystemServiceServer).GetAuditLog(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SystemService_GetAuditLog_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SystemServiceServer).GetAuditLog(ctx, req.(*GetResourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SystemService_ListNamespaceLocks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SystemServiceServer).ListNamespaceLocks(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SystemService_ListNamespaceLocks_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SystemServiceServer).ListNamespaceLocks(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SystemService_ListQueuedTasks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SystemServiceServer).ListQueuedTasks(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SystemService_ListQueuedTasks_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SystemServiceServer).ListQueuedTasks(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SystemService_GetSystemMetrics_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SystemServiceServer).GetSystemMetrics(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SystemService_GetSystemMetrics_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SystemServiceServer).GetSystemMetrics(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _SystemService_GetSystemMetricsHistory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PingRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SystemServiceServer).GetSystemMetricsHistory(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SystemService_GetSystemMetricsHistory_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SystemServiceServer).GetSystemMetricsHistory(ctx, req.(*PingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// SystemService_ServiceDesc is the grpc.ServiceDesc for SystemService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var SystemService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "system.v1.SystemService", + HandlerType: (*SystemServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Ping", + Handler: _SystemService_Ping_Handler, + }, + { + MethodName: "GetHealth", + Handler: _SystemService_GetHealth_Handler, + }, + { + MethodName: "GetMetrics", + Handler: _SystemService_GetMetrics_Handler, + }, + { + MethodName: "GetSystemInfo", + Handler: _SystemService_GetSystemInfo_Handler, + }, + { + MethodName: "ListConfigs", + Handler: _SystemService_ListConfigs_Handler, + }, + { + MethodName: "GetConfig", + Handler: _SystemService_GetConfig_Handler, + }, + { + MethodName: "ListAuditLogs", + Handler: _SystemService_ListAuditLogs_Handler, + }, + { + MethodName: "GetAuditLog", + Handler: _SystemService_GetAuditLog_Handler, + }, + { + MethodName: "ListNamespaceLocks", + Handler: _SystemService_ListNamespaceLocks_Handler, + }, + { + MethodName: "ListQueuedTasks", + Handler: _SystemService_ListQueuedTasks_Handler, + }, + { + MethodName: "GetSystemMetrics", + Handler: _SystemService_GetSystemMetrics_Handler, + }, + { + MethodName: "GetSystemMetricsHistory", + Handler: _SystemService_GetSystemMetricsHistory_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto/system/v1/system.proto", +} diff --git a/src/repository/audit.go b/src/repository/audit.go deleted file mode 100644 index c703c0f7..00000000 --- a/src/repository/audit.go +++ /dev/null @@ -1,135 +0,0 @@ -package repository - -import ( - "fmt" - "time" - - "aegis/database" - "aegis/dto" - - "gorm.io/gorm" -) - -// CreateAuditLog creates a new audit log entry -func CreateAuditLog(db *gorm.DB, log *database.AuditLog) error { - if err := db.Create(log).Error; err != nil { - return fmt.Errorf("failed to create audit log: %w", err) - } - return nil -} - -// GetAuditLogByID retrieves a single audit log by ID -func GetAuditLogByID(db *gorm.DB, id int) (*database.AuditLog, error) { - var log database.AuditLog - err := db.Where("id = ?", id).First(&log).Error - if err != nil { - return nil, fmt.Errorf("failed to get audit log: %w", err) - } - return &log, nil -} - -// ListAuditLogs retrieves audit logs with pagination and filtering -func ListAuditLogs(db *gorm.DB, limit, offset int, filterOptions *dto.ListAuditLogFilters) ([]database.AuditLog, int64, error) { - var logs []database.AuditLog - var total int64 - - query := db.Model(&database.AuditLog{}).Preload("User").Preload("Resource") - if filterOptions != nil { - if filterOptions.Action != "" { - query = query.Where("action = ?", filterOptions.Action) - } - if filterOptions.IpAddress != "" { - query = query.Where("ip_address = ?", filterOptions.IpAddress) - } - if filterOptions.UserID != 0 { - query = query.Where("user_id = ?", filterOptions.UserID) - } - if filterOptions.ResourceID != 0 { - query = query.Where("resource_id = ?", filterOptions.ResourceID) - } - if filterOptions.State != nil { - query = query.Where("state = ?", *filterOptions.State) - } - if filterOptions.Status != nil { - query = query.Where("status = ?", *filterOptions.Status) - } - if filterOptions.StartTime != nil { - query = query.Where("created_at >= ?", *filterOptions.StartTime) - } - if filterOptions.EndTime != nil { - query = query.Where("created_at <= ?", *filterOptions.EndTime) - } - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count audit logs: %w", err) - } - - if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&logs).Error; err != nil { - return nil, 0, fmt.Errorf("failed to get audit logs: %w", err) - } - - return logs, total, nil -} - -// GetAuditLogStatistics returns statistics about audit logs -func GetAuditLogStatistics() (map[string]any, error) { - stats := make(map[string]interface{}) - - // Total logs count - var totalCount int64 - if err := database.DB.Model(&database.AuditLog{}).Count(&totalCount).Error; err != nil { - return nil, fmt.Errorf("failed to count total audit logs: %w", err) - } - stats["total"] = totalCount - - // Count by status - type StatusCount struct { - Status string `json:"status"` - Count int64 `json:"count"` - } - var statusCounts []StatusCount - err := database.DB.Model(&database.AuditLog{}). - Select("status, COUNT(*) as count"). - Group("status"). - Find(&statusCounts).Error - if err != nil { - return nil, fmt.Errorf("failed to get status counts: %w", err) - } - - statusMap := make(map[string]int64) - for _, sc := range statusCounts { - statusMap[sc.Status] = sc.Count - } - stats["by_status"] = statusMap - - // Count by action - type ActionCount struct { - Action string `json:"action"` - Count int64 `json:"count"` - } - var actionCounts []ActionCount - err = database.DB.Model(&database.AuditLog{}). - Select("action, COUNT(*) as count"). - Group("action"). - Find(&actionCounts).Error - if err != nil { - return nil, fmt.Errorf("failed to get action counts: %w", err) - } - - actionMap := make(map[string]int64) - for _, ac := range actionCounts { - actionMap[ac.Action] = ac.Count - } - stats["by_action"] = actionMap - - // Recent activity (last 24 hours) - last24h := time.Now().Add(-24 * time.Hour) - var recentCount int64 - if err := database.DB.Model(&database.AuditLog{}).Where("created_at >= ?", last24h).Count(&recentCount).Error; err != nil { - return nil, fmt.Errorf("failed to count recent audit logs: %w", err) - } - stats["last_24h"] = recentCount - - return stats, nil -} diff --git a/src/repository/common.go b/src/repository/common.go deleted file mode 100644 index 1e473472..00000000 --- a/src/repository/common.go +++ /dev/null @@ -1,5 +0,0 @@ -package repository - -const ( - commonOmitFields = "active_name" -) diff --git a/src/repository/container.go b/src/repository/container.go deleted file mode 100644 index 74674d29..00000000 --- a/src/repository/container.go +++ /dev/null @@ -1,600 +0,0 @@ -package repository - -import ( - "fmt" - - "aegis/consts" - "aegis/database" - - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -const ( - containerOmitFields = "Versions" - containerVersionOmitFields = "active_version_key,HelmConfig,EnvVars" - helmConfigOmitFields = "Values" -) - -type ParameterConfigFetcher func(db *gorm.DB, keys []string, resourceID int) ([]database.ParameterConfig, error) - -// ===================================================================== -// Container Repository Functions -// ===================================================================== - -// CreateContainer creates a new container record -func CreateContainer(db *gorm.DB, container *database.Container) error { - if err := db.Omit(commonOmitFields, containerOmitFields).Create(container).Error; err != nil { - return fmt.Errorf("failed to create container: %w", err) - } - return nil -} - -// DeleteContainer soft deletes a container by setting its status to deleted -func DeleteContainer(db *gorm.DB, containerID int) (int64, error) { - result := db.Model(&database.Container{}). - Where("id = ? AND status != ?", containerID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to delete container %d: %w", containerID, err) - } - return result.RowsAffected, nil -} - -// GetContainerByID retrieves a container by its ID -func GetContainerByID(db *gorm.DB, id int) (*database.Container, error) { - var container database.Container - if err := db.Where("id = ? AND status != ?", id, consts.CommonDeleted).First(&container).Error; err != nil { - return nil, fmt.Errorf("failed to find container with id %d: %w", id, err) - } - return &container, nil -} - -// GetContainerStatistics returns statistics about containers -func GetContainerStatistics() (map[string]int64, error) { - stats := make(map[string]int64) - - // Total containers - var total int64 - if err := database.DB.Model(&database.Container{}).Count(&total).Error; err != nil { - return nil, fmt.Errorf("failed to count total containers: %v", err) - } - stats["total"] = total - - // Active containers - var active int64 - if err := database.DB.Model(&database.Container{}).Where("status = 1").Count(&active).Error; err != nil { - return nil, fmt.Errorf("failed to count active containers: %v", err) - } - stats["active"] = active - - // Disabled containers - var disabled int64 - if err := database.DB.Model(&database.Container{}).Where("status = 0").Count(&disabled).Error; err != nil { - return nil, fmt.Errorf("failed to count disabled containers: %v", err) - } - stats["disabled"] = disabled - - // Deleted containers - var deleted int64 - if err := database.DB.Model(&database.Container{}).Where("status = -1").Count(&deleted).Error; err != nil { - return nil, fmt.Errorf("failed to count deleted containers: %v", err) - } - stats["deleted"] = deleted - - return stats, nil -} - -// ListContainers lists containers based on filter options -func ListContainers(db *gorm.DB, limit, offset int, contaierType *consts.ContainerType, isPublic *bool, status *consts.StatusType) ([]database.Container, int64, error) { - var containers []database.Container - var total int64 - - query := db.Model(&database.Container{}) - if contaierType != nil { - query = query.Where("type = ?", *contaierType) - } - if isPublic != nil { - query = query.Where("is_public = ?", *isPublic) - } - if status != nil { - query = query.Where("status = ?", *status) - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count containers: %w", err) - } - - if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&containers).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list containers: %w", err) - } - - return containers, total, nil -} - -// ListContainersByID retrieves multiple containers by their IDs -func ListContainersByID(tx *gorm.DB, containerIDs []int) ([]database.Container, error) { - if len(containerIDs) == 0 { - return []database.Container{}, nil - } - - var containers []database.Container - if err := tx. - Where("id IN (?) AND status != ?", containerIDs, consts.CommonDeleted). - Find(&containers).Error; err != nil { - return nil, fmt.Errorf("failed to query containers: %w", err) - } - return containers, nil -} - -// UpdateContainer updates a container -func UpdateContainer(db *gorm.DB, container *database.Container) error { - if err := db.Omit(commonOmitFields).Save(container).Error; err != nil { - return fmt.Errorf("failed to update container: %w", err) - } - return nil -} - -// ===================================================================== -// ContainerVersion Repository Functions -// ===================================================================== - -// BatchCreateContainerVersions creates multiple container versions -func BatchCreateContainerVersions(db *gorm.DB, versions []database.ContainerVersion) error { - if len(versions) == 0 { - return fmt.Errorf("no container versions to create") - } - - if err := db.Omit(containerVersionOmitFields).Create(&versions).Error; err != nil { - return fmt.Errorf("failed to batch create container versions: %w", err) - } - - return nil -} - -// BatchDeleteContainerVersions soft deletes all versions of a specific container -func BatchDeleteContainerVersions(db *gorm.DB, containerID int) (int64, error) { - result := db.Model(&database.ContainerVersion{}). - Where("container_id = ? AND status != ?", containerID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to batch soft delete container versions for container %d: %w", containerID, result.Error) - } - return result.RowsAffected, nil -} - -// BatchGetContainerVersions retrieves container versions for multiple container names -func BatchGetContainerVersions(db *gorm.DB, containerType consts.ContainerType, containerNames []string, userID int) ([]database.ContainerVersion, error) { - if len(containerNames) == 0 { - return []database.ContainerVersion{}, nil - } - - var versions []database.ContainerVersion - - query := db.Table("container_versions cv"). - Preload("Container"). - Where("cv.status = ?", consts.CommonEnabled). - Order("cv.container_id DESC, cv.name_major DESC, cv.name_minor DESC, cv.name_patch DESC") - - query = query.Joins("INNER JOIN containers c ON c.id = cv.container_id"). - Where("c.type = ? AND c.name IN (?) AND c.status = ?", containerType, containerNames, consts.CommonEnabled) - - if userID > 0 { - query = query.Joins( - "LEFT JOIN user_containers uc ON uc.container_id = c.id AND uc.user_id = ? AND uc.status = ?", - userID, consts.CommonEnabled, - ).Where( - db.Where("c.is_public = ?", true). - Or("uc.container_id IS NOT NULL"), - ) - } - - if err := query.Find(&versions).Error; err != nil { - return nil, fmt.Errorf("failed to query container versions: %w", err) - } - - return versions, nil -} - -// CheckContainerExistsWithDifferentType checks if a container exists with a different type -func CheckContainerExistsWithDifferentType(db *gorm.DB, containerName string, requestedType consts.ContainerType, userID int) (bool, consts.ContainerType, error) { - var container database.Container - - query := db.Table("containers"). - Where("containers.name = ? AND containers.type != ? AND containers.status = ?", containerName, requestedType, consts.CommonEnabled) - - if userID > 0 { - query = query.Joins( - "LEFT JOIN user_containers uc ON uc.container_id = containers.id AND uc.user_id = ? AND uc.status = ?", - userID, consts.CommonEnabled, - ).Where( - db.Where("containers.is_public = ?", true). - Or("uc.container_id IS NOT NULL"), - ) - } - - if err := query.First(&container).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return false, 0, nil - } - return false, 0, fmt.Errorf("failed to check container existence: %w", err) - } - - return true, container.Type, nil -} - -// DeleteContainerVersion soft deletes a container version -func DeleteContainerVersion(db *gorm.DB, versionID int) (int64, error) { - result := db.Model(&database.ContainerVersion{}). - Where("id = ? AND status != ?", versionID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to soft delete container version %d: %w", versionID, result.Error) - } - return result.RowsAffected, nil -} - -// GetContainerVersionByID retrieves a ContainerVersion by its ID -func GetContainerVersionByID(db *gorm.DB, versionID int) (*database.ContainerVersion, error) { - var version database.ContainerVersion - if err := db. - Preload("Container"). - Preload("HelmConfig"). - Where("id = ?", versionID).First(&version).Error; err != nil { - return nil, fmt.Errorf("failed to find container version with id %d: %w", versionID, err) - } - return &version, nil -} - -// ListContainerVersions lists container versions with pagination and optional status filtering -func ListContainerVersions(db *gorm.DB, limit, offset int, containerID int, status *consts.StatusType) ([]database.ContainerVersion, int64, error) { - var versions []database.ContainerVersion - var total int64 - - query := db.Model(&database.ContainerVersion{}).Where("container_id = ?", containerID) - if status != nil { - query = query.Where("status = ?", *status) - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count container versions: %v", err) - } - - if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&versions).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list container versions: %v", err) - } - - return versions, total, nil -} - -// ListContainerVersions lists all versions of a specific container -func ListContainerVersionsByContainerID(db *gorm.DB, containerID int) ([]database.ContainerVersion, error) { - var versions []database.ContainerVersion - if err := db. - Preload("Container"). - Preload("HelmConfig"). - Where("container_id = ?", containerID). - Find(&versions).Error; err != nil { - return nil, fmt.Errorf("failed to list container versions for container %d: %w", containerID, err) - } - return versions, nil -} - -// UpdateContainerVersion updates a container version -func UpdateContainerVersion(db *gorm.DB, version *database.ContainerVersion) error { - if err := db.Omit(containerVersionOmitFields).Save(version).Error; err != nil { - return fmt.Errorf("failed to update container version: %w", err) - } - return nil -} - -// UpdateContainerVersionImageColumns atomically rewrites the four image -// reference columns (registry, namespace, repository, tag) on a single -// container_versions row. It performs a targeted UPDATE of only those -// columns so that unrelated fields (status, usage_count, version name) and -// BeforeCreate/hook-maintained fields remain untouched. -func UpdateContainerVersionImageColumns(db *gorm.DB, versionID int, registry, namespace, repository, tag string) (int64, error) { - result := db.Model(&database.ContainerVersion{}). - Where("id = ?", versionID). - Updates(map[string]any{ - "registry": registry, - "namespace": namespace, - "repository": repository, - "tag": tag, - }) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to update container version image columns: %w", err) - } - return result.RowsAffected, nil -} - -// ===================================================================== -// HelmConfig Repository Functions -// ===================================================================== - -// BatchCreateHelmConfigs creates multiple helm configs -func BatchCreateHelmConfigs(db *gorm.DB, helmConfigs []*database.HelmConfig) error { - if len(helmConfigs) == 0 { - return fmt.Errorf("no helm configs to create") - } - - if err := db.Omit(helmConfigOmitFields).Create(helmConfigs).Error; err != nil { - return fmt.Errorf("failed to batch create helm configs: %v", err) - } - - return nil -} - -// GetHelmConfigByContainerVersionID retrieves the HelmConfig associated with a specific ContainerVersion ID -func GetHelmConfigByContainerVersionID(db *gorm.DB, versionID int) (*database.HelmConfig, error) { - var helmConfig database.HelmConfig - if err := db.Preload("ContainerVersion"). - Where("container_version_id = ?", versionID). - First(&helmConfig).Error; err != nil { - return nil, fmt.Errorf("failed to find helm config for version id %d: %w", versionID, err) - } - return &helmConfig, nil -} - -// UpdateHelmConfig updates a helm config -func UpdateHelmConfig(db *gorm.DB, helmConfig *database.HelmConfig) error { - if err := db.Save(helmConfig).Error; err != nil { - return fmt.Errorf("failed to update helm config: %w", err) - } - return nil -} - -// ===================================================================== -// ParameterConfig Repository Functions -// ===================================================================== - -// BatchCreateOrFindParameterConfigs creates multiple parameter configs or finds existing ones using upsert -func BatchCreateOrFindParameterConfigs(db *gorm.DB, params []database.ParameterConfig) error { - if len(params) == 0 { - return nil - } - - if err := db.Clauses(clause.OnConflict{ - OnConstraint: "idx_unique_config", - DoNothing: true, - }).Create(¶ms).Error; err != nil { - return fmt.Errorf("failed to batch create parameter configs: %w", err) - } - return nil -} - -// ListParameterConfigsByKeys retrieves ParameterConfigs by their keys, type and category -func ListParameterConfigsByKeys(db *gorm.DB, configs []database.ParameterConfig) ([]database.ParameterConfig, error) { - if len(configs) == 0 { - return []database.ParameterConfig{}, nil - } - - // Build query conditions for batch lookup - var results []database.ParameterConfig - query := db.Model(&database.ParameterConfig{}) - - // Build OR conditions for each config - conditions := db.Where("1 = 0") // Start with false condition - for _, cfg := range configs { - conditions = conditions.Or( - db.Where("config_key = ? AND type = ? AND category = ?", cfg.Key, cfg.Type, cfg.Category), - ) - } - - if err := query.Where(conditions).Find(&results).Error; err != nil { - return nil, fmt.Errorf("failed to list parameter configs by keys: %w", err) - } - - return results, nil -} - -// ===================================================================== -// ContainerLabel Repository Functions -// ===================================================================== - -// AddContainerLabels adds multiple container-label associations in a batch -func AddContainerLabels(db *gorm.DB, containerLabels []database.ContainerLabel) error { - if len(containerLabels) == 0 { - return nil - } - if err := db.Create(&containerLabels).Error; err != nil { - return fmt.Errorf("failed to add container-label associations: %w", err) - } - return nil -} - -// ClearContainerLabels removes label associations from specified containers -func ClearContainerLabels(db *gorm.DB, containerIDs []int, labelIDs []int) error { - if len(containerIDs) == 0 { - return nil - } - - query := db.Table("container_labels"). - Where("container_id IN (?)", containerIDs) - if len(labelIDs) > 0 { - query = query.Where("label_id IN (?)", labelIDs) - } - - if err := query.Delete(nil).Error; err != nil { - return fmt.Errorf("failed to clear container-label associations: %w", err) - } - return nil -} - -// RemoveContainersFromLabel removes all container associations from a specific label -func RemoveContainersFromLabel(db *gorm.DB, labelID int) (int64, error) { - result := db.Where("label_id = ?", labelID). - Delete(&database.ContainerLabel{}) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to remove all containers from label %d: %w", labelID, err) - } - return result.RowsAffected, nil -} - -// RemoveContainersFromLabels removes all container associations from multiple labels -func RemoveContainersFromLabels(db *gorm.DB, labelIDs []int) (int64, error) { - if len(labelIDs) == 0 { - return 0, nil - } - - result := db.Where("label_id IN (?)", labelIDs). - Delete(&database.ContainerLabel{}) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to remove all containers from labels %v: %w", labelIDs, err) - } - return result.RowsAffected, nil -} - -// ListContainerLabels gets labels for multiple containers in batch -func ListContainerLabels(db *gorm.DB, containerIDs []int) (map[int][]database.Label, error) { - if len(containerIDs) == 0 { - return nil, nil - } - - type containerLabelResult struct { - database.Label - containerID int `gorm:"column:container_id"` - } - - var flatResults []containerLabelResult - if err := db.Model(&database.Label{}). - Joins("JOIN container_labels cl ON cl.label_id = labels.id"). - Where("cl.container_id IN (?)", containerIDs). - Select("labels.*, cl.container_id"). - Find(&flatResults).Error; err != nil { - return nil, fmt.Errorf("failed to batch query container labels: %w", err) - } - - labelsMap := make(map[int][]database.Label) - for _, id := range containerIDs { - labelsMap[id] = []database.Label{} - } - - for _, res := range flatResults { - label := res.Label - labelsMap[res.containerID] = append(labelsMap[res.containerID], label) - } - - return labelsMap, nil -} - -// ListContainerLabelCounts retrieves the count of containers associated with each label ID -func ListContainerLabelCounts(db *gorm.DB, labelIDs []int) (map[int]int64, error) { - if len(labelIDs) == 0 { - return make(map[int]int64), nil - } - - type containerLabelResult struct { - labelID int `gorm:"column:label_id"` - count int64 - } - - var results []containerLabelResult - if err := db.Model(&database.ContainerLabel{}). - Select("label_id, count(label_id) as count"). - Where("label_id IN (?)", labelIDs). - Group("label_id"). - Find(&results).Error; err != nil { - return nil, fmt.Errorf("failed to count associations: %w", err) - } - - countMap := make(map[int]int64, len(results)) - for _, result := range results { - countMap[result.labelID] = result.count - } - - return countMap, nil -} - -// ListLabelsByContainerID lists all labels associated with a specific container -func ListLabelsByContainerID(db *gorm.DB, containerID int) ([]database.Label, error) { - var labels []database.Label - if err := db.Model(&database.Label{}). - Joins("JOIN container_labels cl ON cl.label_id = labels.id"). - Where("cl.container_id = ?", containerID). - Find(&labels).Error; err != nil { - return nil, fmt.Errorf("failed to list labels for container %d: %w", containerID, err) - } - return labels, nil -} - -// ListLabelIDsByKeyAndContainerID finds label IDs by keys associated with a specific container -func ListLabelIDsByKeyAndContainerID(db *gorm.DB, containerID int, keys []string) ([]int, error) { - var labelIDs []int - - err := db.Table("labels l"). - Select("l.id"). - Joins("JOIN container_labels cl ON cl.label_id = l.id"). - Where("cl.container_id = ? AND l.label_key IN (?)", containerID, keys). - Pluck("l.id", &labelIDs).Error - if err != nil { - return nil, fmt.Errorf("failed to find label IDs by keys for container %d: %w", containerID, err) - } - - return labelIDs, nil -} - -// ===================================================================== -// ContainerVersionEnvVar Repository Functions -// ===================================================================== - -// AddContainerVersionEnvVars adds multiple environment variable parameters for a specific container version -func AddContainerVersionEnvVars(db *gorm.DB, envVars []database.ContainerVersionEnvVar) error { - if len(envVars) == 0 { - return nil - } - if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&envVars).Error; err != nil { - return fmt.Errorf("failed to add container version env vars: %w", err) - } - return nil -} - -// ListContainerEnvVars lists environment variable parameters for a specific container version -func ListContainerVersionEnvVars(db *gorm.DB, keys []string, containerVersionID int) ([]database.ParameterConfig, error) { - query := db.Model(&database.ParameterConfig{}). - Joins("JOIN container_version_env_vars cvev ON cvev.parameter_config_id = parameter_configs.id"). - Where("cvev.container_version_id = ?", containerVersionID). - Where("parameter_configs.category = ?", consts.ParameterCategoryEnvVars) - - if len(keys) > 0 { - query = query.Where("parameter_configs.config_key IN (?)", keys) - } - - var params []database.ParameterConfig - if err := query.Find(¶ms).Error; err != nil { - return nil, fmt.Errorf("failed to list container env vars: %w", err) - } - return params, nil -} - -// ===================================================================== -// HelmConfigValues Repository Functions -// ===================================================================== - -// AddHelmConfigValues adds multiple helm value parameters for a specific helm config -func AddHelmConfigValues(db *gorm.DB, helmValues []database.HelmConfigValue) error { - if len(helmValues) == 0 { - return nil - } - if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&helmValues).Error; err != nil { - return fmt.Errorf("failed to add helm config values: %w", err) - } - return nil -} - -// ListHelmConfigValues lists helm value parameters for a specific helm config -func ListHelmConfigValues(db *gorm.DB, keys []string, helmConfigID int) ([]database.ParameterConfig, error) { - query := db.Model(&database.ParameterConfig{}). - Joins("JOIN helm_config_values hcv ON hcv.parameter_config_id = parameter_configs.id"). - Where("hcv.helm_config_id = ?", helmConfigID) - - if len(keys) > 0 { - query = query.Where("parameter_configs.config_key IN (?)", keys) - } - - var params []database.ParameterConfig - if err := query.Find(¶ms).Error; err != nil { - return nil, fmt.Errorf("failed to list helm values: %w", err) - } - return params, nil -} diff --git a/src/repository/dataset.go b/src/repository/dataset.go deleted file mode 100644 index 9107ba6a..00000000 --- a/src/repository/dataset.go +++ /dev/null @@ -1,484 +0,0 @@ -package repository - -import ( - "fmt" - - "aegis/consts" - "aegis/database" - - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -const ( - datasetVersionOmitFields = "active_version_key" -) - -// ===================================================================== -// Dataset Repository Functions -// ===================================================================== - -// CreateDataset creates a new dataset record -func CreateDataset(db *gorm.DB, dataset *database.Dataset) error { - if err := db.Omit(commonOmitFields).Create(dataset).Error; err != nil { - return fmt.Errorf("failed to create dataset: %v", err) - } - return nil -} - -// DeleteDataset soft deletes a dataset by setting its status to deleted -func DeleteDataset(db *gorm.DB, id int) (int64, error) { - result := db.Model(&database.Dataset{}). - Where("id = ? AND status != ?", id, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to delete dataset: %v", err) - } - return result.RowsAffected, nil -} - -// GetDatasetByID gets dataset by ID -func GetDatasetByID(db *gorm.DB, id int) (*database.Dataset, error) { - var dataset database.Dataset - if err := db.Where("id = ? AND status != ?", id, consts.CommonDeleted).First(&dataset).Error; err != nil { - return nil, fmt.Errorf("failed to get dataset: %v", err) - } - return &dataset, nil -} - -// ListDatasets gets dataset list -func ListDatasets(db *gorm.DB, limit, offset int, datasetType string, isPublic *bool, status *consts.StatusType) ([]database.Dataset, int64, error) { - var datasets []database.Dataset - var total int64 - - query := db.Model(&database.Dataset{}) - if datasetType != "" { - query = query.Where("type = ?", datasetType) - } - if isPublic != nil { - query = query.Where("is_public = ?", *isPublic) - } - if status != nil { - query = query.Where("status = ?", *status) - } - - // Get total count - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count datasets: %v", err) - } - - if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&datasets).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list datasets: %v", err) - } - - return datasets, total, nil -} - -// ListDatasetsByID retrieves multiple datasets by their IDs -func ListDatasetsByID(db *gorm.DB, datasetIDs []int) ([]database.Dataset, error) { - if len(datasetIDs) == 0 { - return []database.Dataset{}, nil - } - - var datasets []database.Dataset - if err := db. - Where("id IN (?) AND status != ?", datasetIDs, consts.CommonDeleted). - Find(&datasets).Error; err != nil { - return nil, fmt.Errorf("failed to query datasets: %w", err) - } - - return datasets, nil -} - -// UpdateDataset updates dataset information -func UpdateDataset(db *gorm.DB, dataset *database.Dataset) error { - if err := db.Omit(commonOmitFields).Save(dataset).Error; err != nil { - return fmt.Errorf("failed to update dataset: %v", err) - } - return nil -} - -// GetDatasetStatistics returns statistics about datasets -func GetDatasetStatistics() (map[string]int64, error) { - stats := make(map[string]int64) - - // Total datasets - var total int64 - if err := database.DB.Model(&database.Dataset{}).Count(&total).Error; err != nil { - return nil, fmt.Errorf("failed to count total datasets: %v", err) - } - stats["total"] = total - - // Active datasets - var active int64 - if err := database.DB.Model(&database.Dataset{}).Where("status = ?", consts.DatapackInjectSuccess).Count(&active).Error; err != nil { - return nil, fmt.Errorf("failed to count active datasets: %v", err) - } - stats["active"] = active - - // Disabled datasets - var disabled int64 - if err := database.DB.Model(&database.Dataset{}).Where("status = ?", consts.DatapackInitial).Count(&disabled).Error; err != nil { - return nil, fmt.Errorf("failed to count disabled datasets: %v", err) - } - stats["disabled"] = disabled - - // Deleted datasets - var deleted int64 - if err := database.DB.Model(&database.Dataset{}).Where("status = ?", consts.CommonDeleted).Count(&deleted).Error; err != nil { - return nil, fmt.Errorf("failed to count deleted datasets: %v", err) - } - stats["deleted"] = deleted - - return stats, nil -} - -// ===================================================================== -// DatasetVersion Repository Functions -// ===================================================================== - -// BatchCreateDatasetVersions creates multiple dataset versions -func BatchCreateDatasetVersions(db *gorm.DB, versions []database.DatasetVersion) error { - if len(versions) == 0 { - return fmt.Errorf("no dataset versions to create") - } - - if err := db.Omit(datasetVersionOmitFields).Create(&versions).Error; err != nil { - return fmt.Errorf("failed to batch create dataset versions: %w", err) - } - - return nil -} - -// BatchDeleteDatasetVersions soft deletes all versions of a specific dataset -func BatchDeleteDatasetVersions(db *gorm.DB, datasetID int) (int64, error) { - result := db.Model(&database.DatasetVersion{}). - Where("dataset_id = ? AND status != ?", datasetID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to batch soft delete dataset versions for dataset %d: %w", datasetID, result.Error) - } - return result.RowsAffected, nil -} - -// BatchGetDatasetVersions retrieves dataset versions for multiple dataset names -func BatchGetDatasetVersions(db *gorm.DB, datasetNames []string, userID int) ([]database.DatasetVersion, error) { - if len(datasetNames) == 0 { - return []database.DatasetVersion{}, nil - } - - var versions []database.DatasetVersion - - query := db.Table("dataset_versions dv"). - Preload("Dataset"). - Where("dv.status = ?", consts.CommonEnabled). - Order("dv.dataset_id DESC, dv.name_major DESC, dv.name_minor DESC, dv.name_patch DESC") - - query = query.Joins("INNER JOIN datasets d ON d.id = dv.dataset_id"). - Where("d.name IN (?) AND d.status = ?", datasetNames, consts.CommonEnabled) - - if userID > 0 { - query = query.Joins( - "LEFT JOIN user_datasets ud ON ud.dataset_id = d.id AND ud.user_id = ? AND ud.status = ?", - userID, consts.CommonEnabled, - ).Where( - db.Where("d.is_public = ?", true). - Or("ud.dataset_id IS NOT NULL"), - ) - } - - if err := query.Find(&versions).Error; err != nil { - return nil, fmt.Errorf("failed to query dataset versions: %w", err) - } - - return versions, nil -} - -// DeleteDatasetVersion performs a soft delete on the dataset version by setting its status to deleted -func DeleteDatasetVersion(db *gorm.DB, versionID int) (int64, error) { - result := db.Model(&database.DatasetVersion{}). - Where("id = ? AND status != ?", versionID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to soft delete dataset version %d: %w", versionID, result.Error) - } - return result.RowsAffected, nil -} - -// GetDatasetVersionByID retrieves a dataset version by its ID -func GetDatasetVersionByID(db *gorm.DB, id int) (*database.DatasetVersion, error) { - var version database.DatasetVersion - if err := db.Preload("Datapacks").Where("id = ?", id).First(&version).Error; err != nil { - return nil, fmt.Errorf("failed to get dataset version: %v", err) - } - return &version, nil -} - -// ListDatasetVersions lists dataset versions with pagination and optional status filtering -func ListDatasetVersions(db *gorm.DB, limit, offset int, datasetID int, status *consts.StatusType) ([]database.DatasetVersion, int64, error) { - var versions []database.DatasetVersion - var total int64 - - query := db.Model(&database.DatasetVersion{}).Where("dataset_id = ?", datasetID) - if status != nil { - query = query.Where("status = ?", *status) - } - - // Get total count - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count dataset versions: %v", err) - } - - if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&versions).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list dataset versions: %v", err) - } - - return versions, total, nil -} - -// ListDatasetVersions lists all versions of a specific dataset -func ListDatasetVersionsByDatasetID(db *gorm.DB, datasetID int) ([]database.DatasetVersion, error) { - var versions []database.DatasetVersion - if err := db.Where("dataset_id = ?", datasetID).Find(&versions).Error; err != nil { - return nil, fmt.Errorf("failed to list dataset versions for dataset %d: %w", datasetID, err) - } - return versions, nil -} - -// UpdateDatasetVersion updates a dataset version -func UpdateDatasetVersion(db *gorm.DB, version *database.DatasetVersion) error { - if err := db.Omit(datasetVersionOmitFields).Save(version).Error; err != nil { - return fmt.Errorf("failed to update dataset version: %w", err) - } - return nil -} - -// ===================================================================== -// DatasetLabel Repository Functions -// ===================================================================== - -// AddDatasetLabels adds multiple dataset-label associations in a batch -func AddDatasetLabels(db *gorm.DB, datasetLabels []database.DatasetLabel) error { - if len(datasetLabels) == 0 { - return nil - } - if err := db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "dataset_id"}, {Name: "label_id"}}, - DoNothing: true, - }).Create(&datasetLabels).Error; err != nil { - return fmt.Errorf("failed to add dataset-label associations: %w", err) - } - return nil -} - -// ClearDatasetLabels removes label associations from specified datasets -func ClearDatasetLabels(db *gorm.DB, datasetIDs []int, labelIDs []int) error { - if len(datasetIDs) == 0 { - return nil - } - - query := db.Table("dataset_labels"). - Where("dataset_id IN (?)", datasetIDs) - if len(labelIDs) > 0 { - query = query.Where("label_id IN (?)", labelIDs) - } - - if err := query.Delete(nil).Error; err != nil { - return fmt.Errorf("failed to clear dataset-label associations: %w", err) - } - return nil -} - -// RemoveLabelsFromDataset removes all label associations from a specific dataset -func RemoveLabelsFromDataset(db *gorm.DB, datasetID int) error { - if err := db.Where("dataset_id = ?", datasetID). - Delete(&database.DatasetLabel{}).Error; err != nil { - return fmt.Errorf("failed to delete all labels from dataset %d: %w", datasetID, err) - } - return nil -} - -// RemoveDatasetsFromLabel removes all dataset associations from a specific label -func RemoveDatasetsFromLabel(db *gorm.DB, labelID int) (int64, error) { - result := db.Where("label_id = ?", labelID). - Delete(&database.DatasetLabel{}) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to delete all datasets from label %d: %w", labelID, err) - } - return result.RowsAffected, nil -} - -// RemoveDatasetsFromLabels removes all dataset associations from multiple labels -func RemoveDatasetsFromLabels(db *gorm.DB, labelIDs []int) (int64, error) { - if len(labelIDs) == 0 { - return 0, nil - } - - result := db.Where("label_id IN (?)", labelIDs). - Delete(&database.DatasetLabel{}) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to delete all datasets from labels %v: %w", labelIDs, err) - } - return result.RowsAffected, nil -} - -// ListDatasetLabelCounts retrieves the count of datasets associated with each label ID -func ListDatasetLabelCounts(db *gorm.DB, labelIDs []int) (map[int]int64, error) { - if len(labelIDs) == 0 { - return make(map[int]int64), nil - } - - type datasetLabelResult struct { - labelID int `gorm:"column:label_id"` - count int64 - } - - var results []datasetLabelResult - if err := db.Model(&database.DatasetLabel{}). - Select("label_id, count(label_id) as count"). - Where("label_id IN (?)", labelIDs). - Group("label_id"). - Find(&results).Error; err != nil { - return nil, fmt.Errorf("failed to count dataset-label associations: %w", err) - } - - countMap := make(map[int]int64, len(results)) - for _, result := range results { - countMap[result.labelID] = result.count - } - - return countMap, nil -} - -// ListDatasetLabels lists all labels associated with multiple datasets -func ListDatasetLabels(db *gorm.DB, datasetIDs []int) (map[int][]database.Label, error) { - if len(datasetIDs) == 0 { - return nil, nil - } - - type datasetLabelResult struct { - database.Label - datasetID int `gorm:"column:dataset_id"` - } - - var flatResults []datasetLabelResult - if err := db.Model(&database.Label{}). - Joins("JOIN dataset_labels dl ON dl.label_id = labels.id"). - Where("dl.dataset_id IN (?)", datasetIDs). - Select("labels.*, dl.dataset_id"). - Find(&flatResults).Error; err != nil { - return nil, fmt.Errorf("failed to batch query dataset labels: %w", err) - } - - labelsMap := make(map[int][]database.Label) - for _, id := range datasetIDs { - labelsMap[id] = []database.Label{} - } - - for _, res := range flatResults { - label := res.Label - labelsMap[res.datasetID] = append(labelsMap[res.datasetID], label) - } - - return labelsMap, nil -} - -// ListLabelsByDatasetID lists all labels associated with a specific dataset -func ListLabelsByDatasetID(db *gorm.DB, datasetID int) ([]database.Label, error) { - var labels []database.Label - if err := db.Model(&database.Label{}). - Joins("JOIN dataset_labels dl ON dl.label_id = labels.id"). - Where("dl.dataset_id = ?", datasetID). - Find(&labels).Error; err != nil { - return nil, fmt.Errorf("failed to list labels for dataset %d: %w", datasetID, err) - } - return labels, nil -} - -// ListLabelIDsByKeyAndInjectionID finds label IDs by keys associated with a specific injection -func ListLabelIDsByKeyAndDatasetID(db *gorm.DB, datasetID int, keys []string) ([]int, error) { - var labelIDs []int - - err := db.Table("labels l"). - Select("l.id"). - Joins("JOIN dataset_labels dl ON dl.label_id = l.id"). - Where("dl.dataset_id = ? AND l.label_key IN (?)", datasetID, keys). - Pluck("l.id", &labelIDs).Error - if err != nil { - return nil, fmt.Errorf("failed to find label IDs by key '%s': %w", keys, err) - } - - return labelIDs, nil -} - -// ===================================================================== -// DatasetVersionInjection Repository Functions -// ===================================================================== - -// AddDatasetVersionInjections adds multiple dataset-version-injection associations in a batch -func AddDatasetVersionInjections(db *gorm.DB, datasetVersionInjections []database.DatasetVersionInjection) error { - if len(datasetVersionInjections) == 0 { - return nil - } - if err := db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "dataset_version_id"}, {Name: "injection_id"}}, - DoNothing: true, - }).Create(&datasetVersionInjections).Error; err != nil { - return fmt.Errorf("failed to add dataset-version-injection associations: %w", err) - } - return nil -} - -// ClearDatasetVersionInjections removes fault injection associations from specified dataset versions -func ClearDatasetVersionInjections(db *gorm.DB, datasetVersionIDs []int, injectionIDs []int) error { - if len(datasetVersionIDs) == 0 { - return nil - } - - query := db.Table("dataset_version_injections"). - Where("dataset_version_id IN (?)", datasetVersionIDs) - if len(injectionIDs) > 0 { - query = query.Where("injection_id IN (?)", injectionIDs) - } - - if err := query.Delete(nil).Error; err != nil { - return fmt.Errorf("failed to clear dataset-version-injection associations: %w", err) - } - return nil -} - -// RemoveInjectionsFromDatasetVersion deletes all injection associations for a given dataset version -func RemoveInjectionsFromDatasetVersion(db *gorm.DB, datasetVersionID int) error { - if err := db.Where("dataset_version_id = ?", datasetVersionID). - Delete(&database.DatasetVersionInjection{}).Error; err != nil { - return fmt.Errorf("failed to delete all injections from dataset version %d: %w", datasetVersionID, err) - } - return nil -} - -// RemoveDatasetVersionsFromInjection deletes all dataset version associations for a given fault injection -func RemoveDatasetVersionsFromInjection(db *gorm.DB, faultInjectionID int) error { - if err := db.Where("injection_id = ?", faultInjectionID). - Delete(&database.DatasetVersionInjection{}).Error; err != nil { - return fmt.Errorf("failed to delete all dataset versions from fault injection %d: %w", faultInjectionID, err) - } - return nil -} - -// ListInjectionsByDatasetVersionID lists all fault injections associated with a specific dataset version -func ListInjectionsByDatasetVersionID(db *gorm.DB, datasetVersionID int, includeLabels bool) ([]database.FaultInjection, error) { - query := db.Model(&database.FaultInjection{}) - if includeLabels { - query = query.Preload("Labels") - } - - var injections []database.FaultInjection - if err := query. - Joins("JOIN dataset_version_injections dvi ON dvi.injection_id = id"). - Where("state = ? AND status != ?", consts.DatapackBuildSuccess, consts.CommonDeleted). - Where("dvi.dataset_version_id = ?", datasetVersionID). - Find(&injections).Error; err != nil { - return nil, fmt.Errorf("failed to list fault injections for dataset version %d: %w", datasetVersionID, err) - } - return injections, nil -} diff --git a/src/repository/detector.go b/src/repository/detector.go deleted file mode 100644 index 75c643fb..00000000 --- a/src/repository/detector.go +++ /dev/null @@ -1,33 +0,0 @@ -package repository - -import ( - "fmt" - - "aegis/database" - - "gorm.io/gorm" -) - -// ListDetectorResultsByExecutionID lists detector results for a specific execution ID -func ListDetectorResultsByExecutionID(db *gorm.DB, executionID int) ([]database.DetectorResult, error) { - var results []database.DetectorResult - if err := db. - Where("execution_id = ?", executionID). - Find(&results).Error; err != nil { - return nil, fmt.Errorf("failed to list detectors for execution %d: %w", executionID, err) - } - return results, nil -} - -// SaveDetectorResults saves multiple detector results -func SaveDetectorResults(db *gorm.DB, results []database.DetectorResult) error { - if len(results) == 0 { - return fmt.Errorf("no detector results to save") - } - - if err := db.Create(&results).Error; err != nil { - return fmt.Errorf("failed to save detector results: %w", err) - } - - return nil -} diff --git a/src/repository/dynamic_config.go b/src/repository/dynamic_config.go deleted file mode 100644 index fd04d17e..00000000 --- a/src/repository/dynamic_config.go +++ /dev/null @@ -1,196 +0,0 @@ -package repository - -import ( - "fmt" - - "aegis/consts" - "aegis/database" - - "gorm.io/gorm" -) - -// ===================================================================== -// DynamicConfig Repository Functions -// ===================================================================== - -// CreateConfig creates a new configuration item -func CreateConfig(db *gorm.DB, config *database.DynamicConfig) error { - if err := db.Create(config).Error; err != nil { - return fmt.Errorf("failed to create config: %w", err) - } - return nil -} - -// GetConfigByKey retrieves a configuration by its key -func GetConfigByKey(db *gorm.DB, configKey string, includeUser bool) (*database.DynamicConfig, error) { - query := db - if includeUser { - query = query.Preload("UpdatedByUser") - } - - var config database.DynamicConfig - if err := query. - Where("config_key = ?", configKey). - First(&config).Error; err != nil { - return nil, fmt.Errorf("failed to find config with key %s: %w", configKey, err) - } - return &config, nil -} - -// GetConfigByID retrieves a configuration by its ID -func GetConfigByID(db *gorm.DB, configID int, includeUser bool) (*database.DynamicConfig, error) { - query := db - if includeUser { - query = query.Preload("UpdatedByUser") - } - - var config database.DynamicConfig - if err := query. - Where("id = ?", configID). - First(&config).Error; err != nil { - return nil, fmt.Errorf("failed to find config with id %d: %w", configID, err) - } - return &config, nil -} - -// List ExistingConfigs lists all existing configurations -func ListExistingConfigs(db *gorm.DB) ([]database.DynamicConfig, error) { - var configs []database.DynamicConfig - if err := db. - Order("config_key ASC"). - Find(&configs).Error; err != nil { - return nil, fmt.Errorf("failed to list all existing configs: %w", err) - } - return configs, nil -} - -// ListConfigs lists configs based on filter options -func ListConfigs(db *gorm.DB, limit, offset int, valueType *consts.ConfigValueType, category *string, isSecret *bool, updatedBy *int) ([]database.DynamicConfig, int64, error) { - var configs []database.DynamicConfig - var total int64 - - query := db.Model(&database.DynamicConfig{}) - if valueType != nil { - query = query.Where("value_type = ?", *valueType) - } - if category != nil { - query = query.Where("category = ?", *category) - } - if isSecret != nil { - query = query.Where("is_secret = ?", *isSecret) - } - if updatedBy != nil { - query = query.Where("updated_by = ?", *updatedBy) - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count configs: %w", err) - } - - if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&configs).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list configs: %w", err) - } - - return configs, total, nil -} - -// ListConfigByScope lists configs filtered by scope -func ListConfigByScope(db *gorm.DB, scope consts.ConfigScope) ([]database.DynamicConfig, error) { - var configs []database.DynamicConfig - if err := db. - Where("scope = ?", scope). - Order("config_key ASC"). - Find(&configs).Error; err != nil { - return nil, fmt.Errorf("failed to list configs by scope %s: %w", consts.GetConfigScopeName(scope), err) - } - return configs, nil -} - -// UpdateConfig updates a configuration item -func UpdateConfig(db *gorm.DB, config *database.DynamicConfig) error { - if err := db.Save(config).Error; err != nil { - return fmt.Errorf("failed to update config: %w", err) - } - return nil -} - -// ===================================================================== -// ConfigHistory Repository Functions -// ===================================================================== - -// CreateConfigHistory creates a new history record -func CreateConfigHistory(db *gorm.DB, history *database.ConfigHistory) error { - if err := db.Create(history).Error; err != nil { - return fmt.Errorf("failed to create config history: %w", err) - } - return nil -} - -// GetConfigHistory retrieves a specific history entry by ID -func GetConfigHistory(db *gorm.DB, historyID int) (*database.ConfigHistory, error) { - var history database.ConfigHistory - if err := db. - Preload("Operator"). - Preload("Config"). - First(&history, historyID).Error; err != nil { - return nil, fmt.Errorf("failed to find config history with id %d: %w", historyID, err) - } - return &history, nil -} - -// GetLatestConfigHistory retrieves the most recent configuration change -func GetLatestConfigHistory(db *gorm.DB) (*database.ConfigHistory, error) { - var history database.ConfigHistory - if err := db. - Preload("Operator"). - Preload("Config"). - Order("created_at DESC"). - First(&history).Error; err != nil { - return nil, fmt.Errorf("failed to get latest config history: %w", err) - } - return &history, nil -} - -// ListConfigHistories lists configuration history entries with pagination and optional filters -func ListConfigHistories(db *gorm.DB, limit, offset int, configID int, changeType *consts.ConfigHistoryChangeType, operatorID *int) ([]database.ConfigHistory, int64, error) { - var histories []database.ConfigHistory - var total int64 - - query := db.Model(&database.ConfigHistory{}). - Where("config_id = ?", configID) - - if changeType != nil { - query = query.Where("change_type = ?", *changeType) - } - if operatorID != nil { - query = query.Where("operator_id = ?", *operatorID) - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count config histories: %w", err) - } - - if err := query. - Preload("Operator"). - Limit(limit). - Offset(offset). - Order("created_at DESC"). - Find(&histories).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list config histories: %w", err) - } - - return histories, total, nil -} - -// ListConfigHistoriesByConfigID lists all history entries for a specific configuration -func ListConfigHistoriesByConfigID(db *gorm.DB, configID int) ([]database.ConfigHistory, error) { - var histories []database.ConfigHistory - if err := db. - Preload("Operator"). - Where("config_id = ?", configID). - Order("created_at DESC"). - Find(&histories).Error; err != nil { - return nil, fmt.Errorf("failed to list config histories for config %d: %w", configID, err) - } - return histories, nil -} diff --git a/src/repository/execution.go b/src/repository/execution.go deleted file mode 100644 index c2433c06..00000000 --- a/src/repository/execution.go +++ /dev/null @@ -1,522 +0,0 @@ -package repository - -import ( - "fmt" - "strings" - - "gorm.io/gorm" - "gorm.io/gorm/clause" - - "aegis/consts" - "aegis/database" -) - -const BATCH_SIZE = 500 - -// ===================================================================== -// Execution Repository Functions -// ===================================================================== - -// BatchDeleteExecutions marks multiple executions as deleted in batch -func BatchDeleteExecutions(db *gorm.DB, executions []int) error { - if len(executions) == 0 { - return nil - } - - if err := db.Model(&database.Execution{}). - Where("id IN (?) AND status != ?", executions, consts.CommonDeleted). - Update("status", consts.CommonDeleted).Error; err != nil { - return fmt.Errorf("failed to batch delete executions: %w", err) - } - - return nil -} - -// CreateExecution creates a new execution result record -func CreateExecution(db *gorm.DB, execution *database.Execution) error { - if err := db.Create(execution).Error; err != nil { - return fmt.Errorf("failed to create execution result: %w", err) - } - return nil -} - -// GetExecutionByID retrieves an execution result by its ID with preloaded associations -func GetExecutionByID(db *gorm.DB, id int) (*database.Execution, error) { - var result database.Execution - if err := db. - Preload("AlgorithmVersion.Container"). - Preload("Datapack.Benchmark.Container"). - Preload("Datapack.Pedestal.Container"). - Preload("DatasetVersion"). - Preload("Task.Trace.Project"). - Where("id = ? AND status != ?", id, consts.CommonDeleted). - First(&result).Error; err != nil { - return nil, fmt.Errorf("failed to find execution result with id %d: %w", id, err) - } - return &result, nil -} - -// ListExecutions lists executions based on filters and pagination -func ListExecutions(db *gorm.DB, limit, offset int, event *consts.ExecutionState, status *consts.StatusType, labelConditions []map[string]string) ([]database.Execution, int64, error) { - var executions []database.Execution - var total int64 - - query := db.Model(&database.Execution{}). - Preload("AlgorithmVersion.Container"). - Preload("Datapack.Benchmark.Container"). - Preload("Datapack.Pedestal.Container"). - Preload("DatasetVersion"). - Preload("Task.Trace.Project") - if event != nil { - query = query.Where("event = ?", *event) - } - if status != nil { - query = query.Where("status = ?", *status) - } - - if len(labelConditions) > 0 { - for _, condition := range labelConditions { - subQuery := db.Table("execution_injection_labels eil"). - Select("eil.execution_id"). - Joins("JOIN labels ON labels.id = eil.label_id"). - Where("labels.label_key = ? AND labels.label_value = ?", condition["key"], condition["value"]) - - query = query.Where("executions.id IN (?)", subQuery) - } - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count executions: %w", err) - } - - if err := query.Limit(limit).Offset(offset).Order("updated_at DESC").Find(&executions).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list executions: %w", err) - } - - return executions, total, nil -} - -func ListExecutionsByDatapackIDs(db *gorm.DB, datapackIDs []int) ([]database.Execution, error) { - if len(datapackIDs) == 0 { - return make([]database.Execution, 0), nil - } - - var results []database.Execution - - query := db. - Preload("AlgorithmVersion.Container"). - Preload("Datapack.Benchmark.Container"). - Preload("Datapack.Pedestal.Container"). - Preload("DatasetVersion"). - Preload("Task.Trace.Project"). - Where("datapack_id IN (?) AND status != ?", datapackIDs, consts.CommonDeleted) - if err := query.Find(&results).Error; err != nil { - return nil, fmt.Errorf("failed to list executions by datapack IDs: %w", err) - } - - return results, nil -} - -// UpdateExecution updates fields of an execution record -func UpdateExecution(db *gorm.DB, id int, updates map[string]any) error { - result := db.Model(&database.Execution{}). - Where("id = ? AND status != ?", id, consts.CommonDeleted). - Updates(updates) - if err := result.Error; err != nil { - return result.Error - } - if result.RowsAffected == 0 { - return fmt.Errorf("execution not found or no changes made") - } - return nil -} - -// ===================================================================== -// ExecutionLabel Repository Functions -// ===================================================================== - -// AddExecutionLabels adds multiple execution-label associations -func AddExecutionLabels(db *gorm.DB, executionID int, labelIDs []int) error { - if len(labelIDs) == 0 { - return nil - } - - // Create ExecutionInjectionLabel associations - executionLabels := make([]database.ExecutionInjectionLabel, 0, len(labelIDs)) - for _, labelID := range labelIDs { - executionLabels = append(executionLabels, database.ExecutionInjectionLabel{ - ExecutionID: executionID, - LabelID: labelID, - }) - } - - if err := db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "execution_id"}, {Name: "label_id"}}, - DoNothing: true, - }).Create(&executionLabels).Error; err != nil { - return fmt.Errorf("failed to add execution-label associatons: %w", err) - } - - return nil -} - -// ClearExecutionLabels removes label associations from specified executions -func ClearExecutionLabels(db *gorm.DB, executionIDs []int, labelIDs []int) error { - if len(executionIDs) == 0 { - return nil - } - - query := db.Table("execution_injection_labels"). - Where("execution_id IN (?)", executionIDs) - if len(labelIDs) > 0 { - query = query.Where("label_id IN (?)", labelIDs) - } - - if err := query.Delete(nil).Error; err != nil { - return fmt.Errorf("failed to clear execution labels: %w", err) - } - return nil -} - -// RemoveLabelsFromExecution removes all label associations from a specific execution -func RemoveLabelsFromExecution(db *gorm.DB, executionID int) error { - if err := db.Where("execution_id = ?", executionID). - Delete(&database.ExecutionInjectionLabel{}).Error; err != nil { - return fmt.Errorf("failed to remove all labels from execution %d: %w", executionID, err) - } - return nil -} - -// RemoveLabelsFromExecutions removes all label associations from multiple executions -func RemoveLabelsFromExecutions(db *gorm.DB, executionIDs []int) error { - if len(executionIDs) == 0 { - return nil - } - - if err := db.Where("execution_id IN (?)", executionIDs). - Delete(&database.ExecutionInjectionLabel{}).Error; err != nil { - return fmt.Errorf("failed to remove all labels from executions %v: %w", executionIDs, err) - } - return nil -} - -// RemoveExecutionsFromLabel deletes all execution-label associations for a specific label -func RemoveExecutionsFromLabel(db *gorm.DB, labelID int) (int64, error) { - result := db.Where("label_id = ?", labelID). - Delete(&database.ExecutionInjectionLabel{}) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to delete execution-label associations for label %d: %w", labelID, err) - } - - return result.RowsAffected, nil -} - -// RemoveExecutionsFromLabels removes all execution-label associations for multiple labels -func RemoveExecutionsFromLabels(db *gorm.DB, labelIDs []int) (int64, error) { - if len(labelIDs) == 0 { - return 0, nil - } - - result := db.Where("label_id IN (?)", labelIDs). - Delete(&database.ExecutionInjectionLabel{}) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to delete execution-label associations for labels %v: %w", labelIDs, err) - } - - return result.RowsAffected, nil -} - -// ListExecutionsByDatapackFilter lists executions for a specific algorithm version and datapack name, with optional label filtering -func ListExecutionsByDatapackFilter(db *gorm.DB, algorithmVersionID int, datapackName string, labelConditions []map[string]string) ([]database.Execution, error) { - var executions []database.Execution - - query := db.Model(&database.Execution{}). - Preload("DetectorResults"). - Preload("GranularityResults"). - Preload("AlgorithmVersion.Container"). - Preload("Datapack"). - Joins("JOIN fault_injections fi ON executions.datapack_id = fi.id"). - Where("executions.algorithm_version_id = ? AND fi.name = ? AND executions.status != ?", - algorithmVersionID, datapackName, consts.CommonDeleted) - - if len(labelConditions) > 0 { - query = query. - Joins("JOIN execution_injection_labels eil ON eil.execution_id = executions.id"). - Joins("JOIN labels l ON l.id = eil.label_id") - - var whereConditions *gorm.DB - for _, condition := range labelConditions { - if whereConditions == nil { - whereConditions = db.Where("l.label_key = ? AND l.label_value = ?", condition["key"], condition["value"]) - } else { - whereConditions = whereConditions.Or("l.label_key = ? AND l.label_value = ?", condition["key"], condition["value"]) - } - } - - if whereConditions != nil { - query = query.Where(whereConditions) - } - - query = query. - Group("executions.id"). - Having("COUNT(executions.id) = ?", len(labelConditions)) - } - - if err := query.Order("executions.updated_at DESC").Find(&executions).Error; err != nil { - return nil, fmt.Errorf("failed to list executions for algorithm %d and datapack %s: %w", - algorithmVersionID, datapackName, err) - } - - return executions, nil -} - -// ListExecutionsByDatasetFilter lists executions for a specific algorithm version and dataset version, with optional label filtering -func ListExecutionsByDatasetFilter(db *gorm.DB, algorithmVersionID, datasetVersionID int, labelConditions []map[string]string) ([]database.Execution, error) { - var executions []database.Execution - - query := db.Model(&database.Execution{}). - Preload("DetectorResults"). - Preload("GranularityResults"). - Preload("AlgorithmVersion.Container"). - Preload("Datapack"). - Preload("DatasetVersion"). - Preload("DatasetVersion.Injections"). - Where("executions.algorithm_version_id = ? AND executions.dataset_version_id = ? AND executions.status != ?", - algorithmVersionID, datasetVersionID, consts.CommonDeleted) - - if len(labelConditions) > 0 { - query = query. - Joins("JOIN execution_injection_labels eil ON eil.execution_id = executions.id"). - Joins("JOIN labels l ON l.id = eil.label_id") - - var whereConditions *gorm.DB - for _, condition := range labelConditions { - if whereConditions == nil { - whereConditions = db.Where("l.label_key = ? AND l.label_value = ?", condition["key"], condition["value"]) - } else { - whereConditions = whereConditions.Or("l.label_key = ? AND l.label_value = ?", condition["key"], condition["value"]) - } - } - - if whereConditions != nil { - query = query.Where(whereConditions) - } - - query = query. - Group("executions.id"). - Having("COUNT(executions.id) = ?", len(labelConditions)) - } - - if err := query.Order("executions.updated_at DESC").Find(&executions).Error; err != nil { - return nil, fmt.Errorf("failed to list executions for algorithm %d and dataset version %d: %w", - algorithmVersionID, datasetVersionID, err) - } - - return executions, nil -} - -// ListExecutionIDsByLabels gets execution IDs associated with all specified labels -func ListExecutionIDsByLabels(db *gorm.DB, labelConditions []map[string]string) ([]int, error) { - var executionIDs []int - query := db.Model(&database.Execution{}). - Select("DISTINCT executions.id"). - Joins("JOIN execution_injection_labels eil ON eil.execution_id = executions.id"). - Joins("JOIN labels ON labels.id = eil.label_id"). - Where("executions.status != ?", consts.CommonDeleted) - - var whereClauses []string - var whereArgs []any - - for _, condition := range labelConditions { - whereClauses = append(whereClauses, "(labels.label_key = ? AND labels.label_value = ?)") - whereArgs = append(whereArgs, condition["key"], condition["value"]) - } - - if len(whereClauses) > 0 { - whereClause := strings.Join(whereClauses, " OR ") - query = query.Where(whereClause, whereArgs...) - } - - if err := query.Pluck("executions.id", &executionIDs).Error; err != nil { - return nil, fmt.Errorf("failed to list execution IDs by labels: %w", err) - } - - return executionIDs, nil -} - -// ListExecutionLabels gets labels for multiple executions in batch -func ListExecutionLabels(db *gorm.DB, executionIDs []int) (map[int][]database.Label, error) { - if len(executionIDs) == 0 { - return nil, nil - } - - type executionLabelResult struct { - database.Label - executionID int `gorm:"column:execution_id"` - } - - var flatResults []executionLabelResult - if err := db.Model(&database.Label{}). - Joins("JOIN execution_injection_labels eil ON eil.label_id = labels.id"). - Where("eil.execution_id IN (?)", executionIDs). - Select("labels.*, eil.execution_id"). - Find(&flatResults).Error; err != nil { - return nil, fmt.Errorf("failed to batch query execution labels: %w", err) - } - - labelsMap := make(map[int][]database.Label) - for _, id := range executionIDs { - labelsMap[id] = []database.Label{} - } - - for _, res := range flatResults { - label := res.Label - labelsMap[res.executionID] = append(labelsMap[res.executionID], label) - } - - return labelsMap, nil -} - -// ListExecutionLabelCounts retrieves the count of executions associated with each label ID -func ListExecutionLabelCounts(db *gorm.DB, labelIDs []int) (map[int]int64, error) { - if len(labelIDs) == 0 { - return make(map[int]int64), nil - } - - type executionLabelResult struct { - labelID int `gorm:"column:label_id"` - count int64 - } - - var results []executionLabelResult - if err := db.Table("execution_injection_labels eil"). - Select("eil.label_id, count(DISTINCT eil.execution_id) as count"). - Where("eil.label_id IN (?)", labelIDs). - Group("eil.label_id"). - Find(&results).Error; err != nil { - return nil, fmt.Errorf("failed to count execution-label associations: %w", err) - } - - countMap := make(map[int]int64, len(results)) - for _, result := range results { - countMap[result.labelID] = result.count - } - - return countMap, nil -} - -// ListLabelsByExecutionID retrieves all labels associated with a specific execution -func ListLabelsByExecutionID(db *gorm.DB, executionID int) ([]database.Label, error) { - var labels []database.Label - if err := db.Table("labels"). - Joins("JOIN execution_injection_labels eil ON labels.id = eil.label_id"). - Where("eil.execution_id = ?", executionID). - Find(&labels).Error; err != nil { - return nil, fmt.Errorf("failed to get execution labels: %v", err) - } - return labels, nil -} - -// ListLabelIDsByKeyAndExecutionID retrieves label IDs for a specific execution based on label keys -func ListLabelIDsByKeyAndExecutionID(db *gorm.DB, executionID int, keys []string) ([]int, error) { - var labelIDs []int - - err := db.Table("labels l"). - Select("l.id"). - Joins("JOIN execution_injection_labels eil ON eil.label_id = l.id"). - Where("eil.execution_id = ? AND l.label_key IN (?)", executionID, keys). - Pluck("l.id", &labelIDs).Error - if err != nil { - return nil, fmt.Errorf("failed to find label IDs by key '%s': %w", keys, err) - } - - return labelIDs, nil -} - -// GetExecutionStatistics returns statistics about executions -func GetExecutionStatistics() (map[string]int64, error) { - stats := make(map[string]int64) - - // Total executions - var total int64 - if err := database.DB.Model(&database.Execution{}).Count(&total).Error; err != nil { - return nil, fmt.Errorf("failed to count total executions: %w", err) - } - stats["total"] = total - - // Executions by status - type StatusCount struct { - Status string `json:"status"` - Count int64 `json:"count"` - } - - var statusCounts []StatusCount - err := database.DB.Model(&database.Execution{}). - Select("status, COUNT(*) as count"). - Group("status"). - Find(&statusCounts).Error - - if err != nil { - return nil, fmt.Errorf("failed to count executions by status: %w", err) - } - - // Set status counts - for _, sc := range statusCounts { - switch sc.Status { - case "pending": - stats["pending"] = sc.Count - case "running": - stats["running"] = sc.Count - case "completed": - stats["completed"] = sc.Count - case "failed": - stats["failed"] = sc.Count - case "cancelled": - stats["cancelled"] = sc.Count - default: - stats[sc.Status] = sc.Count - } - } - - // Initialize missing statuses with 0 - statuses := []string{"pending", "running", "completed", "failed", "cancelled"} - for _, status := range statuses { - if _, exists := stats[status]; !exists { - stats[status] = 0 - } - } - - return stats, nil -} - -// ListExecutionsByProjectID retrieves executions for a specific project with pagination -func ListExecutionsByProjectID(db *gorm.DB, projectID int, limit, offset int) ([]database.Execution, int64, error) { - var executions []database.Execution - var total int64 - - // Base query with JOIN and WHERE conditions - baseQuery := db.Model(&database.Execution{}). - Joins("JOIN tasks ON tasks.id = executions.task_id"). - Joins("JOIN traces on traces.id = tasks.trace_id"). - Where("traces.project_id = ? AND executions.status != ?", projectID, consts.CommonDeleted) - - // Count without Preload - if err := baseQuery.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count executions for project %d: %w", projectID, err) - } - - // Find with Preload - if err := baseQuery. - Preload("AlgorithmVersion.Container"). - Preload("Datapack.Benchmark.Container"). - Preload("Datapack.Pedestal.Container"). - Preload("DatasetVersion"). - Limit(limit). - Offset(offset). - Order("executions.updated_at DESC"). - Find(&executions).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list executions for project %d: %w", projectID, err) - } - - return executions, total, nil -} diff --git a/src/repository/granularity.go b/src/repository/granularity.go deleted file mode 100644 index 7a3bf779..00000000 --- a/src/repository/granularity.go +++ /dev/null @@ -1,42 +0,0 @@ -package repository - -import ( - "errors" - "fmt" - - "aegis/consts" - "aegis/database" - - "gorm.io/gorm" -) - -// ListGranularityResultsByExecutionID lists granularity results for a specific execution ID -func ListGranularityResultsByExecutionID(db *gorm.DB, executionID int) ([]database.GranularityResult, error) { - var results []database.GranularityResult - if err := db. - Where("execution_id = ?", executionID). - Find(&results).Error; err != nil { - return nil, fmt.Errorf("failed to list granularity results for execution %d: %w", executionID, err) - } - return results, nil -} - -// SaveGranularityResults saves multiple granularity results -func SaveGranularityResults(db *gorm.DB, results []database.GranularityResult) error { - if len(results) == 0 { - return fmt.Errorf("no granularity results to create") - } - - for i := range results { - resultPtr := &results[i] - err := db.Omit(containerVersionOmitFields).Create(resultPtr).Error - if err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return fmt.Errorf("%w: index %d", consts.ErrAlreadyExists, i) - } - return fmt.Errorf("failed to create record index %d: %w", i, err) - } - } - - return nil -} diff --git a/src/repository/injection.go b/src/repository/injection.go deleted file mode 100644 index b2f9a518..00000000 --- a/src/repository/injection.go +++ /dev/null @@ -1,580 +0,0 @@ -package repository - -import ( - "encoding/json" - "fmt" - "strings" - "time" - - "aegis/consts" - "aegis/database" - "aegis/dto" - - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -// ===================================================================== -// Injection Repository Functions -// ===================================================================== - -// BatchDelteInjections marks multiple injections as deleted in batch -func BatchDeleteInjections(db *gorm.DB, injectionIDs []int) error { - if len(injectionIDs) == 0 { - return nil - } - - if err := db.Model(&database.FaultInjection{}). - Where("id IN (?) AND status != ?", injectionIDs, consts.CommonDeleted). - Update("status", consts.CommonDeleted).Error; err != nil { - return fmt.Errorf("failed to batch delete injections: %w", err) - } - - return nil -} - -// CreateInjection creates a fault injection record -func CreateInjection(db *gorm.DB, injection *database.FaultInjection) error { - if err := db.Omit(commonOmitFields).Create(injection).Error; err != nil { - return fmt.Errorf("failed to create injection: %w", err) - } - return nil -} - -// GetInjectionByID gets injection by ID with preloaded associations -func GetInjectionByID(db *gorm.DB, id int) (*database.FaultInjection, error) { - var injection database.FaultInjection - if err := db. - Preload("Task"). - Preload("Task.Trace"). - Preload("Benchmark.Container"). - Preload("Pedestal.Container"). - Where("id = ?", id).First(&injection).Error; err != nil { - return nil, fmt.Errorf("failed to find injection with id %d: %w", id, err) - } - return &injection, nil -} - -// GetInjectionByName gets injection by name with preloaded associations -func GetInjectionByName(db *gorm.DB, name string, includeLabels bool) (*database.FaultInjection, error) { - query := db - if includeLabels { - query = query.Preload("Labels") - } - - var injection database.FaultInjection - if err := query. - Where("name = ? AND status != ?", name, consts.CommonDeleted).First(&injection).Error; err != nil { - return nil, fmt.Errorf("failed to find injection with name %s: %w", name, err) - } - return &injection, nil -} - -// ListFaultInjectionsByID retrieves multiple fault injections by their IDs with preloaded associations -func ListFaultInjectionsByID(db *gorm.DB, injectionIDs []int) ([]database.FaultInjection, error) { - if len(injectionIDs) == 0 { - return []database.FaultInjection{}, nil - } - - var injections []database.FaultInjection - if err := db. - Preload("Benchmark.Container"). - Preload("Pedestal.Container"). - Preload("Task.Trace.Project"). - Preload("Labels"). - Where("id IN (?) AND status != ?", injectionIDs, consts.CommonDeleted). - Find(&injections).Error; err != nil { - return nil, fmt.Errorf("failed to query fault injections: %w", err) - } - return injections, nil -} - -// ListExistingEngineConfigs lists engine_config strings that already exist in DB and are considered completed builds. -// This is used to de-duplicate incoming injection requests by their engine configuration. -// Excludes records that have the "invalid" label. -func ListExistingEngineConfigs(db *gorm.DB, configs []string) ([]string, error) { - if len(configs) == 0 { - return []string{}, nil - } - - query := db. - Model(&database.FaultInjection{}). - Select("engine_config"). - Where("engine_config in (?) AND state >= ? AND status = ?", configs, consts.DatapackInjectSuccess, consts.CommonEnabled) - - invalidLabelSubQuery := db.Table("fault_injection_labels fil"). - Select("fil.fault_injection_id"). - Joins("JOIN labels ON labels.id = fil.label_id"). - Where("labels.label_key = ? AND labels.label_value = ?", consts.LabelKeyTag, "invalid") - - query = query.Where("fault_injections.id NOT IN (?)", invalidLabelSubQuery) - - var existingEngineConfigs []string - if err := query.Pluck("engine_config", &existingEngineConfigs).Error; err != nil { - return nil, err - } - - return existingEngineConfigs, nil -} - -// ListEngineConfigByNames retrieves engine configurations by injection names -func ListEngineConfigByNames(db *gorm.DB, names []string) (map[string]string, error) { - var records []struct { - Name string `gorm:"column:name"` - EngineConfig string `gorm:"column:engine_config"` - } - - if err := database.DB. - Model(&database.FaultInjection{}). - Select("name, engine_config"). - Where("name IN (?)", names). - Find(&records).Error; err != nil { - return nil, fmt.Errorf("failed to query engine configs: %v", err) - } - - result := make(map[string]string, len(records)) - for _, record := range records { - result[record.Name] = record.EngineConfig - } - - return result, nil -} - -// ListInjections lists fault injections based on filter options with preloaded associations -func ListInjections(db *gorm.DB, limit, offset int, filterOptions *dto.ListInjectionFilters) ([]database.FaultInjection, int64, error) { - var injections []database.FaultInjection - var total int64 - - query := db.Model(&database.FaultInjection{}). - Preload("Benchmark.Container"). - Preload("Pedestal.Container"). - Preload("Task.Trace.Project"). - Preload("Labels") - if filterOptions.FaultType != nil { - query = query.Where("fault_type = ?", *filterOptions.FaultType) - } - if filterOptions.Category != nil { - query = query.Where("category = ?", *filterOptions.Category) - } - if filterOptions.Benchmark != "" { - query = query.Where("benchmark = ?", filterOptions.Benchmark) - } - if filterOptions.State != nil { - query = query.Where("state = ?", *filterOptions.State) - } - if filterOptions.Status != nil { - query = query.Where("status = ?", *filterOptions.Status) - } - - if len(filterOptions.LabelConditions) > 0 { - for _, condition := range filterOptions.LabelConditions { - subQuery := db.Table("fault_injection_labels fil"). - Select("fil.fault_injection_id"). - Joins("JOIN labels ON labels.id = fil.label_id"). - Where("labels.label_key = ? AND labels.label_value = ?", condition["key"], condition["value"]) - - query = query.Where("fault_injections.id IN (?)", subQuery) - } - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count injections: %w", err) - } - - if err := query.Limit(limit).Offset(offset).Order("updated_at DESC").Find(&injections).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list injections: %w", err) - } - - return injections, total, nil -} - -// ListInjectionIDsByNames retrieves injection IDs by their names -func ListInjectionIDsByNames(db *gorm.DB, names []string) (map[string]int, error) { - if len(names) == 0 { - return map[string]int{}, nil - } - - var records []struct { - Name string `gorm:"column:name"` - ID int `gorm:"column:id"` - } - - if err := db.Model(&database.FaultInjection{}). - Select("name, id"). - Where("state = ? AND status = ?", consts.DatapackBuildSuccess, consts.CommonEnabled). - Where("name IN (?)", names). - Find(&records).Error; err != nil { - return nil, fmt.Errorf("failed to query injection IDs: %w", err) - } - - result := make(map[string]int, len(records)) - for _, record := range records { - result[record.Name] = record.ID - } - - return result, nil -} - -// UpdateGroundtruth updates ground truth and its source for an injection -func UpdateGroundtruth(db *gorm.DB, id int, groundtruths []database.Groundtruth, source string) error { - gtJSON, err := json.Marshal(groundtruths) - if err != nil { - return fmt.Errorf("failed to marshal groundtruths: %w", err) - } - result := db.Model(&database.FaultInjection{}). - Where("id = ? AND status != ?", id, consts.CommonDeleted). - Updates(map[string]interface{}{ - "groundtruths": string(gtJSON), - "groundtruth_source": source, - }) - if result.Error != nil { - return fmt.Errorf("failed to update groundtruth for injection %d: %w", id, result.Error) - } - if result.RowsAffected == 0 { - return fmt.Errorf("injection with id %d: %w", id, consts.ErrNotFound) - } - return nil -} - -// UpdateInjection updates fields of a fault injection record -func UpdateInjection(db *gorm.DB, id int, updates map[string]any) error { - result := db.Model(&database.FaultInjection{}). - Where("id = ? AND status != ?", id, consts.CommonDeleted). - Updates(updates) - if err := result.Error; err != nil { - return result.Error - } - if result.RowsAffected == 0 { - return fmt.Errorf("injection not found or no changes made") - } - return nil -} - -// ListInjectionsNoIssues lists fault injections without issues based on label conditions and time range -func ListInjectionsNoIssues(db *gorm.DB, labelConditions []map[string]string, startTime, endTime *time.Time, projectID *int) ([]database.FaultInjectionNoIssues, error) { - query := db.Model(&database.FaultInjectionNoIssues{}).Scopes(database.Sort("dataset_id desc")) - if startTime != nil { - query = query.Where("created_at >= ?", *startTime) - } - if endTime != nil { - query = query.Where("created_at <= ?", *endTime) - } - - // Filter by project_id if provided - if projectID != nil { - query = query.Where("project_id = ?", *projectID) - } - - if len(labelConditions) > 0 { - var whereConditions *gorm.DB - for _, condition := range labelConditions { - if whereConditions == nil { - whereConditions = db.Where("label_key = ? AND label_value = ?", condition["key"], condition["value"]) - } else { - whereConditions = whereConditions.Or("label_key = ? AND label_value = ?", condition["key"], condition["value"]) - } - } - - if whereConditions != nil { - query = query.Where(whereConditions) - } - - query = query. - Group("id"). - Having("COUNT(id) = ?", len(labelConditions)) - } - - var records []database.FaultInjectionNoIssues - if err := query.Find(&records).Error; err != nil { - return nil, fmt.Errorf("failed to query fault injections without issues: %v", err) - } - - return records, nil -} - -// ListInjectionsWithIssues lists fault injections with issues based on label conditions and time range -func ListInjectionsWithIssues(db *gorm.DB, labelConditions []map[string]string, startTime, endTime *time.Time, projectID *int) ([]database.FaultInjectionWithIssues, error) { - query := db.Model(&database.FaultInjectionNoIssues{}).Scopes(database.Sort("dataset_id desc")) - if startTime != nil { - query = query.Where("created_at >= ?", *startTime) - } - if endTime != nil { - query = query.Where("created_at <= ?", *endTime) - } - - // Filter by project_id if provided - if projectID != nil { - query = query.Where("project_id = ?", *projectID) - } - - if len(labelConditions) > 0 { - var whereConditions *gorm.DB - for _, condition := range labelConditions { - if whereConditions == nil { - whereConditions = db.Where("label_key = ? AND label_value = ?", condition["key"], condition["value"]) - } else { - whereConditions = whereConditions.Or("label_key = ? AND label_value = ?", condition["key"], condition["value"]) - } - } - - if whereConditions != nil { - query = query.Where(whereConditions) - } - - query = query. - Group("id"). - Having("COUNT(id) = ?", len(labelConditions)) - } - - var records []database.FaultInjectionWithIssues - if err := query.Find(&records).Error; err != nil { - return nil, fmt.Errorf("failed to query fault injections without issues: %v", err) - } - - return records, nil -} - -// ===================================================================== -// InjectionLabel Repository Functions -// ===================================================================== - -// Business layer: Injection labels are stored as FaultInjectionLabel in database - -// AddInjectionLabels adds multiple injection-label associations via FaultInjectionLabel -func AddInjectionLabels(db *gorm.DB, injectionID int, labelIDs []int) error { - if len(labelIDs) == 0 { - return nil - } - - // Create FaultInjectionLabel associations - injectionLabels := make([]database.FaultInjectionLabel, 0, len(labelIDs)) - for _, labelID := range labelIDs { - injectionLabels = append(injectionLabels, database.FaultInjectionLabel{ - FaultInjectionID: injectionID, - LabelID: labelID, - }) - } - - if err := db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "fault_injection_id"}, {Name: "label_id"}}, - DoNothing: true, - }).Create(&injectionLabels).Error; err != nil { - return fmt.Errorf("failed to add injection-label associations: %w", err) - } - - return nil -} - -// ClearInjectionLabels removes label associations from specified fault injections via FaultInjectionLabel -func ClearInjectionLabels(db *gorm.DB, injectionIDs []int, labelIDs []int) error { - if len(injectionIDs) == 0 { - return nil - } - - query := db.Table("fault_injection_labels"). - Where("fault_injection_id IN (?)", injectionIDs) - if len(labelIDs) > 0 { - query = query.Where("label_id IN (?)", labelIDs) - } - - if err := query.Delete(&database.FaultInjectionLabel{}).Error; err != nil { - return fmt.Errorf("failed to clear injection labels: %w", err) - } - return nil -} - -// RemoveInjectionsFromLabel removes all injection-label associations for a specific label -func RemoveInjectionsFromLabel(db *gorm.DB, labelID int) (int64, error) { - result := db.Where("label_id = ?", labelID). - Delete(&database.FaultInjectionLabel{}) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to remove injection-label associations for label %d: %w", labelID, err) - } - - return result.RowsAffected, nil -} - -// RemoveInjectionsFromLabels removes all injection-label associations for multiple labels -func RemoveInjectionsFromLabels(db *gorm.DB, labelIDs []int) (int64, error) { - if len(labelIDs) == 0 { - return 0, nil - } - - result := db.Where("label_id IN (?)", labelIDs). - Delete(&database.FaultInjectionLabel{}) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to remove injection-label associations for labels %v: %w", labelIDs, err) - } - - return result.RowsAffected, nil -} - -// RemoveLabelsFromInjection removes all label associations from a specific injection -func RemoveLabelsFromInjection(db *gorm.DB, injectionID int) error { - if err := db.Where("fault_injection_id = ?", injectionID). - Delete(&database.FaultInjectionLabel{}).Error; err != nil { - return fmt.Errorf("failed to remove all labels from injection %d: %w", injectionID, err) - } - return nil -} - -// RemoveLabelsFromInjections removes all label associations from multiple injections -func RemoveLabelsFromInjections(db *gorm.DB, injectionIDs []int) error { - if len(injectionIDs) == 0 { - return nil - } - - if err := db.Where("fault_injection_id IN (?)", injectionIDs). - Delete(&database.FaultInjectionLabel{}).Error; err != nil { - return fmt.Errorf("failed to remove all labels from injections %v: %w", injectionIDs, err) - } - return nil -} - -// ListInjectionIDsByLabels gets injection IDs associated with all specified labels -func ListInjectionIDsByLabels(db *gorm.DB, labelConditions []map[string]string) ([]int, error) { - var injectionIDs []int - query := db.Model(&database.FaultInjection{}). - Select("DISTINCT fault_injections.id"). - Joins("JOIN fault_injection_labels fil ON fil.fault_injection_id = fault_injections.id"). - Joins("JOIN labels ON labels.id = fil.label_id"). - Where("fault_injections.status != ?", consts.CommonDeleted) - - var whereClauses []string - var whereArgs []any - - for _, condition := range labelConditions { - whereClauses = append(whereClauses, "(labels.label_key = ? AND labels.label_value = ?)") - whereArgs = append(whereArgs, condition["key"], condition["value"]) - } - - if len(whereClauses) > 0 { - whereClause := strings.Join(whereClauses, " OR ") - query = query.Where(whereClause, whereArgs...) - } - - if err := query.Pluck("fault_injections.id", &injectionIDs).Error; err != nil { - return nil, fmt.Errorf("failed to list injection IDs by labels: %v", err) - } - - return injectionIDs, nil -} - -// ListInjectionLabels gets labels for multiple injections in batch -func ListInjectionLabels(db *gorm.DB, injectionIDs []int) (map[int][]database.Label, error) { - if len(injectionIDs) == 0 { - return nil, nil - } - - type injectionLabelResult struct { - database.Label - InjectionID int `gorm:"column:injection_id"` - } - - var flatResults []injectionLabelResult - if err := db.Model(&database.Label{}). - Joins("JOIN fault_injection_labels fil ON fil.label_id = labels.id"). - Where("fil.fault_injection_id IN (?)", injectionIDs). - Select("labels.*, fil.fault_injection_id as injection_id"). - Find(&flatResults).Error; err != nil { - return nil, fmt.Errorf("failed to batch query fault injection labels: %w", err) - } - - labelsMap := make(map[int][]database.Label) - for _, id := range injectionIDs { - labelsMap[id] = []database.Label{} - } - - for _, res := range flatResults { - label := res.Label - labelsMap[res.InjectionID] = append(labelsMap[res.InjectionID], label) - } - - return labelsMap, nil -} - -// ListInjectionLabelCounts retrieves the count of injections associated with each label ID -func ListInjectionLabelCounts(db *gorm.DB, labelIDs []int) (map[int]int64, error) { - if len(labelIDs) == 0 { - return make(map[int]int64), nil - } - - type injectionLabelResult struct { - labelID int `gorm:"column:label_id"` - count int64 - } - - var results []injectionLabelResult - if err := db.Table("fault_injection_labels fil"). - Select("fil.label_id, count(DISTINCT fil.fault_injection_id) as count"). - Where("fil.label_id IN (?)", labelIDs). - Group("fil.label_id"). - Find(&results).Error; err != nil { - return nil, fmt.Errorf("failed to count injection-label associations: %w", err) - } - - countMap := make(map[int]int64, len(results)) - for _, result := range results { - countMap[result.labelID] = result.count - } - - return countMap, nil -} - -// ListInjectionLabelsByInjectionID gets labels for a specific injection -func ListLabelsByInjectionID(db *gorm.DB, injectionID int) ([]database.Label, error) { - var labels []database.Label - if err := db.Table("labels"). - Joins("JOIN fault_injection_labels fil ON labels.id = fil.label_id"). - Where("fil.fault_injection_id = ?", injectionID). - Find(&labels).Error; err != nil { - return nil, fmt.Errorf("failed to get injection labels: %v", err) - } - return labels, nil -} - -// ListLabelIDsByKeyAndInjectionID finds label IDs by keys associated with a specific injection via TaskLabel -func ListLabelIDsByKeyAndInjectionID(db *gorm.DB, injectionID int, keys []string) ([]int, error) { - var labelIDs []int - - err := db.Table("labels l"). - Select("l.id"). - Joins("JOIN fault_injection_labels fil ON fil.label_id = l.id"). - Where("fil.fault_injection_id = ? AND l.label_key IN (?)", injectionID, keys). - Pluck("l.id", &labelIDs).Error - if err != nil { - return nil, fmt.Errorf("failed to find label IDs by key '%s': %w", keys, err) - } - - return labelIDs, nil -} - -// ListInjectionsByProjectID retrieves fault injections for a specific project with pagination -func ListInjectionsByProjectID(db *gorm.DB, projectID int, limit, offset int) ([]database.FaultInjection, int64, error) { - var injections []database.FaultInjection - var total int64 - - // Base query with JOIN and WHERE conditions - baseQuery := db.Model(&database.FaultInjection{}). - Joins("JOIN tasks ON tasks.id = fault_injections.task_id"). - Joins("JOIN traces on traces.id = tasks.trace_id"). - Where("traces.project_id = ? AND fault_injections.status != ?", projectID, consts.CommonDeleted) - - // Count without Preload - if err := baseQuery.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count injections for project %d: %w", projectID, err) - } - - // Find with Preload - if err := baseQuery. - Preload("Benchmark.Container"). - Preload("Pedestal.Container"). - Limit(limit). - Offset(offset). - Order("fault_injections.updated_at DESC"). - Find(&injections).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list injections for project %d: %w", projectID, err) - } - - return injections, total, nil -} diff --git a/src/repository/label.go b/src/repository/label.go deleted file mode 100644 index 12cea053..00000000 --- a/src/repository/label.go +++ /dev/null @@ -1,303 +0,0 @@ -package repository - -import ( - "errors" - "fmt" - - "aegis/consts" - "aegis/database" - "aegis/dto" - - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -const ( - labelKeyOmitFields = "active_key_value" -) - -// ===================================================================== -// Label Repository Functions -// ===================================================================== - -// BatchCreateLabels inserts multiple labels -func BatchCreateLabels(db *gorm.DB, labels []database.Label) error { - if len(labels) == 0 { - return nil - } - - if err := db.Omit(labelKeyOmitFields).Create(&labels).Error; err != nil { - return fmt.Errorf("failed to batch upsert labels: %w", err) - } - - return nil -} - -// BatchDeleteLabels marks multiple labels as deleted in batch -func BatchDeleteLabels(db *gorm.DB, labelIDs []int) error { - if len(labelIDs) == 0 { - return nil - } - - if err := db.Model(&database.Label{}). - Where("id IN (?) AND status != ?", labelIDs, consts.CommonDeleted). - Update("status", consts.CommonDeleted).Error; err != nil { - return fmt.Errorf("failed to batch delete labels: %w", err) - } - return nil -} - -// BatchIncreaseLabelUsages increases the usage counts of multiple labels -func BatchIncreaseLabelUsages(db *gorm.DB, labelIDs []int, increament int) error { - if len(labelIDs) == 0 { - return nil - } - - expr := gorm.Expr("usage_count + ?", increament) - if err := db.Model(&database.Label{}). - Where("id IN (?)", labelIDs). - UpdateColumn("usage_count", expr).Error; err != nil { - return fmt.Errorf("failed to batch increase label usages: %w", err) - } - - return nil -} - -// BatchDecreaseLabelUsages decreases the usage counts of multiple labels -func BatchDecreaseLabelUsages(db *gorm.DB, labelIDs []int, decrement int) error { - if len(labelIDs) == 0 { - return nil - } - - expr := gorm.Expr("GREATEST(0, usage_count - ?)", decrement) - if err := db.Model(&database.Label{}). - Where("id IN (?)", labelIDs). - Clauses(clause.Returning{}). - UpdateColumn("usage_count", expr).Error; err != nil { - return fmt.Errorf("failed to batch decrease label usages: %w", err) - } - return nil -} - -// BatchUpdateLabels updates multiple labels -func BatchUpdateLabels(db *gorm.DB, labels []database.Label) error { - if len(labels) == 0 { - return fmt.Errorf("no labels to update") - } - - if err := db.Omit(labelKeyOmitFields).Save(&labels).Error; err != nil { - return fmt.Errorf("failed to batch update labels: %w", err) - } - - return nil -} - -// CreateLabel creates a label -func CreateLabel(db *gorm.DB, label *database.Label) error { - if err := db.Omit(labelKeyOmitFields).Create(label).Error; err != nil { - return fmt.Errorf("failed to create label: %w", err) - } - return nil -} - -// DeleteLabel soft deletes a label by setting its status to deleted -func DeleteLabel(db *gorm.DB, labelID int) (int64, error) { - result := db.Model(&database.Label{}). - Where("id = ? AND status != ?", labelID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to soft delete project %d: %w", labelID, result.Error) - } - return result.RowsAffected, nil -} - -// GetLabelByID gets label by ID -func GetLabelByID(db *gorm.DB, id int) (*database.Label, error) { - var label database.Label - if err := db.First(&label, id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("label with id %d not found", id) - } - return nil, fmt.Errorf("failed to get label: %w", err) - } - return &label, nil -} - -// GetLabelByKeyAndValue gets label by key and value -func GetLabelByKeyAndValue(db *gorm.DB, key, value string, status ...consts.StatusType) (*database.Label, error) { - query := db.Where("label_key = ? AND label_value = ?", key, value) - - if len(status) == 0 { - query = query.Where("status != ?", consts.CommonDeleted) - } else if len(status) == 1 { - query = query.Where("status = ?", status[0]) - } else { - query = query.Where("status IN (?)", status) - } - - var label database.Label - if err := query.First(&label).Error; err != nil { - return nil, fmt.Errorf("failed to get label: %w", err) - } - - return &label, nil -} - -// ListLabels gets the label list -func ListLabels(db *gorm.DB, limit, offset int, filterOptions *dto.ListLabelFilters) ([]database.Label, int64, error) { - var labels []database.Label - var total int64 - - query := db.Model(&database.Label{}) - if filterOptions.Key != "" { - query = query.Where("label_key = ?", filterOptions.Key) - } - if filterOptions.Value != "" { - query = query.Where("label_value = ?", filterOptions.Value) - } - if filterOptions.Category != nil { - query = query.Where("category = ?", *filterOptions.Category) - } - if filterOptions.IsSystem != nil { - query = query.Where("is_system = ?", *filterOptions.IsSystem) - } - if filterOptions.Status != nil { - query = query.Where("status = ?", *filterOptions.Status) - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count labels: %w", err) - } - - if err := query.Limit(limit).Offset(offset).Order("usage_count DESC, created_at DESC").Find(&labels).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list labels: %w", err) - } - - return labels, total, nil -} - -// ListLabelsByConditions lists labels based on key-value conditions -func ListLabelsByConditions(db *gorm.DB, conditions []map[string]string) ([]database.Label, error) { - if len(conditions) == 0 { - return []database.Label{}, nil - } - - query := db.Model(&database.Label{}).Where("status != ?", consts.CommonDeleted) - orBuilder := db.Where("1 = 0") - - for _, condition := range conditions { - andBuilder := db.Where("1 = 1") - - if key, ok := condition["key"]; ok { - andBuilder = andBuilder.Where("label_key = ?", key) - } - if value, ok := condition["value"]; ok { - andBuilder = andBuilder.Where("label_value = ?", value) - } - - orBuilder = orBuilder.Or(andBuilder) - } - - var labels []database.Label - if err := query.Where(orBuilder).Find(&labels).Error; err != nil { - return nil, fmt.Errorf("failed to list labels by conditions: %w", err) - } - return labels, nil -} - -// ListLabelIDsByConditions lists label IDs based on key-value conditions and category -func ListLabelIDsByConditions(db *gorm.DB, conditions []map[string]string, category consts.LabelCategory) ([]int, error) { - if len(conditions) == 0 { - return []int{}, nil - } - - query := db.Model(&database.Label{}). - Where("status != ? AND category = ?", consts.CommonDeleted, category) - - orBuilder := db.Where("1 = 0") - - for _, condition := range conditions { - andBuilder := db.Where("1 = 1") - - if key, ok := condition["key"]; ok { - andBuilder = andBuilder.Where("label_key = ?", key) - } - if value, ok := condition["value"]; ok { - andBuilder = andBuilder.Where("label_value = ?", value) - } - - orBuilder = orBuilder.Or(andBuilder) - } - - var labelIDs []int - if err := query.Where(orBuilder).Pluck("id", &labelIDs).Error; err != nil { - return nil, fmt.Errorf("failed to list label IDs by conditions: %w", err) - } - return labelIDs, nil -} - -// ListLabelsByID lists labels by their IDs -func ListLabelsByID(db *gorm.DB, labelIDs []int) ([]database.Label, error) { - if len(labelIDs) == 0 { - return []database.Label{}, nil - } - - var labels []database.Label - if err := db. - Where("id IN (?) AND status != ?", labelIDs, consts.CommonDeleted). - Find(&labels).Error; err != nil { - return nil, fmt.Errorf("failed to list labels by IDs: %w", err) - } - return labels, nil -} - -// ListLabelsGroupByCategory lists labels grouped by their categories -func ListLabelsGroupByCategory(db *gorm.DB) (map[consts.LabelCategory][]database.Label, error) { - var labels []database.Label - if err := db. - Where("status != ?", consts.CommonDeleted). - Order("usage_count DESC, created_at DESC"). - Find(&labels).Error; err != nil { - return nil, fmt.Errorf("failed to list labels: %w", err) - } - - groupedLabels := make(map[consts.LabelCategory][]database.Label) - for _, label := range labels { - groupedLabels[label.Category] = append(groupedLabels[label.Category], label) - } - - return groupedLabels, nil -} - -// SearchLabels searches for labels -func SearchLabels(keyword string, category string, limit int) ([]database.Label, error) { - var labels []database.Label - - query := database.DB.Model(&database.Label{}) - - if keyword != "" { - query = query.Where("key ILIKE ? OR value ILIKE ? OR description ILIKE ?", - "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") - } - - if category != "" { - query = query.Where("category = ?", category) - } - - if limit > 0 { - query = query.Limit(limit) - } - - if err := query.Order("usage_count DESC, created_at DESC").Find(&labels).Error; err != nil { - return nil, fmt.Errorf("failed to search labels: %w", err) - } - - return labels, nil -} - -func UpdateLabel(db *gorm.DB, label *database.Label) error { - if err := db.Omit(labelKeyOmitFields).Save(label).Error; err != nil { - return fmt.Errorf("failed to update label: %w", err) - } - return nil -} diff --git a/src/repository/permission.go b/src/repository/permission.go deleted file mode 100644 index ee7fe0ba..00000000 --- a/src/repository/permission.go +++ /dev/null @@ -1,329 +0,0 @@ -package repository - -import ( - "fmt" - "time" - - "aegis/consts" - "aegis/database" - "aegis/dto" - - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -// BatchUpsertPermissions performs batch upsert of permissions -func BatchUpsertPermissions(db *gorm.DB, perimissons []database.Permission) error { - if len(perimissons) == 0 { - return fmt.Errorf("no permissions to upsert") - } - - if err := db.Omit(commonOmitFields).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "name"}}, - DoUpdates: clause.AssignmentColumns([]string{}), - }).Create(&perimissons).Error; err != nil { - return fmt.Errorf("failed to batch upsert permissions: %v", err) - } - - return nil -} - -// CreatePermission creates a permission -func CreatePermission(db *gorm.DB, permission *database.Permission) error { - if err := db.Omit(commonOmitFields).Create(permission).Error; err != nil { - return fmt.Errorf("failed to create permission: %w", err) - } - return nil -} - -// DeletePermission soft deletes a permission by setting its status to deleted -func DeletePermission(db *gorm.DB, permissionID int) (int64, error) { - result := db.Model(&database.Permission{}). - Where("id = ? AND status != ?", permissionID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete permission %d: %w", permissionID, result.Error) - } - return result.RowsAffected, nil -} - -// GetPermissionByID gets permission by ID -func GetPermissionByID(db *gorm.DB, id int) (*database.Permission, error) { - var permission database.Permission - if err := db.Preload("Resource").Where("id = ? and status != ?", id, consts.CommonDeleted).First(&permission).Error; err != nil { - return nil, fmt.Errorf("failed to find permission with id %d: %w", id, err) - } - return &permission, nil -} - -// GetPermissionByName gets permission by name -func GetPermissionByName(db *gorm.DB, name string) (*database.Permission, error) { - var permission database.Permission - if err := db.Preload("Resource").Where("name = ? and status != ?", name, consts.CommonDeleted).First(&permission).Error; err != nil { - return nil, fmt.Errorf("failed to find permission with name %s: %w", name, err) - } - return &permission, nil -} - -// GetPermissionsByAction gets permissions by action -func GetPermissionsByAction(db *gorm.DB, action string) ([]database.Permission, error) { - var permissions []database.Permission - if err := db.Preload("Resource"). - Where("action = ? AND status = ?", action, consts.CommonEnabled). - Order("name"). - Find(&permissions).Error; err != nil { - return nil, fmt.Errorf("failed to get permissions by action: %v", err) - } - return permissions, nil -} - -// GetPermissionByActionAndResource gets permission by action and resource name -func GetPermissionByActionAndResource(db *gorm.DB, action consts.ActionName, scope consts.ResourceScope, resourceName consts.ResourceName) (*database.Permission, error) { - var permission database.Permission - if err := db. - Select("permissions.*"). - Joins("JOIN resources ON permissions.resource_id = resources.id"). - Where("permissions.action = ? AND permissions.scope= ? AND resources.name = ?", action, scope, resourceName). - Where("permissions.status != ?", consts.CommonDeleted). - First(&permission).Error; err != nil { - return nil, fmt.Errorf("failed to find permission with action %s and resource %s: %w", action, resourceName, err) - } - return &permission, nil -} - -// GetPermissionsByResource gets permissions by resource -func GetPermissionsByResource(db *gorm.DB, resourceID int) ([]database.Permission, error) { - var permissions []database.Permission - if err := db. - Where("resource_id = ? AND status = ?", resourceID, consts.CommonEnabled). - Order("action"). - Find(&permissions).Error; err != nil { - return nil, fmt.Errorf("failed to get permissions by resource: %v", err) - } - return permissions, nil -} - -// GetSystemPermissions gets system permissions -func GetSystemPermissions(db *gorm.DB) ([]database.Permission, error) { - var permissions []database.Permission - if err := db.Preload("Resource"). - Where("is_system = ? AND status = ?", true, consts.CommonEnabled). - Order("resource_id, action"). - Find(&permissions).Error; err != nil { - return nil, fmt.Errorf("failed to get system permissions: %v", err) - } - return permissions, nil -} - -// ListPermissions gets permission list -func ListPermissions(db *gorm.DB, limit, offset int, action consts.ActionName, isSystem *bool, status *consts.StatusType) ([]database.Permission, int64, error) { - var permissions []database.Permission - var total int64 - - query := db.Model(&database.Permission{}) - if action != "" { - query = query.Where("action = ?", action) - } - if isSystem != nil { - query = query.Where("is_system = ?", *isSystem) - } - if status != nil { - query = query.Where("status = ?", *status) - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count permissions: %v", err) - } - - if err := query.Limit(limit).Offset(offset).Order("updated_at DESC").Find(&permissions).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list permissions: %v", err) - } - - return permissions, total, nil -} - -// ListPermissionsByID lists permissions by their IDs -func ListPermissionsByID(db *gorm.DB, permissionIDs []int) ([]database.Permission, error) { - if len(permissionIDs) == 0 { - return []database.Permission{}, nil - } - - var permissions []database.Permission - if err := db. - Where("id IN (?) AND status = ?", permissionIDs, consts.CommonEnabled). - Find(&permissions).Error; err != nil { - return nil, fmt.Errorf("failed to query permissions: %w", err) - } - - return permissions, nil -} - -// ListPermissionsByNames lists permissions by their names -func ListPermissionsByNames(db *gorm.DB, permissionNames []string) ([]database.Permission, error) { - if len(permissionNames) == 0 { - return []database.Permission{}, nil - } - - var permissions []database.Permission - if err := db. - Where("name IN (?) AND status = ?", permissionNames, consts.CommonEnabled). - Find(&permissions).Error; err != nil { - return nil, fmt.Errorf("failed to query permissions: %w", err) - } - - return permissions, nil -} - -// ListSystemPermissions gets system permissions -func ListSystemPermissions(db *gorm.DB) ([]database.Permission, error) { - var permissions []database.Permission - if err := db.Where("is_system = ? AND status = ?", true, consts.CommonEnabled). - Find(&permissions).Error; err != nil { - return nil, fmt.Errorf("failed to get system permissions: %v", err) - } - return permissions, nil -} - -// UpdatePermission updates permission information -func UpdatePermission(db *gorm.DB, permission *database.Permission) error { - if err := db.Omit(commonOmitFields).Save(permission).Error; err != nil { - return fmt.Errorf("failed to update permission: %w", err) - } - return nil -} - -// GetPermissionRoles retrieves all roles that have a specific permission -func ListRolesByPermissionID(db *gorm.DB, permissionID int) ([]database.Role, error) { - var roles []database.Role - - if err := db.Table("roles"). - Joins("JOIN role_permissions ON roles.id = role_permissions.role_id"). - Where("role_permissions.permission_id = ? AND roles.status != ?", permissionID, consts.CommonDeleted). - Find(&roles).Error; err != nil { - return nil, fmt.Errorf("failed to get roles for permission %d: %v", permissionID, err) - } - - return roles, nil -} - -// CheckUserHasPermission checks if user has specific permission through various sources -func CheckUserHasPermission(db *gorm.DB, params *dto.CheckPermissionParams, permissionID int) (bool, error) { - // Build direct permission query - directQuery := buildDirectPermissionQuery(db, params.UserID, permissionID, params.ProjectID, params.ContainerID, params.DatasetID) - - // Build global role permission query - globalRoleQuery := buildGlobalRolePermissionQuery(db, params.UserID, permissionID) - - // Combine direct and global role permissions - finalQuery := db.Table("(? UNION ALL ?) as base", directQuery, globalRoleQuery) - - // Add team role permissions if teamID is provided - if params.TeamID != nil { - teamRoleQuery := buildTeamRolePermissionQuery(db, params.UserID, permissionID, *params.TeamID) - finalQuery = db.Table("(? UNION ALL ?) as combined", finalQuery, teamRoleQuery) - } - - // Add project role permissions if projectID is provided - if params.ProjectID != nil { - projectRoleQuery := buildProjectRolePermissionQuery(db, params.UserID, permissionID, *params.ProjectID) - finalQuery = db.Table("(? UNION ALL ?) as combined", finalQuery, projectRoleQuery) - } - - // Add container role permissions if containerID is provided - if params.ContainerID != nil { - containerRoleQuery := buildContainerRolePermissionQuery(db, params.UserID, permissionID, *params.ContainerID) - finalQuery = db.Table("(? UNION ALL ?) as combined", finalQuery, containerRoleQuery) - } - - // Add dataset role permissions if datasetID is provided - if params.DatasetID != nil { - datasetRoleQuery := buildDatasetRolePermissionQuery(db, params.UserID, permissionID, *params.DatasetID) - finalQuery = db.Table("(? UNION ALL ?) as combined", finalQuery, datasetRoleQuery) - } - - var count int64 - if err := finalQuery.Limit(1).Count(&count).Error; err != nil { - return false, fmt.Errorf("failed to check user permission: %w", err) - } - - return count > 0, nil -} - -// buildDirectPermissionQuery builds query for direct user permissions -func buildDirectPermissionQuery(db *gorm.DB, userID int, permissionID int, projectID, containerID, datasetID *int) *gorm.DB { - query := db. - Select("up.permission_id"). - Table("user_permissions up"). - Where("up.user_id = ? AND up.permission_id = ?", userID, permissionID). - Where("up.grant_type = ?", consts.GrantTypeGrant). - Where("up.expires_at IS NULL OR up.expires_at > ?", time.Now()) - - if projectID != nil { - query = query.Where("up.project_id IS NULL OR up.project_id = ?", *projectID) - } else { - query = query.Where("up.project_id IS NULL") - } - - if containerID != nil { - query = query.Where("up.container_id IS NULL OR up.container_id = ?", *containerID) - } else { - query = query.Where("up.container_id IS NULL") - } - - if datasetID != nil { - query = query.Where("up.dataset_id IS NULL OR up.dataset_id = ?", *datasetID) - } else { - query = query.Where("up.dataset_id IS NULL") - } - - return query -} - -// buildGlobalRolePermissionQuery builds query for global role permissions -func buildGlobalRolePermissionQuery(db *gorm.DB, userID int, permissionID int) *gorm.DB { - return db. - Select("rp.permission_id"). - Table("role_permissions rp"). - Joins("JOIN user_roles ur ON rp.role_id = ur.role_id"). - Where("ur.user_id = ? AND rp.permission_id = ?", userID, permissionID) -} - -// buildTeamRolePermissionQuery builds query for team-specific role permissions -func buildTeamRolePermissionQuery(db *gorm.DB, userID int, permissionID int, teamID int) *gorm.DB { - return db. - Select("rp.permission_id"). - Table("role_permissions rp"). - Joins("JOIN user_teams ut ON rp.role_id = ut.role_id"). - Where("ut.user_id = ? AND ut.team_id = ? AND rp.permission_id = ?", userID, teamID, permissionID). - Where("ut.status = ?", consts.CommonEnabled) -} - -// buildProjectRolePermissionQuery builds query for project-specific role permissions -func buildProjectRolePermissionQuery(db *gorm.DB, userID int, permissionID int, projectID int) *gorm.DB { - return db. - Select("rp.permission_id"). - Table("role_permissions rp"). - Joins("JOIN user_projects upr ON rp.role_id = upr.role_id"). - Where("upr.user_id = ? AND upr.project_id = ? AND rp.permission_id = ?", userID, projectID, permissionID). - Where("upr.status = ?", consts.CommonEnabled) -} - -// buildContainerRolePermissionQuery builds query for container-specific role permissions -func buildContainerRolePermissionQuery(db *gorm.DB, userID int, permissionID int, containerID int) *gorm.DB { - return db. - Select("rp.permission_id"). - Table("role_permissions rp"). - Joins("JOIN user_containers uc ON rp.role_id = uc.role_id"). - Where("uc.user_id = ? AND uc.container_id = ? AND rp.permission_id = ?", userID, containerID, permissionID). - Where("uc.status = ?", consts.CommonEnabled) -} - -// buildDatasetRolePermissionQuery builds query for dataset-specific role permissions -func buildDatasetRolePermissionQuery(db *gorm.DB, userID int, permissionID int, datasetID int) *gorm.DB { - return db. - Select("rp.permission_id"). - Table("role_permissions rp"). - Joins("JOIN user_datasets ud ON rp.role_id = ud.role_id"). - Where("ud.user_id = ? AND ud.dataset_id = ? AND rp.permission_id = ?", userID, datasetID, permissionID). - Where("ud.status = ?", consts.CommonEnabled) -} diff --git a/src/repository/project.go b/src/repository/project.go deleted file mode 100644 index 7f21c99e..00000000 --- a/src/repository/project.go +++ /dev/null @@ -1,363 +0,0 @@ -package repository - -import ( - "fmt" - "time" - - "aegis/consts" - "aegis/database" - "aegis/dto" - - "gorm.io/gorm" -) - -const ( - projectOmitFields = "ActiveName" -) - -// ===================================================================== -// Project Repository Functions -// ===================================================================== - -// CreateProject creates a new project -func CreateProject(db *gorm.DB, project *database.Project) error { - if err := db.Omit(projectOmitFields).Create(project).Error; err != nil { - return fmt.Errorf("failed to create project: %w", err) - } - return nil -} - -// DeleteProjct soft deletes a project by setting its status to deleted -func DeleteProject(db *gorm.DB, projectID int) (int64, error) { - result := db.Model(&database.Project{}). - Where("id = ? AND status != ?", projectID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to soft delete project %d: %w", projectID, result.Error) - } - return result.RowsAffected, nil -} - -// GetProjectByID retrieves a project by its ID -func GetProjectByID(db *gorm.DB, id int) (*database.Project, error) { - var project database.Project - if err := db.Where("id = ?", id).First(&project).Error; err != nil { - return nil, fmt.Errorf("failed to find project with id %d: %w", id, err) - } - return &project, nil -} - -// GetProjectByName retrieves a project by its name -func GetProjectByName(db *gorm.DB, name string) (*database.Project, error) { - var project database.Project - if err := db.Where("name = ? AND status != ?", name, consts.CommonDeleted).First(&project).Error; err != nil { - return nil, fmt.Errorf("failed to find project with name %s: %w", name, err) - } - return &project, nil -} - -// GetProjectUserCount gets the count of users in a project -func GetProjectUserCount(db *gorm.DB, projectID int) (int, error) { - var count int64 - if err := db.Model(&database.UserProject{}). - Where("project_id = ? AND status = ?", projectID, consts.CommonEnabled). - Count(&count).Error; err != nil { - return 0, fmt.Errorf("failed to count project users: %w", err) - } - return int(count), nil -} - -// GetUserProjectRole retrieves a user's role in a specific project -func GetUserProjectRole(db *gorm.DB, userID, projectID int) (*database.UserProject, error) { - var userProject database.UserProject - if err := db. - Preload("Role"). - Where("user_id = ? AND project_id = ? AND status = ?", userID, projectID, consts.CommonEnabled). - First(&userProject).Error; err != nil { - return nil, err - } - return &userProject, nil -} - -// ListProjects lists projects based on filter options -func ListProjects(db *gorm.DB, limit, offset int, isPublic *bool, status *consts.StatusType) ([]database.Project, int64, error) { - var projects []database.Project - var total int64 - - query := db.Model(&database.Project{}) - if isPublic != nil { - query = query.Where("is_public = ?", *isPublic) - } - if status != nil { - query = query.Where("status = ?", *status) - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count projects: %w", err) - } - - if err := query.Limit(limit).Offset(offset).Find(&projects).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list projects: %w", err) - } - - return projects, total, nil -} - -// BatchGetProjectsByID retrieves multiple projects by their IDs -func ListProjectsByID(db *gorm.DB, projectIDs []int) ([]database.Project, error) { - if len(projectIDs) == 0 { - return []database.Project{}, nil - } - - var projects []database.Project - if err := db. - Where("id IN (?) AND status != ?", projectIDs, consts.CommonDeleted). - Find(&projects).Error; err != nil { - return nil, fmt.Errorf("failed to query projects: %w", err) - } - return projects, nil -} - -// BatchGetProjectStatistics retrieves statistics for multiple projects in one query -func BatchGetProjectStatistics(db *gorm.DB, projectIDs []int) (map[int]*dto.ProjectStatistics, error) { - if len(projectIDs) == 0 { - return make(map[int]*dto.ProjectStatistics), nil - } - - statsMap := make(map[int]*dto.ProjectStatistics) - - // Initialize map with zero values - for _, id := range projectIDs { - statsMap[id] = &dto.ProjectStatistics{} - } - - // Batch query injection statistics - var injStats []struct { - ProjectID int - Count int64 - LastAt *time.Time - } - - err := db.Table("fault_injections fi"). - Select("tr.project_id, COUNT(*) as count, MAX(fi.updated_at) as last_at"). - Joins("JOIN tasks t ON fi.task_id = t.id"). - Joins("JOIN traces tr ON t.trace_id = tr.id"). - Where("tr.project_id IN (?)", projectIDs). - Group("tr.project_id"). - Scan(&injStats).Error - if err != nil { - return nil, fmt.Errorf("failed to batch get injection statistics: %w", err) - } - - for _, stat := range injStats { - if s, exists := statsMap[stat.ProjectID]; exists { - s.InjectionCount = int(stat.Count) - s.LastInjectionAt = stat.LastAt - } - } - - // Batch query execution statistics - var execStats []struct { - ProjectID int - Count int64 - LastAt *time.Time - } - - err = db.Table("executions e"). - Select("tr.project_id, COUNT(*) as count, MAX(e.updated_at) as last_at"). - Joins("JOIN tasks t ON e.task_id = t.id"). - Joins("JOIN traces tr ON t.trace_id = tr.id"). - Where("tr.project_id IN (?)", projectIDs). - Group("tr.project_id"). - Scan(&execStats).Error - if err != nil { - return nil, fmt.Errorf("failed to batch get execution statistics: %w", err) - } - - for _, stat := range execStats { - if s, exists := statsMap[stat.ProjectID]; exists { - s.ExecutionCount = int(stat.Count) - s.LastExecutionAt = stat.LastAt - } - } - - return statsMap, nil -} - -// UpdateProject updates a project -func UpdateProject(db *gorm.DB, project *database.Project) error { - if err := db.Omit(projectOmitFields).Save(project).Error; err != nil { - return fmt.Errorf("failed to update project: %w", err) - } - return nil -} - -// ===================================================================== -// ProjectLabel Repository Functions -// ===================================================================== - -// AddProjectLabels adds multiple project-label associations in a batch -func AddProjectLabels(db *gorm.DB, projectLabels []database.ProjectLabel) error { - if len(projectLabels) == 0 { - return nil - } - if err := db.Create(&projectLabels).Error; err != nil { - return fmt.Errorf("failed to add project-label associations: %w", err) - } - return nil -} - -// ClearProjectLabels removes label associations from specified projects -func ClearProjectLabels(db *gorm.DB, projectIDs []int, labelIDs []int) error { - if len(projectIDs) == 0 { - return nil - } - - query := db.Table("project_labels"). - Where("project_id IN (?)", projectIDs) - if len(labelIDs) > 0 { - query = query.Where("label_id IN (?)", labelIDs) - } - - if err := query.Delete(nil).Error; err != nil { - return fmt.Errorf("failed to clear project-label associations: %w", err) - } - return nil -} - -// RemoveLabelsFromProject removes all label associations from a specific project -func RemoveLabelsFromProject(db *gorm.DB, projectID int) error { - if err := db.Where("project_id = ?", projectID). - Delete(&database.ProjectLabel{}).Error; err != nil { - return fmt.Errorf("failed to delete all labels from project %d: %w", projectID, err) - } - return nil -} - -// RemoveProjectsFromLabel removes all project associations from a specific label -func RemoveProjectsFromLabel(db *gorm.DB, labelID int) (int64, error) { - result := db.Where("label_id = ?", labelID). - Delete(&database.ProjectLabel{}) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to delete all projects from label %d: %w", labelID, err) - } - return result.RowsAffected, nil -} - -// RemoveProjectsFromLabels removes all project associations from multiple labels -func RemoveProjectsFromLabels(db *gorm.DB, labelIDs []int) (int64, error) { - if len(labelIDs) == 0 { - return 0, nil - } - - result := db.Where("label_id IN (?)", labelIDs). - Delete(&database.ProjectLabel{}) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to delete all projects from labels %v: %w", labelIDs, err) - } - return result.RowsAffected, nil -} - -// ListProjectLabels gets labels for multiple projects in batch -func ListProjectLabels(db *gorm.DB, projectIDs []int) (map[int][]database.Label, error) { - if len(projectIDs) == 0 { - return nil, nil - } - - type projectLabelResult struct { - database.Label - projectID int `gorm:"column:project_id"` - } - - var flatResults []projectLabelResult - if err := db.Model(&database.Label{}). - Joins("JOIN project_labels pl ON pl.label_id = labels.id"). - Where("pl.project_id IN (?)", projectIDs). - Select("labels.*, pl.project_id"). - Find(&flatResults).Error; err != nil { - return nil, fmt.Errorf("failed to batch query project labels: %w", err) - } - - labelsMap := make(map[int][]database.Label) - for _, id := range projectIDs { - labelsMap[id] = []database.Label{} - } - - for _, res := range flatResults { - label := res.Label - labelsMap[res.projectID] = append(labelsMap[res.projectID], label) - } - - return labelsMap, nil -} - -// ListProjectLabelCounts retrieves the count of projects associated with each label ID -func ListProjectLabelCounts(db *gorm.DB, labelIDs []int) (map[int]int64, error) { - if len(labelIDs) == 0 { - return make(map[int]int64), nil - } - - type projectLabelResult struct { - labelID int `gorm:"column:label_id"` - count int64 - } - - var results []projectLabelResult - if err := db.Model(&database.ProjectLabel{}). - Select("label_id, count(label_id) as count"). - Where("label_id IN (?)", labelIDs). - Group("label_id"). - Find(&results).Error; err != nil { - return nil, fmt.Errorf("failed to count project-label associations: %w", err) - } - - countMap := make(map[int]int64, len(results)) - for _, result := range results { - countMap[result.labelID] = result.count - } - - return countMap, nil -} - -// ListLabelsByProjectID lists all labels associated with a specific project -func ListLabelsByProjectID(db *gorm.DB, projectID int) ([]database.Label, error) { - var labels []database.Label - if err := db.Model(&database.Label{}). - Joins("JOIN project_labels pl ON pl.label_id = labels.id"). - Where("pl.project_id = ?", projectID). - Find(&labels).Error; err != nil { - return nil, fmt.Errorf("failed to list labels for project %d: %w", projectID, err) - } - return labels, nil -} - -// GetProjectTeamID retrieves the team ID for a project -func GetProjectTeamID(db *gorm.DB, projectID int) (int, error) { - var teamID *int - if err := db.Model(&database.Project{}). - Select("team_id"). - Where("id = ? AND status != ?", projectID, consts.CommonDeleted). - Scan(&teamID).Error; err != nil { - return 0, fmt.Errorf("failed to get team ID for project %d: %w", projectID, err) - } - if teamID == nil { - return 0, fmt.Errorf("project %d has no associated team", projectID) - } - return *teamID, nil -} - -// ListLabelIDsByKeyAndProjectID finds label IDs by keys associated with a specific project -func ListLabelIDsByKeyAndProjectID(db *gorm.DB, projectID int, keys []string) ([]int, error) { - var labelIDs []int - - err := db.Table("labels l"). - Select("l.id"). - Joins("JOIN project_labels pl ON pl.label_id = l.id"). - Where("pl.project_id = ? AND l.label_key IN (?)", projectID, keys). - Pluck("l.id", &labelIDs).Error - if err != nil { - return nil, fmt.Errorf("failed to find label IDs by key '%s': %w", keys, err) - } - - return labelIDs, nil -} diff --git a/src/repository/query_builder.go b/src/repository/query_builder.go deleted file mode 100644 index dae103a7..00000000 --- a/src/repository/query_builder.go +++ /dev/null @@ -1,349 +0,0 @@ -package repository - -import ( - "encoding/json" - "fmt" - "reflect" - "strings" - - "aegis/dto" - - "gorm.io/gorm" -) - -// SearchQueryBuilder provides methods to build complex database queries from SearchRequest -type SearchQueryBuilder[F ~string] struct { - db *gorm.DB - query *gorm.DB - allowedSortFields map[F]string // user field name -> DB column name (whitelist for sort/group) -} - -// NewSearchQueryBuilder creates a new search query builder. -// allowedSortFields is a whitelist mapping user-facing field names to DB column names -// for sort and group_by operations. If nil, sorting defaults to "id DESC" only. -func NewSearchQueryBuilder[F ~string](db *gorm.DB, allowedSortFields map[F]string) *SearchQueryBuilder[F] { - return &SearchQueryBuilder[F]{ - db: db, - query: db, - allowedSortFields: allowedSortFields, - } -} - -// ApplySearchReq applies filters, sorting, and pagination from SearchRequest. -func (qb *SearchQueryBuilder[F]) ApplySearchReq(filters []dto.SearchFilter, keyword string, sortOptions []dto.TypedSortOption[F], groupBy []F, modelType interface{}) *gorm.DB { - // Start with the base query - qb.query = qb.db.Model(modelType) - - // Apply filters - qb.applyFilters(filters) - - // Apply keyword search if provided - if keyword != "" { - qb.applyKeywordSearch(keyword, modelType) - } - - // Apply sorting (group_by fields first, then user sort) - qb.applySorting(sortOptions, groupBy) - - return qb.query -} - -// applyFilters applies all filters to the query -func (qb *SearchQueryBuilder[F]) applyFilters(filters []dto.SearchFilter) { - for _, filter := range filters { - qb.applySingleFilter(filter) - } -} - -// applyInclude applies include options to the query -func (qb *SearchQueryBuilder[F]) applyIncludes(includes []string) { - for _, include := range includes { - qb.query = qb.query.Preload(include) - } -} - -// applyIncludeFields includes specified fields in the query -func (qb *SearchQueryBuilder[F]) applyIncludeFields(includeFields []string) { - for _, field := range includeFields { - qb.query = qb.query.Select(field) - } -} - -// applyExcludeFields excludes specified fields from the query -func (qb *SearchQueryBuilder[F]) applyExcludeFields(excludeFields []string, modelType interface{}) { - // Get all fields from model type - t := reflect.TypeOf(modelType) - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - - var allFields []string - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - dbTag := field.Tag.Get("gorm") - if dbTag != "" { - dbField := strings.Split(dbTag, ";")[0] - allFields = append(allFields, dbField) - } else { - allFields = append(allFields, field.Name) - } - } - - // Determine fields to select - fieldsToSelect := make([]string, 0, len(allFields)) - excludeMap := make(map[string]struct{}) - for _, field := range excludeFields { - excludeMap[field] = struct{}{} - } - - for _, field := range allFields { - if _, excluded := excludeMap[field]; !excluded { - fieldsToSelect = append(fieldsToSelect, field) - } - } - - if len(fieldsToSelect) > 0 { - qb.query = qb.query.Select(strings.Join(fieldsToSelect, ", ")) - } -} - -// applyKeywordSearch applies general keyword search across searchable fields -func (qb *SearchQueryBuilder[F]) applyKeywordSearch(keyword string, modelType interface{}) { - // Get searchable fields from model type - searchableFields := qb.getSearchableFields(modelType) - - if len(searchableFields) == 0 { - return - } - - // Build OR conditions for keyword search - var conditions []string - var values []any - - for _, field := range searchableFields { - conditions = append(conditions, fmt.Sprintf("%s LIKE ?", field)) - values = append(values, "%"+keyword+"%") - } - - whereClause := strings.Join(conditions, " OR ") - qb.query = qb.query.Where(whereClause, values...) -} - -// applySingleFilter applies a single filter to the query -func (qb *SearchQueryBuilder[F]) applySingleFilter(filter dto.SearchFilter) { - field := qb.sanitizeFieldName(filter.Field) - if field == "" { - return - } - - switch filter.Operator { - case dto.OpEqual: - qb.query = qb.query.Where(fmt.Sprintf("%s = ?", field), filter.Value) - - case dto.OpNotEqual: - qb.query = qb.query.Where(fmt.Sprintf("%s != ?", field), filter.Value) - - case dto.OpGreater: - qb.query = qb.query.Where(fmt.Sprintf("%s > ?", field), filter.Value) - - case dto.OpGreaterEq: - qb.query = qb.query.Where(fmt.Sprintf("%s >= ?", field), filter.Value) - - case dto.OpLess: - qb.query = qb.query.Where(fmt.Sprintf("%s < ?", field), filter.Value) - - case dto.OpLessEq: - qb.query = qb.query.Where(fmt.Sprintf("%s <= ?", field), filter.Value) - - case dto.OpLike: - qb.query = qb.query.Where(fmt.Sprintf("%s LIKE ?", field), "%"+fmt.Sprintf("%v", filter.Value)+"%") - - case dto.OpStartsWith: - qb.query = qb.query.Where(fmt.Sprintf("%s LIKE ?", field), fmt.Sprintf("%v", filter.Value)+"%") - - case dto.OpEndsWith: - qb.query = qb.query.Where(fmt.Sprintf("%s LIKE ?", field), "%"+fmt.Sprintf("%v", filter.Value)) - - case dto.OpNotLike: - qb.query = qb.query.Where(fmt.Sprintf("%s NOT LIKE ?", field), "%"+fmt.Sprintf("%v", filter.Value)+"%") - - case dto.OpIn: - if values := resolveMultiValues(filter); len(values) > 0 { - qb.query = qb.query.Where(fmt.Sprintf("%s IN (?)", field), values) - } - - case dto.OpNotIn: - if values := resolveMultiValues(filter); len(values) > 0 { - qb.query = qb.query.Where(fmt.Sprintf("%s NOT IN (?)", field), values) - } - - case dto.OpIsNull: - qb.query = qb.query.Where(fmt.Sprintf("%s IS NULL", field)) - - case dto.OpIsNotNull: - qb.query = qb.query.Where(fmt.Sprintf("%s IS NOT NULL", field)) - - case dto.OpDateEqual: - qb.query = qb.query.Where(fmt.Sprintf("DATE(%s) = DATE(?)", field), filter.Value) - - case dto.OpDateAfter: - qb.query = qb.query.Where(fmt.Sprintf("DATE(%s) > DATE(?)", field), filter.Value) - - case dto.OpDateBefore: - qb.query = qb.query.Where(fmt.Sprintf("DATE(%s) < DATE(?)", field), filter.Value) - - case dto.OpDateBetween: - if len(filter.Values) == 2 { - qb.query = qb.query.Where(fmt.Sprintf("DATE(%s) BETWEEN DATE(?) AND DATE(?)", field), filter.Values[0], filter.Values[1]) - } - } -} - -// applyPagination applies pagination to the query -func (qb *SearchQueryBuilder[F]) applyPagination(pagination *dto.PaginationReq) *gorm.DB { - offset := (pagination.Page - 1) * int(pagination.Size) - return qb.query.Offset(offset).Limit(int(pagination.Size)) -} - -// applySorting applies sorting to the query using a whitelist approach. -// GroupBy fields are applied first (ASC) to ensure items in the same group are adjacent, -// then user sort options are applied within each group. -func (qb *SearchQueryBuilder[F]) applySorting(sortOptions []dto.TypedSortOption[F], groupBy []F) { - applied := false - - // Apply group_by fields first for consistent grouping order - for _, field := range groupBy { - if dbField, ok := qb.allowedSortFields[field]; ok { - qb.query = qb.query.Order(dbField + " ASC") - applied = true - } - } - - // Apply user sort options (whitelist validated via typed key lookup) - for _, sort := range sortOptions { - dbField, ok := qb.allowedSortFields[sort.Field] - if !ok { - continue // skip fields not in whitelist - } - direction := "ASC" - if strings.ToUpper(string(sort.Direction)) == "DESC" { - direction = "DESC" - } - qb.query = qb.query.Order(dbField + " " + direction) - applied = true - } - - if !applied { - qb.query = qb.query.Order("id DESC") - } -} - -// GetCount gets the total count before pagination -func (qb *SearchQueryBuilder[F]) getCount() (int64, error) { - var count int64 - err := qb.query.Count(&count).Error - return count, err -} - -// getSearchableFields returns fields that can be searched with keywords -func (qb *SearchQueryBuilder[F]) getSearchableFields(modelType interface{}) []string { - // This is a simplified implementation - // In a real application, you might want to use struct tags or configuration - // to mark fields as searchable - - searchableFields := map[string][]string{ - "User": {"username", "email", "full_name"}, - "Role": {"name", "display_name", "description"}, - "Permission": {"name", "display_name", "description"}, - "Project": {"name", "description"}, - "Task": {"name", "description"}, - "Dataset": {"name", "description"}, - "Container": {"name"}, - } - - typeName := qb.getTypeName(modelType) - if fields, exists := searchableFields[typeName]; exists { - return fields - } - - return []string{} -} - -// getTypeName gets the type name from interface -func (qb *SearchQueryBuilder[F]) getTypeName(modelType interface{}) string { - t := reflect.TypeOf(modelType) - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - return t.Name() -} - -// sanitizeFieldName validates that a field name contains only safe characters -// (alphanumeric, underscore, dot for table.column notation). -// Returns empty string if any unsafe character is detected. -func (qb *SearchQueryBuilder[F]) sanitizeFieldName(field string) string { - if field == "" { - return "" - } - for _, c := range field { - if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '_' && c != '.' { - return "" - } - } - return field -} - -// ExecuteSearch executes a complete search operation -func ExecuteSearch[T any, F ~string](db *gorm.DB, searchReq *dto.SearchReq[F], modelType T, allowedSortFields map[F]string) ([]T, int64, error) { - qb := NewSearchQueryBuilder(db, allowedSortFields) - - qb.applyIncludes(searchReq.Includes) - qb.applyIncludeFields(searchReq.IncludeFields) - qb.applyExcludeFields(searchReq.ExcludeFields, modelType) - - // Pass typed Sort/GroupBy directly — no string conversion needed, whitelist lookup uses typed key - qb.ApplySearchReq(searchReq.Filters, searchReq.Keyword, searchReq.Sort, searchReq.GroupBy, modelType) - - // Get total count - total, err := qb.getCount() - if err != nil { - return nil, 0, fmt.Errorf("failed to get count: %w", err) - } - - if searchReq.Size != 0 && searchReq.Page != 0 { - qb.applyPagination(&searchReq.PaginationReq) - } - - // Apply pagination and execute query - var items []T - err = qb.query.Find(&items).Error - if err != nil { - return nil, 0, fmt.Errorf("failed to execute search query: %w", err) - } - - return items, total, nil -} - -// resolveMultiValues returns the effective []string for IN/NOT IN operators. -// It prefers filter.Values when populated; otherwise it tries to parse filter.Value -// as a JSON array (e.g. "[\"a\",\"b\"]" or "[1,2]"). -// A bare non-JSON single value is wrapped in a one-element slice. -func resolveMultiValues(filter dto.SearchFilter) []string { - if len(filter.Values) > 0 { - return filter.Values - } - if filter.Value == "" { - return nil - } - // Try JSON array parse - var parsed []any - if err := json.Unmarshal([]byte(filter.Value), &parsed); err == nil { - result := make([]string, len(parsed)) - for i, v := range parsed { - result[i] = fmt.Sprintf("%v", v) - } - return result - } - // Fallback: treat the whole value as a single element - return []string{filter.Value} -} diff --git a/src/repository/resource.go b/src/repository/resource.go deleted file mode 100644 index 87a179fe..00000000 --- a/src/repository/resource.go +++ /dev/null @@ -1,113 +0,0 @@ -package repository - -import ( - "fmt" - - "aegis/consts" - "aegis/database" - - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -// BatchUpsertResources upserts multiple resources -func BatchUpsertResources(db *gorm.DB, resources []database.Resource) error { - if len(resources) == 0 { - return fmt.Errorf("no resources to upsert") - } - - if err := db.Omit(commonOmitFields).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "name"}}, - DoUpdates: clause.AssignmentColumns([]string{}), - }).Create(&resources).Error; err != nil { - return fmt.Errorf("failed to batch upsert resources: %w", err) - } - - return nil -} - -// GetResourceByID gets resource by ID -func GetResourceByID(db *gorm.DB, id int) (*database.Resource, error) { - var resource database.Resource - if err := db.Where("id = ? and status != ?", id, consts.CommonDeleted).First(&resource).Error; err != nil { - return nil, fmt.Errorf("failed to find resource with id %d: %w", id, err) - } - return &resource, nil -} - -// GetResourceByName gets resource by name -func GetResourceByName(db *gorm.DB, name consts.ResourceName) (*database.Resource, error) { - var resource database.Resource - if err := db. - Where("name = ? and status != ?", name, consts.CommonDeleted). - First(&resource).Error; err != nil { - return nil, fmt.Errorf("failed to find resource with name %s: %w", name, err) - } - return &resource, nil -} - -// ListResources gets resource list -func ListResources(db *gorm.DB, limit, offset int, resourceType *consts.ResourceType, category *consts.ResourceCategory) ([]database.Resource, int64, error) { - var resources []database.Resource - var total int64 - - query := database.DB.Model(&database.Resource{}).Preload("Parent") - if resourceType != nil { - query = query.Where("type = ?", resourceType) - } - if category != nil { - query = query.Where("category = ?", category) - } - - // Get total count - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count resources: %v", err) - } - - if err := query.Limit(limit).Offset(offset).Find(&resources).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list resources: %v", err) - } - - return resources, total, nil -} - -// ListResourcesByNames lists resources by names -func ListResourcesByNames(db *gorm.DB, names []consts.ResourceName) ([]database.Resource, error) { - if len(names) == 0 { - return nil, fmt.Errorf("no resource names provided") - } - - var resources []database.Resource - if err := db.Where("name IN (?)", names). - Find(&resources).Error; err != nil { - return nil, fmt.Errorf("failed to list resources by names: %v", err) - } - - return resources, nil -} - -// SearchResources searches resources -func SearchResources(keyword string, resourceType string, category string) ([]database.Resource, error) { - var resources []database.Resource - - query := database.DB.Model(&database.Resource{}).Where("status = 1") - - if keyword != "" { - query = query.Where("name ILIKE ? OR display_name ILIKE ? OR description ILIKE ?", - "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") - } - - if resourceType != "" { - query = query.Where("type = ?", resourceType) - } - - if category != "" { - query = query.Where("category = ?", category) - } - - if err := query.Order("name").Find(&resources).Error; err != nil { - return nil, fmt.Errorf("failed to search resources: %v", err) - } - - return resources, nil -} diff --git a/src/repository/role.go b/src/repository/role.go deleted file mode 100644 index 615c04b2..00000000 --- a/src/repository/role.go +++ /dev/null @@ -1,174 +0,0 @@ -package repository - -import ( - "fmt" - - "aegis/consts" - "aegis/database" - - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -// BatchUpsertRoles performs batch upsert of roles -func BatchUpsertRoles(db *gorm.DB, roles []database.Role) error { - if len(roles) == 0 { - return fmt.Errorf("no roles to upsert") - } - - if err := db.Omit(commonOmitFields).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "name"}}, - DoUpdates: clause.AssignmentColumns([]string{}), - }, - ).Create(&roles).Error; err != nil { - return fmt.Errorf("failed to batch upsert roles: %v", err) - } - - return nil -} - -// CreateRole creates a role -func CreateRole(db *gorm.DB, role *database.Role) error { - if err := db.Omit(commonOmitFields).Create(role).Error; err != nil { - return fmt.Errorf("failed to create role: %w", err) - } - return nil -} - -// DeleteRole soft deletes a role by setting its status to deleted -func DeleteRole(db *gorm.DB, roleID int) (int64, error) { - result := db.Model(&database.Role{}). - Where("id = ? AND status != ?", roleID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete role %d: %w", roleID, result.Error) - } - return result.RowsAffected, nil -} - -// GetRoleByID gets role by ID -func GetRoleByID(db *gorm.DB, id int) (*database.Role, error) { - var role database.Role - if err := db.Where("id = ? and status != ?", id, consts.CommonDeleted).First(&role).Error; err != nil { - return nil, fmt.Errorf("failed to find role with id %d: %w", id, err) - } - return &role, nil -} - -// GetRoleByName gets role by name -func GetRoleByName(db *gorm.DB, name string) (*database.Role, error) { - var role database.Role - if err := db. - Where("name = ? and status != ?", name, consts.CommonDeleted). - First(&role).Error; err != nil { - return nil, fmt.Errorf("failed to find role with name %s: %w", name, err) - } - return &role, nil -} - -// GetRolePermissions gets role permissions -func GetRolePermissions(db *gorm.DB, roleID int) ([]database.Permission, error) { - var permissions []database.Permission - if err := db.Table("permissions"). - Joins("JOIN role_permissions ON permissions.id = role_permissions.permission_id"). - Where("role_permissions.role_id = ? AND permissions.status = ?", roleID, consts.CommonEnabled). - Find(&permissions).Error; err != nil { - return nil, fmt.Errorf("failed to get role permissions: %v", err) - } - return permissions, nil -} - -// ListRoles gets role list -func ListRoles(db *gorm.DB, limit, offset int, isSystem *bool, status *consts.StatusType) ([]database.Role, int64, error) { - var roles []database.Role - var total int64 - - query := db.Model(&database.Role{}) - if isSystem != nil { - query = query.Where("is_system = ?", *isSystem) - } - if status != nil { - query = query.Where("status = ?", *status) - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count roles: %v", err) - } - - if err := query.Limit(limit).Offset(offset).Order("updated_at DESC").Find(&roles).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list roles: %v", err) - } - - return roles, total, nil -} - -// ListRolesByIDs gets roles by a list of IDs -func ListRolesByIDs(db *gorm.DB, roleIDs []int) ([]database.Role, error) { - var roles []database.Role - if err := db.Where("id IN (?) AND status = ?", roleIDs, consts.CommonEnabled). - Find(&roles).Error; err != nil { - return nil, fmt.Errorf("failed to list roles by IDs: %v", err) - } - return roles, nil -} - -// ListSystemRoles gets system roles -func ListSystemRoles(db *gorm.DB) ([]database.Role, error) { - var roles []database.Role - if err := db.Where("is_system = ? AND status = ?", true, consts.CommonEnabled). - Order("created_at ASC").Find(&roles).Error; err != nil { - return nil, fmt.Errorf("failed to get system roles: %v", err) - } - return roles, nil -} - -// UpdateRole updates role information -func UpdateRole(db *gorm.DB, role *database.Role) error { - if err := db.Omit(commonOmitFields).Save(role).Error; err != nil { - return fmt.Errorf("failed to update role: %w", err) - } - return nil -} - -// ===================== Role-Permission ===================== - -// BatchCreateRolePermissions creates multiple role-permission associations in a batch -func BatchCreateRolePermissions(db *gorm.DB, rolePermissions []database.RolePermission) error { - if len(rolePermissions) == 0 { - return nil - } - if err := db.Create(&rolePermissions).Error; err != nil { - return fmt.Errorf("failed to batch create role permissions: %w", err) - } - return nil -} - -// BatchDeleteRolePermisssions deletes multiple role-permission associations in a batch -func BatchDeleteRolePermisssions(db *gorm.DB, roleID int, permissionIDs []int) error { - if len(permissionIDs) == 0 { - return nil - } - if err := db.Where("role_id = ? AND permission_id IN (?)", roleID, permissionIDs). - Delete(&database.RolePermission{}).Error; err != nil { - return fmt.Errorf("failed to batch delete role permissions: %w", err) - } - return nil -} - -// RemoveRolesFromPermission deletes all role-permission associations associated with a given permission -func RemoveRolesFromPermission(db *gorm.DB, permissionID int) error { - if err := db.Where("permission_id = ?", permissionID). - Delete(&database.RolePermission{}).Error; err != nil { - return fmt.Errorf("failed to remove all roles from permission: %w", err) - } - return nil -} - -// RemovePermissionsFromRole deletes all role-permission associations associated with a given role -func RemovePermissionsFromRole(db *gorm.DB, roleID int) error { - if err := db.Where("role_id = ?", roleID). - Delete(&database.RolePermission{}).Error; err != nil { - return fmt.Errorf("failed to remove all permissions from role: %w", err) - } - return nil -} diff --git a/src/repository/system.go b/src/repository/system.go deleted file mode 100644 index 226a5020..00000000 --- a/src/repository/system.go +++ /dev/null @@ -1,95 +0,0 @@ -package repository - -import ( - "fmt" - - "aegis/consts" - "aegis/database" - - "gorm.io/gorm" -) - -func ListSystems(db *gorm.DB, limit, offset int) ([]database.System, int64, error) { - var systems []database.System - var total int64 - - query := db.Model(&database.System{}). - Where("status != ?", consts.CommonDeleted) - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count systems: %w", err) - } - - if err := query.Limit(limit).Offset(offset).Order("updated_at DESC").Find(&systems).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list systems: %w", err) - } - - return systems, total, nil -} - -func GetSystemByID(db *gorm.DB, id int) (*database.System, error) { - var system database.System - if err := db. - Where("id = ? AND status != ?", id, consts.CommonDeleted). - First(&system).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("system with id %d: %w", id, consts.ErrNotFound) - } - return nil, fmt.Errorf("failed to find system with id %d: %w", id, err) - } - return &system, nil -} - -func GetSystemByName(db *gorm.DB, name string) (*database.System, error) { - var system database.System - if err := db. - Where("name = ? AND status != ?", name, consts.CommonDeleted). - First(&system).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("system with name %s: %w", name, consts.ErrNotFound) - } - return nil, fmt.Errorf("failed to find system with name %s: %w", name, err) - } - return &system, nil -} - -func CreateSystem(db *gorm.DB, system *database.System) error { - if err := db.Create(system).Error; err != nil { - return fmt.Errorf("failed to create system: %w", err) - } - return nil -} - -func UpdateSystem(db *gorm.DB, id int, updates map[string]interface{}) error { - result := db.Model(&database.System{}). - Where("id = ? AND status != ?", id, consts.CommonDeleted). - Updates(updates) - if err := result.Error; err != nil { - return fmt.Errorf("failed to update system with id %d: %w", id, err) - } - if result.RowsAffected == 0 { - return fmt.Errorf("system with id %d: %w", id, consts.ErrNotFound) - } - return nil -} - -func DeleteSystem(db *gorm.DB, id int) error { - result := db.Model(&database.System{}). - Where("id = ? AND status != ?", id, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if err := result.Error; err != nil { - return fmt.Errorf("failed to delete system with id %d: %w", id, err) - } - if result.RowsAffected == 0 { - return fmt.Errorf("system with id %d: %w", id, consts.ErrNotFound) - } - return nil -} - -func ListEnabledSystems(db *gorm.DB) ([]database.System, error) { - var systems []database.System - if err := db.Where("status = ?", consts.CommonEnabled).Find(&systems).Error; err != nil { - return nil, fmt.Errorf("failed to list enabled systems: %w", err) - } - return systems, nil -} diff --git a/src/repository/system_metadata.go b/src/repository/system_metadata.go deleted file mode 100644 index 9e5124c6..00000000 --- a/src/repository/system_metadata.go +++ /dev/null @@ -1,73 +0,0 @@ -package repository - -import ( - "fmt" - - "aegis/database" - - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -func GetSystemMetadata(db *gorm.DB, systemName, metadataType, serviceName string) (*database.SystemMetadata, error) { - var meta database.SystemMetadata - if err := db. - Where("system_name = ? AND metadata_type = ? AND service_name = ?", systemName, metadataType, serviceName). - First(&meta).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, nil - } - return nil, fmt.Errorf("failed to get system metadata: %w", err) - } - return &meta, nil -} - -func ListSystemMetadata(db *gorm.DB, systemName, metadataType string) ([]database.SystemMetadata, error) { - var metas []database.SystemMetadata - query := db.Where("system_name = ?", systemName) - if metadataType != "" { - query = query.Where("metadata_type = ?", metadataType) - } - if err := query.Find(&metas).Error; err != nil { - return nil, fmt.Errorf("failed to list system metadata: %w", err) - } - return metas, nil -} - -func UpsertSystemMetadata(db *gorm.DB, meta *database.SystemMetadata) error { - if err := db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "system_name"}, {Name: "metadata_type"}, {Name: "service_name"}}, - DoUpdates: clause.AssignmentColumns([]string{"data", "updated_at"}), - }).Create(meta).Error; err != nil { - // Fallback: try find and update - var existing database.SystemMetadata - if findErr := db.Where("system_name = ? AND metadata_type = ? AND service_name = ?", - meta.SystemName, meta.MetadataType, meta.ServiceName).First(&existing).Error; findErr == nil { - return db.Model(&existing).Updates(map[string]interface{}{ - "data": meta.Data, - }).Error - } - return fmt.Errorf("failed to upsert system metadata: %w", err) - } - return nil -} - -func DeleteSystemMetadata(db *gorm.DB, systemName string) error { - if err := db.Where("system_name = ?", systemName).Delete(&database.SystemMetadata{}).Error; err != nil { - return fmt.Errorf("failed to delete system metadata for %s: %w", systemName, err) - } - return nil -} - -func ListServiceNames(db *gorm.DB, systemName, metadataType string) ([]string, error) { - var names []string - query := db.Model(&database.SystemMetadata{}). - Where("system_name = ?", systemName) - if metadataType != "" { - query = query.Where("metadata_type = ?", metadataType) - } - if err := query.Distinct("service_name").Pluck("service_name", &names).Error; err != nil { - return nil, fmt.Errorf("failed to list service names: %w", err) - } - return names, nil -} diff --git a/src/repository/task.go b/src/repository/task.go deleted file mode 100644 index 47e093a4..00000000 --- a/src/repository/task.go +++ /dev/null @@ -1,507 +0,0 @@ -package repository - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "aegis/client" - "aegis/consts" - "aegis/database" - "aegis/dto" - - "github.com/redis/go-redis/v9" - "github.com/sirupsen/logrus" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -// ===================== Task Redis ===================== - -// Redis key constants for task queues and indexes -const ( - DelayedQueueKey = "task:delayed" // Sorted set for delayed tasks - ReadyQueueKey = "task:ready" // List for ready-to-execute tasks - DeadLetterKey = "task:dead" // Sorted set for failed tasks - TaskIndexKey = "task:index" // Hash mapping task IDs to their queue - ConcurrencyLockKey = "task:concurrency_lock" // Counter for concurrency control - LastBatchInfoKey = "last_batch_info" // Key for batch processing information - MaxConcurrency = 20 // Maximum concurrent tasks -) - -// ImmediateTask - -// SubmitImmediateTask sends a task to the ready queue for immediate execution -func SubmitImmediateTask(ctx context.Context, taskData []byte, taskID string) error { - redisCli := client.GetRedisClient() - if err := redisCli.LPush(ctx, ReadyQueueKey, taskData).Err(); err != nil { - return err - } - - return redisCli.HSet(ctx, TaskIndexKey, taskID, ReadyQueueKey).Err() -} - -// GetTask retrieves a task from the ready queue with blocking -func GetTask(ctx context.Context, timeout time.Duration) (string, error) { - redisCli := client.GetRedisClient() - result, err := redisCli.BRPop(ctx, timeout, ReadyQueueKey).Result() - if err != nil { - return "", err - } - - return result[1], nil -} - -// HandleFailedTask moves a failed task to the dead letter queue -func HandleFailedTask(ctx context.Context, taskData []byte, backoffSec int) error { - deadLetterTime := time.Now().Add(time.Duration(backoffSec) * time.Second).Unix() - redisCli := client.GetRedisClient() - return redisCli.ZAdd(ctx, DeadLetterKey, redis.Z{ - Score: float64(deadLetterTime), - Member: taskData, - }).Err() -} - -// Delayed Task - -// SubmitDelayedTask sends a task to the delayed queue for future execution -func SubmitDelayedTask(ctx context.Context, taskData []byte, taskID string, executeTime int64) error { - redisCli := client.GetRedisClient() - if err := redisCli.ZAdd(ctx, DelayedQueueKey, redis.Z{ - Score: float64(executeTime), - Member: taskData, - }).Err(); err != nil { - return err - } - - return redisCli.HSet(ctx, TaskIndexKey, taskID, DelayedQueueKey).Err() -} - -// ProcessDelayedTasks moves tasks from delayed queue to ready queue when their time arrives -func ProcessDelayedTasks(ctx context.Context) ([]string, error) { - redisCli := client.GetRedisClient() - now := time.Now().Unix() - - delayedTaskScript := redis.NewScript(` - local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1]) - if #tasks > 0 then - redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1]) - redis.call('LPUSH', KEYS[2], unpack(tasks)) - -- Update task index - for _, task in ipairs(tasks) do - local t = cjson.decode(task) - redis.call('HSET', KEYS[3], t.task_id, KEYS[2]) - end - end - return tasks - `) - - result, err := delayedTaskScript.Run(ctx, redisCli, - []string{DelayedQueueKey, ReadyQueueKey, TaskIndexKey}, - now, - ).StringSlice() - - if err != nil && err != redis.Nil { - return nil, err - } - - return result, nil -} - -// HandleCronRescheduleFailure moves a failed cron task to the dead letter queue -func HandleCronRescheduleFailure(ctx context.Context, taskData []byte) error { - return client.GetRedisClient().ZAdd(ctx, DeadLetterKey, redis.Z{ - Score: float64(time.Now().Unix()), - Member: taskData, - }).Err() -} - -// AcquireConcurrencyLock attempts to acquire a lock for task execution -func AcquireConcurrencyLock(ctx context.Context) bool { - redisCli := client.GetRedisClient() - currentCount, _ := redisCli.Get(ctx, ConcurrencyLockKey).Int64() - if currentCount >= MaxConcurrency { - return false - } - return redisCli.Incr(ctx, ConcurrencyLockKey).Err() == nil -} - -// InitConcurrencyLock initializes the concurrency lock counter -func InitConcurrencyLock(ctx context.Context) error { - redisCli := client.GetRedisClient() - return redisCli.Set(ctx, ConcurrencyLockKey, 0, 0).Err() -} - -// ReleaseConcurrencyLock releases a lock after task execution -func ReleaseConcurrencyLock(ctx context.Context) { - redisCli := client.GetRedisClient() - if err := redisCli.Decr(ctx, ConcurrencyLockKey).Err(); err != nil { - logrus.Warnf("error releasing concurrency lock: %v", err) - } -} - -// GetTaskQueue retrieves the queue a task is in -func GetTaskQueue(ctx context.Context, taskID string) (string, error) { - return client.GetRedisClient().HGet(ctx, TaskIndexKey, taskID).Result() -} - -// ListDelayedTasks lists all tasks in the delayed queue -func ListDelayedTasks(ctx context.Context, limit int64) ([]string, error) { - delayedTasksWithScore, err := client.GetRedisZRangeByScoreWithScores(ctx, DelayedQueueKey, limit) - if err != nil { - return nil, err - } - - taskDatas := make([]string, 0, len(delayedTasksWithScore)) - for _, z := range delayedTasksWithScore { - taskData, ok := z.Member.(string) - if !ok { - return nil, fmt.Errorf("invalid delayed task data") - } - taskDatas = append(taskDatas, taskData) - } - - return taskDatas, nil -} - -// ListReadyTasks lists all tasks in the ready queue -func ListReadyTasks(ctx context.Context) ([]string, error) { - return client.GetRedisListRange(ctx, ReadyQueueKey) -} - -// RemoveFromList removes a task from a Redis list using Lua script -func RemoveFromList(ctx context.Context, key, taskID string) (bool, error) { - cli := client.GetRedisClient() - // Efficient list removal Lua script - removeFromListScript := redis.NewScript(` - local key = KEYS[1] - local taskID = ARGV[1] - local count = 0 - - for i=0, redis.call('LLEN', key)-1 do - local item = redis.call('LINDEX', key, i) - if item then - local task = cjson.decode(item) - if task.task_id == taskID then - redis.call('LSET', key, i, "__DELETED__") - count = count + 1 - end - end - end - - if count > 0 then - redis.call('LREM', key, count, "__DELETED__") - end - - return count - `) - result, err := removeFromListScript.Run(ctx, cli, []string{key}, taskID).Int() - if err != nil { - return false, fmt.Errorf("failed to remove from list: %w", err) - } - - return result > 0, nil -} - -// RemoveFromZSet removes a task from a Redis sorted set -func RemoveFromZSet(ctx context.Context, key, taskID string) bool { - cli := client.GetRedisClient() - members, err := cli.ZRangeByScore(ctx, key, &redis.ZRangeBy{ - Min: "-inf", - Max: "+inf", - }).Result() - if err != nil { - return false - } - - for _, member := range members { - var t dto.UnifiedTask - if json.Unmarshal([]byte(member), &t) == nil && t.TaskID == taskID { - if err := cli.ZRem(ctx, key, member).Err(); err != nil { - logrus.Warnf("failed to remove from ZSet: %v", err) - return false - } - return true - } - } - - return false -} - -// DeleteTaskIndex removes a task from the task index -func DeleteTaskIndex(ctx context.Context, taskID string) error { - return client.GetRedisClient().HDel(ctx, TaskIndexKey, taskID).Err() -} - -// ExpediteDelayedTask finds a task in the delayed queue by task_id, updates -// its embedded execute_time field, and re-scores the sorted-set entry to -// newExecuteTime. It returns (found, err). -// -// The operation is implemented by locating the existing member, updating -// the JSON payload in-memory, then atomically removing the old member and -// adding the updated member in a single Redis pipeline. -// -// If the task is not present in the delayed queue this function returns -// (false, nil) so callers can decide whether that is an error condition -// (e.g. the scheduler may have already moved it to the ready queue). -func ExpediteDelayedTask(ctx context.Context, taskID string, newExecuteTime int64) (bool, error) { - cli := client.GetRedisClient() - members, err := cli.ZRangeByScore(ctx, DelayedQueueKey, &redis.ZRangeBy{ - Min: "-inf", - Max: "+inf", - }).Result() - if err != nil { - return false, fmt.Errorf("failed to scan delayed queue: %w", err) - } - - for _, member := range members { - var parsed map[string]any - if err := json.Unmarshal([]byte(member), &parsed); err != nil { - continue - } - id, _ := parsed["task_id"].(string) - if id != taskID { - continue - } - - parsed["execute_time"] = newExecuteTime - updated, err := json.Marshal(parsed) - if err != nil { - return false, fmt.Errorf("failed to re-marshal task payload: %w", err) - } - - pipe := cli.TxPipeline() - pipe.ZRem(ctx, DelayedQueueKey, member) - pipe.ZAdd(ctx, DelayedQueueKey, redis.Z{ - Score: float64(newExecuteTime), - Member: updated, - }) - pipe.HSet(ctx, TaskIndexKey, taskID, DelayedQueueKey) - if _, err := pipe.Exec(ctx); err != nil { - return false, fmt.Errorf("failed to rescore delayed task: %w", err) - } - return true, nil - } - - return false, nil -} - -// UpdateTaskExecuteTime updates the execute_time column of a task row. -func UpdateTaskExecuteTime(db *gorm.DB, ctx context.Context, taskID string, executeTime int64) error { - return db.WithContext(ctx).Model(&database.Task{}). - Where("id = ?", taskID). - Update("execute_time", executeTime).Error -} - -// ===================== Task Database ===================== - -// BatchDeleteTasks marks multiple tasks as deleted in batch -func BatchDeleteTasks(db *gorm.DB, taskIDs []string) error { - if len(taskIDs) == 0 { - return nil - } - - if err := db.Model(&database.Task{}). - Where("id IN (?) AND status != ?", taskIDs, consts.CommonDeleted). - Update("status", consts.CommonDeleted).Error; err != nil { - return fmt.Errorf("failed to batch delete tasks: %w", err) - } - return nil -} - -// GetTaskByID retrieves a task by its ID with preloaded associations -func GetTaskByID(db *gorm.DB, taskID string) (*database.Task, error) { - var result database.Task - if err := db. - Preload("FaultInjection.Benchmark.Container"). - Preload("FaultInjection.Pedestal.Container"). - Preload("Execution.AlgorithmVersion.Container"). - Preload("Execution.Datapack"). - Preload("Execution.DatasetVersion"). - Where("id = ? AND status != ?", taskID, consts.CommonDeleted). - First(&result).Error; err != nil { - return nil, fmt.Errorf("failed to find task with id %s: %w", taskID, err) - } - return &result, nil -} - -// GetTaskWithParentByID retrieves a task along with its parent task by ID -func GetTaskWithParentByID(db *gorm.DB, taskID string) (*database.Task, error) { - var result database.Task - if err := db. - Preload("ParentTask"). - Where("id = ? AND status != ?", taskID, consts.CommonDeleted). - First(&result).Error; err != nil { - return nil, fmt.Errorf("failed to find task with id %s: %w", taskID, err) - } - return &result, nil -} - -// GetParentTaskLevelByID retrieves the level of a parent task by its ID -func GetParentTaskLevelByID(db *gorm.DB, parentTaskID string) (int, error) { - var result database.Task - if err := db. - Select("level"). - Where("id = ? AND status != ?", parentTaskID, consts.CommonDeleted). - First(&result).Error; err != nil { - return 0, fmt.Errorf("failed to find parent task with id %s: %w", parentTaskID, err) - } - return result.Level, nil -} - -// ListTasks lists tasks based on filter and pagination with preloaded associations -func ListTasks(db *gorm.DB, limit, offset int, filterOptions *dto.ListTaskFilters) ([]database.Task, int64, error) { - var tasks []database.Task - var total int64 - - query := db.Model(&database.Task{}) - if filterOptions.Immediate != nil { - query = query.Where("immediate = ?", *filterOptions.Immediate) - } - if filterOptions.TaskType != nil { - query = query.Where("type = ?", *filterOptions.TaskType) - } - if filterOptions.TraceID != "" { - query = query.Where("trace_id = ?", filterOptions.TraceID) - } - if filterOptions.GroupID != "" { - query = query.Where("group_id = ?", filterOptions.GroupID) - } - if filterOptions.ProjectID > 0 { - query = query.Where("project_id = ?", filterOptions.ProjectID) - } - if filterOptions.State != nil { - query = query.Where("state = ?", *filterOptions.State) - } - if filterOptions.Status != nil { - query = query.Where("status = ?", *filterOptions.Status) - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count tasks: %w", err) - } - - if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&tasks).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list tasks: %w", err) - } - - return tasks, total, nil -} - -// ListTasksByTimeRange retrieves tasks created within a specific time range -func ListTasksByTimeRange(db *gorm.DB, startTime, endTime time.Time) ([]database.Task, error) { - var tasks []database.Task - err := database.DB.Model(&database.Task{}). - Where("created_at >= ? AND created_at <= ? AND status != ?", startTime, endTime, consts.CommonDeleted). - Find(&tasks).Error - return tasks, err -} - -// UpdateTaskState updates the task state in the database -func UpdateTaskState(db *gorm.DB, ctx context.Context, taskID string, state consts.TaskState) error { - return db.WithContext(ctx).Model(&database.Task{}). - Where("id = ?", taskID). - Update("state", state).Error -} - -// UpdateTaskStatus updates the task status in the database -func UpdateTaskStatus(db *gorm.DB, ctx context.Context, taskID string, status int) error { - return db.WithContext(ctx).Model(&database.Task{}). - Where("id = ?", taskID). - Update("status", status).Error -} - -// UpsertTask inserts or updates a task in the database -func UpsertTask(db *gorm.DB, task *database.Task) error { - if err := db.Clauses( - clause.OnConflict{ - Columns: []clause.Column{{Name: "id"}}, - DoUpdates: clause.AssignmentColumns([]string{ - "execute_time", - "state", - "updated_at", - }), - }, - ).Create(task).Error; err != nil { - return fmt.Errorf("failed to upsert task: %w", err) - } - return nil -} - -// GetTaskStatistics returns statistics about tasks -func GetTaskStatistics() (map[string]int64, error) { - stats := make(map[string]int64) - - // Total tasks - var total int64 - if err := database.DB.Model(&database.Task{}).Count(&total).Error; err != nil { - return nil, fmt.Errorf("failed to count total tasks: %v", err) - } - stats["total"] = total - - // Tasks by status - type StatusCount struct { - Status string `json:"status"` - Count int64 `json:"count"` - } - - var statusCounts []StatusCount - err := database.DB.Model(&database.Task{}). - Select("status, COUNT(*) as count"). - Group("status"). - Find(&statusCounts).Error - - if err != nil { - return nil, fmt.Errorf("failed to count tasks by status: %v", err) - } - - for _, sc := range statusCounts { - stats[sc.Status] = sc.Count - } - - // Tasks by type - type TypeCount struct { - Type string `json:"type"` - Count int64 `json:"count"` - } - - var typeCounts []TypeCount - err = database.DB.Model(&database.Task{}). - Select("type, COUNT(*) as count"). - Group("type"). - Find(&typeCounts).Error - - if err != nil { - return nil, fmt.Errorf("failed to count tasks by type: %v", err) - } - - for _, tc := range typeCounts { - stats[tc.Type+"_tasks"] = tc.Count - } - - return stats, nil -} - -// GetRecentTaskActivity returns task activity for the last N days -func GetRecentTaskActivity(days int) (map[string]int64, error) { - stats := make(map[string]int64) - - // Last N days activity - startDate := time.Now().AddDate(0, 0, -days) - var recentCount int64 - if err := database.DB.Model(&database.Task{}).Where("created_at >= ?", startDate).Count(&recentCount).Error; err != nil { - return nil, fmt.Errorf("failed to count recent tasks: %v", err) - } - stats[fmt.Sprintf("last_%d_days", days)] = recentCount - - // Today's tasks - today := time.Now().Truncate(24 * time.Hour) - var todayCount int64 - if err := database.DB.Model(&database.Task{}).Where("created_at >= ?", today).Count(&todayCount).Error; err != nil { - return nil, fmt.Errorf("failed to count today's tasks: %v", err) - } - stats["today"] = todayCount - - return stats, nil -} diff --git a/src/repository/team.go b/src/repository/team.go deleted file mode 100644 index f65f507e..00000000 --- a/src/repository/team.go +++ /dev/null @@ -1,257 +0,0 @@ -package repository - -import ( - "fmt" - - "aegis/consts" - "aegis/database" - - "gorm.io/gorm" -) - -const ( - teamOmitFields = "ActiveName" -) - -// ===================================================================== -// Team Repository Functions -// ===================================================================== - -// CreateTeam creates a new team -func CreateTeam(db *gorm.DB, team *database.Team) error { - if err := db.Omit(teamOmitFields).Create(team).Error; err != nil { - return fmt.Errorf("failed to create team: %w", err) - } - return nil -} - -// DeleteTeam soft deletes a team by setting its status to deleted -func DeleteTeam(db *gorm.DB, teamID int) (int64, error) { - result := db.Model(&database.Team{}). - Where("id = ? AND status != ?", teamID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to soft delete team %d: %w", teamID, result.Error) - } - return result.RowsAffected, nil -} - -// GetTeamByID retrieves a team by its ID -func GetTeamByID(db *gorm.DB, id int) (*database.Team, error) { - var team database.Team - if err := db.Where("id = ?", id).First(&team).Error; err != nil { - return nil, fmt.Errorf("failed to find team with id %d: %w", id, err) - } - return &team, nil -} - -// GetTeamByName retrieves a team by its name -func GetTeamByName(db *gorm.DB, name string) (*database.Team, error) { - var team database.Team - if err := db.Where("name = ? AND status != ?", name, consts.CommonDeleted).First(&team).Error; err != nil { - return nil, fmt.Errorf("failed to find team with name %s: %w", name, err) - } - return &team, nil -} - -// GetTeamUserCount gets the count of users in a team -func GetTeamUserCount(db *gorm.DB, teamID int) (int, error) { - var count int64 - if err := db.Model(&database.UserTeam{}). - Where("team_id = ? AND status = ?", teamID, consts.CommonEnabled). - Count(&count).Error; err != nil { - return 0, fmt.Errorf("failed to count team users: %w", err) - } - return int(count), nil -} - -// GetTeamProjectCount gets the count of projects in a team -func GetTeamProjectCount(db *gorm.DB, teamID int) (int, error) { - var count int64 - if err := db.Model(&database.Project{}). - Where("team_id = ? AND status != ?", teamID, consts.CommonDeleted). - Count(&count).Error; err != nil { - return 0, fmt.Errorf("failed to count team projects: %w", err) - } - return int(count), nil -} - -// ListTeams lists teams based on filter options -func ListTeams(db *gorm.DB, limit, offset int, isPublic *bool, status *consts.StatusType, ids []int) ([]database.Team, int64, error) { - var teams []database.Team - var total int64 - - query := db.Model(&database.Team{}) - if isPublic != nil { - query = query.Where("is_public = ?", *isPublic) - } - if status != nil { - query = query.Where("status = ?", *status) - } - if len(ids) > 0 { - query = query.Where("id IN ?", ids) - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count teams: %w", err) - } - - if err := query.Limit(limit).Offset(offset).Find(&teams).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list teams: %w", err) - } - - return teams, total, nil -} - -// UpdateTeam updates a team -func UpdateTeam(db *gorm.DB, team *database.Team) error { - if err := db.Omit(teamOmitFields).Save(team).Error; err != nil { - return fmt.Errorf("failed to update team: %w", err) - } - return nil -} - -// ListProjectsByTeamID lists all projects belonging to a team with pagination and filtering -func ListProjectsByTeamID(db *gorm.DB, teamID int, limit, offset int, isPublic *bool, status *consts.StatusType) ([]database.Project, int64, error) { - var projects []database.Project - var total int64 - - query := db.Model(&database.Project{}).Where("team_id = ? AND status != ?", teamID, consts.CommonDeleted) - - if isPublic != nil { - query = query.Where("is_public = ?", *isPublic) - } - if status != nil { - query = query.Where("status = ?", *status) - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count projects for team %d: %w", teamID, err) - } - - if err := query.Limit(limit).Offset(offset).Find(&projects).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list projects for team %d: %w", teamID, err) - } - return projects, total, nil -} - -// ===================================================================== -// Team Member Relationship Functions -// ===================================================================== - -// CreateUserTeam creates a user-team association -func CreateUserTeam(db *gorm.DB, userTeam *database.UserTeam) error { - if err := db.Omit(userTeamOmitFields).Create(userTeam).Error; err != nil { - return fmt.Errorf("failed to create user-team association: %w", err) - } - return nil -} - -// DeleteUserTeam deletes a user-team association -func DeleteUserTeam(db *gorm.DB, userID, teamID int) (int64, error) { - result := db.Model(&database.UserTeam{}). - Where("user_id = ? AND team_id = ? AND status != ?", userID, teamID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete user-team association: %w", result.Error) - } - return result.RowsAffected, nil -} - -// GetUserTeamRole retrieves a user's role in a specific team -func GetUserTeamRole(db *gorm.DB, userID int, teamID int) (*database.UserTeam, error) { - var userTeam database.UserTeam - if err := db. - Preload("Role"). - Where("user_id = ? AND team_id = ? AND status = ?", userID, teamID, consts.CommonEnabled). - First(&userTeam).Error; err != nil { - return nil, err - } - return &userTeam, nil -} - -// ListTeamsByUserID gets teams the user participates in -func ListTeamsByUserID(db *gorm.DB, userID int) ([]database.Team, error) { - var teams []database.Team - if err := db.Table("teams"). - Joins("JOIN user_teams ON teams.id = user_teams.team_id"). - Where("user_teams.user_id = ? AND user_teams.status = ? AND teams.status != ?", userID, consts.CommonEnabled, consts.CommonDeleted). - Find(&teams).Error; err != nil { - return nil, fmt.Errorf("failed to list teams for user %d: %w", userID, err) - } - return teams, nil -} - -// ListUserTeamsByUserID gets user-team associations for a specific user -func ListUserTeamsByUserID(db *gorm.DB, userID int, status ...consts.StatusType) ([]database.UserTeam, error) { - query := db.Preload("Team").Preload("Role") - if len(status) == 0 { - query = query.Where("user_id = ? AND status != ?", userID, consts.CommonDeleted) - } else if len(status) == 1 { - query = query.Where("user_id = ? AND status = ?", userID, status[0]) - } else { - query = query.Where("user_id = ? AND status IN (?)", userID, status) - } - - var userTeams []database.UserTeam - if err := query. - Where("user_id = ? AND status != ?", userID, consts.CommonDeleted). - Find(&userTeams).Error; err != nil { - return nil, fmt.Errorf("failed to list user-team associations for user %d: %w", userID, err) - } - - return userTeams, nil -} - -// ListUsersByTeamID gets users who are members of a specific team with pagination -func ListUsersByTeamID(db *gorm.DB, teamID int, limit, offset int) ([]database.User, int64, error) { - var users []database.User - var total int64 - - query := db.Model(&database.User{}). - Joins("JOIN user_teams ON users.id = user_teams.user_id"). - Where("user_teams.team_id = ? AND user_teams.status = ? AND users.status != ?", teamID, consts.CommonEnabled, consts.CommonDeleted) - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count users for team %d: %w", teamID, err) - } - - if err := query.Limit(limit).Offset(offset).Find(&users).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list users for team %d: %w", teamID, err) - } - - return users, total, nil -} - -// RemoveUsersFromTeam deletes all user-team associations for a given team -func RemoveUsersFromTeam(db *gorm.DB, teamID int) (int64, error) { - result := db.Model(&database.UserTeam{}). - Where("team_id = ? AND status != ?", teamID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete all users from team %d: %w", teamID, result.Error) - } - return result.RowsAffected, nil -} - -// RemoveTeamsFromRole deletes all user-team associations for a given role -func RemoveTeamsFromRole(db *gorm.DB, roleID int) (int64, error) { - result := db.Model(&database.UserTeam{}). - Where("role_id = ? AND status != ?", roleID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete all teams with role %d: %w", roleID, result.Error) - } - return result.RowsAffected, nil -} - -// RemoveTeamsFromUser deletes all user-team associations for a given user -func RemoveTeamsFromUser(db *gorm.DB, userID int) (int64, error) { - result := db.Model(&database.UserTeam{}). - Where("user_id = ? AND status != ?", userID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete all teams from user %d: %w", userID, result.Error) - } - return result.RowsAffected, nil -} diff --git a/src/repository/token.go b/src/repository/token.go deleted file mode 100644 index 4cedade3..00000000 --- a/src/repository/token.go +++ /dev/null @@ -1,98 +0,0 @@ -package repository - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "aegis/client" -) - -const ( - tokenBlacklistPrefix = "blacklist:token:%s" - userBlacklistPrefix = "blacklist:user:%d" -) - -// AddTokenToBlacklist adds a token to Redis blacklist with expiry and metadata -func AddTokenToBlacklist(ctx context.Context, tokenID string, expiresAt time.Time, metaData map[string]any) error { - key := fmt.Sprintf(tokenBlacklistPrefix, tokenID) - - ttl := time.Until(expiresAt) - if ttl <= 0 { - return nil - } - - metaDataJSON, err := json.Marshal(metaData) - if err != nil { - return fmt.Errorf("failed to marshal metadata to JSON: %v", err) - } - - if err = client.GetRedisClient().Set(ctx, key, string(metaDataJSON), ttl).Err(); err != nil { - return fmt.Errorf("failed to blacklist token in Redis: %v", err) - } - - return nil -} - -// AddUserTokensToBlacklist blacklists all tokens for a user by setting a key with expiry and metadata -func AddUserTokensToBlacklist(ctx context.Context, userID int, duration time.Duration, metaData map[string]any) error { - key := fmt.Sprintf(userBlacklistPrefix, userID) - - metaDataJSON, err := json.Marshal(metaData) - if err != nil { - return fmt.Errorf("failed to marshal metadata to JSON: %v", err) - } - - if err := client.GetRedisClient().Set(ctx, key, string(metaDataJSON), duration).Err(); err != nil { - return fmt.Errorf("failed to blacklist user tokens in Redis: %v", err) - } - - return nil -} - -// IsTokenBlacklisted checks if a token exists in Redis blacklist -func IsTokenBlacklisted(ctx context.Context, tokenID string) (bool, error) { - key := fmt.Sprintf(tokenBlacklistPrefix, tokenID) - - result, err := client.GetRedisClient().Exists(ctx, key).Result() - if err != nil { - return false, fmt.Errorf("failed to check token blacklist in Redis: %v", err) - } - - return result > 0, nil -} - -// IsUserBlacklisted checks if all user's tokens are blacklisted -func IsUserBlacklisted(ctx context.Context, userID int) (bool, error) { - key := fmt.Sprintf(userBlacklistPrefix, userID) - - result, err := client.GetRedisClient().Exists(ctx, key).Result() - if err != nil { - return false, fmt.Errorf("failed to check user blacklist in Redis: %v", err) - } - - return result > 0, nil -} - -// GetBlacklistedTokensCount retrieves the count of blacklisted tokens in Redis -func GetBlacklistedTokensCount(ctx context.Context) (int64, error) { - var cursor uint64 - var count int64 - - for { - keys, nextCursor, err := client.GetRedisClient().Scan(ctx, cursor, "blacklist:token:*", 100).Result() - if err != nil { - return 0, fmt.Errorf("failed to scan blacklisted tokens: %v", err) - } - - count += int64(len(keys)) - cursor = nextCursor - - if cursor == 0 { - break - } - } - - return count, nil -} diff --git a/src/repository/trace.go b/src/repository/trace.go deleted file mode 100644 index 1894edb8..00000000 --- a/src/repository/trace.go +++ /dev/null @@ -1,126 +0,0 @@ -package repository - -import ( - "fmt" - "time" - - "aegis/consts" - "aegis/database" - "aegis/dto" - - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -// ===================================================================== -// Database Operations -// ===================================================================== - -// GetTraceByID retrieves a trace by its trace ID -func GetTraceByID(db *gorm.DB, traceID string) (*database.Trace, error) { - var trace database.Trace - if err := db.Model(&database.Trace{}). - Preload("Project"). - Preload("Tasks", func(db *gorm.DB) *gorm.DB { - return db.Order("level ASC, sequence ASC") - }). - Where("id = ? AND status != ?", traceID, consts.CommonDeleted). - First(&trace).Error; err != nil { - return nil, err - } - return &trace, nil -} - -// ListTraces lists traces based on filter and pagination with preloaded associations -func ListTraces(db *gorm.DB, limit, offset int, filterOptions *dto.ListTraceFilters) ([]database.Trace, int64, error) { - var traces []database.Trace - var total int64 - - query := db.Model(&database.Trace{}).Preload("Project") - if filterOptions.TraceType != nil { - query = query.Where("type = ?", *filterOptions.TraceType) - } - if filterOptions.GroupID != "" { - query = query.Where("group_id = ?", filterOptions.GroupID) - } - if filterOptions.ProjectID > 0 { - query = query.Where("project_id = ?", filterOptions.ProjectID) - } - if filterOptions.State != nil { - query = query.Where("state = ?", *filterOptions.State) - } - if filterOptions.Status != nil { - query = query.Where("status = ?", *filterOptions.Status) - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count traces: %w", err) - } - - if err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&traces).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list traces: %w", err) - } - - return traces, total, nil -} - -// GetTracesByGroupID retrieves all traces belonging to a specific group -func GetTracesByGroupID(db *gorm.DB, groupID string) ([]database.Trace, error) { - var traces []database.Trace - if err := db.Model(&database.Trace{}). - Preload("Tasks"). - Where("group_id = ? AND status != ?", groupID, consts.CommonDeleted). - Order("start_time DESC"). - Find(&traces).Error; err != nil { - return nil, err - } - return traces, nil -} - -// CountTracesByGroupID counts the total number of non-deleted traces in a group -func CountTracesByGroupID(db *gorm.DB, groupID string) (int64, error) { - var count int64 - if err := db.Model(&database.Trace{}). - Where("group_id = ? AND status != ?", groupID, consts.CommonDeleted). - Count(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -// ListTraceIDs retrieves distinct trace IDs from tasks within the specified time range -func ListTraceIDs(db *gorm.DB, startTime, endTime *time.Time) ([]string, error) { - var traceIDs []string - - query := db.Model(&database.Task{}).Select("DISTINCT trace_id") - if startTime != nil { - query = query.Where("created_at >= ?", *startTime) - } - if endTime != nil { - query = query.Where("created_at <= ?", *endTime) - } - - if err := query.Find(&traceIDs).Error; err != nil { - return nil, err - } - - return traceIDs, nil -} - -// UpsertTrace inserts or updates a trace in the database -func UpsertTrace(db *gorm.DB, trace *database.Trace) error { - if err := db.Clauses( - clause.OnConflict{ - Columns: []clause.Column{{Name: "id"}}, - DoUpdates: clause.AssignmentColumns([]string{ - "last_event", - "end_time", - "state", - "updated_at", - }), - }, - ).Create(trace).Error; err != nil { - return fmt.Errorf("failed to upsert task: %w", err) - } - return nil -} diff --git a/src/repository/user.go b/src/repository/user.go deleted file mode 100644 index 53844de0..00000000 --- a/src/repository/user.go +++ /dev/null @@ -1,501 +0,0 @@ -package repository - -import ( - "fmt" - "time" - - "aegis/consts" - "aegis/database" - - "gorm.io/gorm" -) - -const ( - userOmitFields = "active_username" - userContainerOmitFields = "active_user_container" - userDatasetOmitFields = "active_user_dataset" - userProjectOmitFields = "active_user_project" - userTeamOmitFields = "active_user_team" -) - -// CreateUser creates a user -func CreateUser(db *gorm.DB, user *database.User) error { - if err := db.Omit(userOmitFields).Create(user).Error; err != nil { - return fmt.Errorf("failed to create user: %w", err) - } - return nil -} - -// DeleteUser soft deletes a user by setting its status to deleted -func DeleteUser(db *gorm.DB, userID int) (int64, error) { - result := db.Model(&database.User{}). - Where("id = ? AND status != ?", userID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete user %d: %w", userID, result.Error) - } - return result.RowsAffected, nil -} - -// GetUserByID gets a user by ID -func GetUserByID(db *gorm.DB, id int) (*database.User, error) { - var user database.User - if err := db.Where("id = ?", id).First(&user).Error; err != nil { - return nil, fmt.Errorf("failed to find user with id %d: %w", id, err) - } - return &user, nil -} - -// GetUserByUsername gets a user by username -func GetUserByUsername(db *gorm.DB, username string) (*database.User, error) { - var user database.User - if err := db.Where("username = ?", username).First(&user).Error; err != nil { - return nil, fmt.Errorf("failed to find user with username %s: %w", username, err) - } - return &user, nil -} - -// GetUserByEmail gets a user by email -func GetUserByEmail(db *gorm.DB, email string) (*database.User, error) { - var user database.User - if err := db.Where("email = ?", email).First(&user).Error; err != nil { - return nil, fmt.Errorf("failed to find user with email %s: %w", email, err) - } - return &user, nil -} - -// ListUsers lists users with filters and pagination -func ListUsers(db *gorm.DB, limit, offset int, isActive *bool, status *consts.StatusType) ([]database.User, int64, error) { - var users []database.User - var total int64 - - query := db.Model(&database.User{}).Where("status != ?", consts.CommonDeleted) - if status != nil { - query = query.Where("status = ?", *status) - } - if isActive != nil { - query = query.Where("is_active = ?", *isActive) - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count users: %w", err) - } - - if err := query.Limit(limit).Offset(offset).Find(&users).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list users: %w", err) - } - - return users, total, nil -} - -// UpdateUser updates user information -func UpdateUser(db *gorm.DB, user *database.User) error { - if err := db.Omit(userOmitFields).Save(user).Error; err != nil { - return fmt.Errorf("failed to update user: %w", err) - } - return nil -} - -// UpdateUserLoginTime updates user's last login time -func UpdateUserLoginTime(db *gorm.DB, userID int) error { - now := db.NowFunc() - if err := db.Model(&database.User{}). - Where("id = ? AND status != ?", userID, consts.CommonDeleted). - Update("last_login_at", now).Error; err != nil { - return fmt.Errorf("failed to update user login time: %w", err) - } - return nil -} - -// ===================== User-Role ===================== - -// CreateUserRole creates a user-role association -func CreateUserRole(db *gorm.DB, userRole *database.UserRole) error { - if err := db.Create(userRole).Error; err != nil { - return fmt.Errorf("failed to create user-role association: %w", err) - } - return nil -} - -// DeleteUserRole deletes a user-role association -func DeleteUserRole(db *gorm.DB, userID, roleID int) error { - if err := db.Where("user_id = ? AND role_id = ?", userID, roleID). - Delete(&database.UserRole{}).Error; err != nil { - return fmt.Errorf("failed to delete user-role association: %w", err) - } - return nil -} - -// IsSystemAdmin checks if a user has system admin role -func IsSystemAdmin(db *gorm.DB, userID int) (bool, error) { - var count int64 - if err := db.Table("user_roles"). - Joins("JOIN roles ON user_roles.role_id = roles.id"). - Where("user_roles.user_id = ? AND roles.name IN (?, ?)", - userID, consts.RoleSuperAdmin, consts.RoleAdmin). - Count(&count).Error; err != nil { - return false, fmt.Errorf("failed to check system admin status: %w", err) - } - return count > 0, nil -} - -// RemoveUsersFromRole deletes all user-role associations associated with a given role -func RemoveUsersFromRole(db *gorm.DB, roleID int) error { - if err := db.Where("role_id = ?", roleID). - Delete(&database.UserRole{}).Error; err != nil { - return fmt.Errorf("failed to delete all users from role: %w", err) - } - return nil -} - -// RemoveRolesFromUser deletes all user-role associations associated with a given user -func RemoveRolesFromUser(db *gorm.DB, userID int) error { - if err := db.Where("user_id = ?", userID). - Delete(&database.UserRole{}).Error; err != nil { - return fmt.Errorf("failed to delete all roles from user: %w", err) - } - return nil -} - -// GetRoleUserCount gets count of users who have this role -func GetRoleUserCount(db *gorm.DB, roleID int) (int64, error) { - var count int64 - if err := db.Table("users"). - Joins("JOIN user_roles ON users.id = user_roles.user_id"). - Where("user_roles.role_id = ? AND users.status = ?", roleID, consts.CommonEnabled). - Count(&count).Error; err != nil { - return 0, fmt.Errorf("failed to get role users: %v", err) - } - return count, nil -} - -// ListUsersByRoleID gets users who have a specific role -func ListUsersByRoleID(db *gorm.DB, roleID int) ([]database.User, error) { - var users []database.User - if err := db.Table("users"). - Joins("JOIN user_roles ON users.id = user_roles.user_id"). - Where("user_roles.role_id = ? AND users.status = ?", roleID, consts.CommonEnabled). - Find(&users).Error; err != nil { - return nil, fmt.Errorf("failed to get role users: %v", err) - } - return users, nil -} - -// ListRolesByUserID gets roles the user has -func ListRolesByUserID(db *gorm.DB, userID int) ([]database.Role, error) { - var roles []database.Role - if err := db.Table("roles"). - Joins("JOIN user_roles ur ON ur.role_id = roles.id"). - Where("ur.user_id = ? AND roles.status = ?", userID, consts.CommonEnabled). - Find(&roles).Error; err != nil { - return nil, fmt.Errorf("failed to get global roles of the specific user: %w", err) - } - return roles, nil -} - -// ===================== User-Permission ===================== - -// BatchCreateUserPermissions creates multiple user-permission associations in a batch -func BatchCreateUserPermissions(db *gorm.DB, userPermissions []database.UserPermission) error { - if len(userPermissions) == 0 { - return nil - } - if err := db.Create(&userPermissions).Error; err != nil { - return fmt.Errorf("failed to batch create user permissions: %w", err) - } - return nil -} - -// BatchDeleteUserPermisssions deletes multiple user-permission associations in a batch -func BatchDeleteUserPermisssions(db *gorm.DB, userID int, permissionIDs []int) error { - if len(permissionIDs) == 0 { - return nil - } - if err := db.Where("user_id = ? AND permission_id IN (?)", userID, permissionIDs). - Delete(&database.UserPermission{}).Error; err != nil { - return fmt.Errorf("failed to batch delete user permissions: %w", err) - } - return nil -} - -// RemoveUsersFromPermission deletes all user-permission associations associated with a given permission -func RemoveUsersFromPermission(db *gorm.DB, permissionID int) error { - if err := db.Where("permission_id = ?", permissionID). - Delete(&database.UserPermission{}).Error; err != nil { - return fmt.Errorf("failed to delete all users from permission: %w", err) - } - return nil -} - -// RemovePermissionsFromUser deletes all user-permission associations associated with a given user -func RemovePermissionsFromUser(db *gorm.DB, userID int) error { - if err := db.Where("user_id = ?", userID). - Delete(&database.UserPermission{}).Error; err != nil { - return fmt.Errorf("failed to delete all permissions from user: %w", err) - } - return nil -} - -// ListPermissionsByUserID lists all permissions a user has, including direct and role-based permissions -func ListPermissionsByUserID(db *gorm.DB, userID int) ([]database.Permission, error) { - var permissions []database.Permission - - // Subquery 1: Get permissions from user's global roles - rolePermissionsQuery := db. - Table("permissions p"). - Select("p.*"). - Joins("JOIN role_permissions rp ON p.id = rp.permission_id"). - Joins("JOIN user_roles ur ON rp.role_id = ur.role_id"). - Where("ur.user_id = ? AND p.status = ?", userID, consts.CommonEnabled) - - // Subquery 2: Get direct permissions assigned to user - directPermissionsQuery := db. - Table("permissions p"). - Select("p.*"). - Joins("JOIN user_permissions up ON p.id = up.permission_id"). - Where("up.user_id = ? AND p.status = ?", userID, consts.CommonEnabled). - Where("up.grant_type = ?", consts.GrantTypeGrant). - Where("up.expires_at IS NULL OR up.expires_at > ?", time.Now()) - - // Union both queries and get distinct permissions - if err := db.Table("(?) UNION (?)", rolePermissionsQuery, directPermissionsQuery). - Scan(&permissions).Error; err != nil { - return nil, fmt.Errorf("failed to get user permissions: %w", err) - } - - return permissions, nil -} - -// ===================== User-Container ===================== - -// CreateUserContainer creates a user-container association -func CreateUserContainer(db *gorm.DB, userContainer *database.UserContainer) error { - if err := db.Omit(userContainerOmitFields).Create(userContainer).Error; err != nil { - return fmt.Errorf("failed to create user-container association: %w", err) - } - return nil -} - -// DeleteUserContainer deletes a user-container association -func DeleteUserContainer(db *gorm.DB, userID, containerID int) (int64, error) { - result := db.Model(&database.UserContainer{}). - Where("user_id = ? AND container_id = ? AND status != ?", userID, containerID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete user-container association: %w", result.Error) - } - return result.RowsAffected, nil -} - -// ListContainersByUserID gets containers the user participates -func ListContainersByUserID(db *gorm.DB, userID int) ([]database.Container, error) { - var containers []database.Container - if err := db.Table("containers"). - Joins("JOIN user_containers uc ON uc.container_id = containers.id"). - Where("uc.user_id = ? AND containers.status = ?", userID, consts.CommonEnabled). - Find(&containers).Error; err != nil { - return nil, fmt.Errorf("failed to get containers of the specific user: %w", err) - } - return containers, nil -} - -// ListUserContainersByUserID gets user-container associations for a specific user -func ListUserContainersByUserID(db *gorm.DB, userID int) ([]database.UserContainer, error) { - var userContainers []database.UserContainer - if err := db.Preload("Container"). - Preload("Role"). - Where("user_id = ? AND status = ?", userID, consts.CommonEnabled). - Find(&userContainers).Error; err != nil { - return nil, fmt.Errorf("failed to get user-container associations of the specific user: %w", err) - } - return userContainers, nil -} - -// RemoveUsersFromContainer deletes all user-container associations for a given container -func RemoveUsersFromContainer(db *gorm.DB, containerID int) (int64, error) { - result := db.Model(&database.UserContainer{}). - Where("container_id = ? AND status != ?", containerID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete all users from container: %v", result.Error) - } - return result.RowsAffected, nil -} - -// RemoveContainersFromRole deletes all user-container associations for a given role -func RemoveContainersFromRole(db *gorm.DB, roleID int) (int64, error) { - result := db.Model(&database.UserContainer{}). - Where("role_id = ? AND status != ?", roleID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to delete all containers from role: %v", err) - } - return result.RowsAffected, nil -} - -// RemoveContainersFromUser deletes all user-container associations for a given user -func RemoveContainersFromUser(db *gorm.DB, userID int) (int64, error) { - result := db.Model(&database.UserContainer{}). - Where("user_id = ? AND status != ?", userID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to delete all containers from user: %v", err) - } - return result.RowsAffected, nil -} - -// ===================== User-Dataset ===================== - -// CreateUserDataset creates a user-dataset association -func CreateUserDataset(db *gorm.DB, userDataset *database.UserDataset) error { - if err := db.Omit(userDatasetOmitFields).Create(userDataset).Error; err != nil { - return fmt.Errorf("failed to create user-dataset association: %w", err) - } - return nil -} - -// DeleteUserDataset deletes a user-dataset association -func DeleteUserDataset(db *gorm.DB, userID, datasetID int) (int64, error) { - result := db.Model(&database.UserDataset{}). - Where("user_id = ? AND dataset_id = ? AND status != ?", userID, datasetID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete user-dataset association: %w", result.Error) - } - return result.RowsAffected, nil -} - -// ListDatasetsByUserID gets datasets the user participates -func ListDatasetsByUserID(db *gorm.DB, userID int) ([]database.Dataset, error) { - var datasets []database.Dataset - if err := db.Table("datasets"). - Joins("JOIN user_datasets ud ON ud.dataset_id = datasets.id"). - Where("ud.user_id = ? AND datasets.status = ?", userID, consts.CommonEnabled). - Find(&datasets).Error; err != nil { - return nil, fmt.Errorf("failed to get datasets of the specific user: %w", err) - } - return datasets, nil -} - -// ListUserDatasetsByUserID gets user-dataset associations for a specific user -func ListUserDatasetsByUserID(db *gorm.DB, userID int) ([]database.UserDataset, error) { - var userDatasets []database.UserDataset - if err := db.Preload("Dataset"). - Preload("Role"). - Where("user_id = ? AND status = ?", userID, consts.CommonEnabled). - Find(&userDatasets).Error; err != nil { - return nil, fmt.Errorf("failed to get user-dataset associations of the specific user: %w", err) - } - return userDatasets, nil -} - -// RemoveUsersFromDataset deletes all user-dataset associations for a given dataset -func RemoveUsersFromDataset(db *gorm.DB, datasetID int) (int64, error) { - result := db.Model(&database.UserDataset{}). - Where("dataset_id = ? AND status != ?", datasetID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete all users from dataset: %v", result.Error) - } - return result.RowsAffected, nil -} - -// RemoveDatasetsFromRole deletes all user-dataset associations for a given role -func RemoveDatasetsFromRole(db *gorm.DB, roleID int) (int64, error) { - result := db.Model(&database.UserDataset{}). - Where("role_id = ? AND status != ?", roleID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to delete all datasets from role: %v", err) - } - return result.RowsAffected, nil -} - -// RemoveDatasetsFromUser deletes all user-dataset associations for a given user -func RemoveDatasetsFromUser(db *gorm.DB, userID int) (int64, error) { - result := db.Model(&database.UserDataset{}). - Where("user_id = ? AND status != ?", userID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete all datasets from user: %v", result.Error) - } - return result.RowsAffected, nil -} - -// ===================== User-Project ===================== - -// CreateUserProject creates a user-project association -func CreateUserProject(db *gorm.DB, userProject *database.UserProject) error { - if err := db.Omit(userProjectOmitFields).Create(userProject).Error; err != nil { - return fmt.Errorf("failed to create user-project association: %w", err) - } - return nil -} - -// DeleteUserProject deletes a user-project association -func DeleteUserProject(db *gorm.DB, userID, projectID int) (int64, error) { - result := db.Model(&database.UserProject{}). - Where("user_id = ? AND project_id = ? AND status != ?", userID, projectID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete user-project association: %w", result.Error) - } - return result.RowsAffected, nil -} - -// ListProjectsByUserID gets projects the user participates -func ListProjectsByUserID(db *gorm.DB, userID int) ([]database.Project, error) { - var projects []database.Project - if err := db.Table("projects"). - Joins("JOIN user_projects up ON up.project_id = projects.id"). - Where("up.user_id = ? AND projects.status = ?", userID, consts.CommonEnabled). - Find(&projects).Error; err != nil { - return nil, fmt.Errorf("failed to get projects of the specific user: %w", err) - } - return projects, nil -} - -// ListUserProjectsByUserID gets user-project associations for a specific user -func ListUserProjectsByUserID(db *gorm.DB, userID int) ([]database.UserProject, error) { - var userProjects []database.UserProject - if err := db.Preload("Project"). - Preload("Role"). - Where("user_id = ? AND status = ?", userID, consts.CommonEnabled). - Find(&userProjects).Error; err != nil { - return nil, fmt.Errorf("failed to get user-project associations of the specific user: %w", err) - } - return userProjects, nil -} - -// RemoveUsersFromProject deletes all user-project associations for a given project -func RemoveUsersFromProject(db *gorm.DB, projectID int) (int64, error) { - result := db.Model(&database.UserProject{}). - Where("project_id = ? AND status != ?", projectID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete all users from project: %v", result.Error) - } - return result.RowsAffected, nil -} - -// RemoveProjectsFromRole deletes all user-project associations for a given role -func RemoveProjectsFromRole(db *gorm.DB, roleID int) (int64, error) { - result := db.Model(&database.UserProject{}). - Where("role_id = ? AND status != ?", roleID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if err := result.Error; err != nil { - return 0, fmt.Errorf("failed to delete all projects from role: %v", err) - } - return result.RowsAffected, nil -} - -// RemoveProjectsFromUser deletes all user-project associations for a given user -func RemoveProjectsFromUser(db *gorm.DB, userID int) (int64, error) { - result := db.Model(&database.UserProject{}). - Where("user_id = ? AND status != ?", userID, consts.CommonDeleted). - Update("status", consts.CommonDeleted) - if result.Error != nil { - return 0, fmt.Errorf("failed to delete all projects from user: %v", result.Error) - } - return result.RowsAffected, nil -} diff --git a/src/router/admin.go b/src/router/admin.go new file mode 100644 index 00000000..85c45d8a --- /dev/null +++ b/src/router/admin.go @@ -0,0 +1,158 @@ +package router + +import ( + "aegis/consts" + "aegis/middleware" + + "github.com/gin-gonic/gin" +) + +func SetupAdminV2Routes(v2 *gin.RouterGroup, handlers *Handlers) { + users := v2.Group("/users", middleware.JWTAuth()) + { + roles := users.Group("/:user_id/roles") + { + roles.POST("/:role_id", middleware.RequireUserAssign, handlers.User.AssignRole) + roles.DELETE("/:role_id", middleware.RequireUserAssign, handlers.User.RemoveRole) + } + + projects := users.Group("/:user_id/projects") + { + projects.POST("/:project_id/roles/:role_id", middleware.RequireUserAssign, handlers.User.AssignProject) + projects.DELETE("/:project_id", middleware.RequireUserAssign, handlers.User.RemoveProject) + } + + permissions := users.Group("/:user_id/permissions") + { + permissions.POST("/assign", middleware.RequireUserAssign, handlers.User.AssignPermissions) + permissions.POST("/remove", middleware.RequireUserAssign, handlers.User.RemovePermissions) + } + + containers := users.Group("/:user_id/containers") + { + containers.POST("/:container_id/roles/:role_id", middleware.RequireUserAssign, handlers.User.AssignContainer) + containers.DELETE("/:container_id", middleware.RequireUserAssign, handlers.User.RemoveContainer) + } + + datasets := users.Group("/:user_id/datasets") + { + datasets.POST("/:dataset_id/roles/:role_id", middleware.RequireUserAssign, handlers.User.AssignDataset) + datasets.DELETE("/:dataset_id", middleware.RequireUserAssign, handlers.User.RemoveDataset) + } + + userRead := users.Group("", middleware.RequireUserRead) + { + userRead.GET("", handlers.User.ListUsers) + userRead.GET("/:user_id/detail", middleware.RequireAdminOrUserOwnership, handlers.User.GetUserDetail) + } + + users.POST("", middleware.RequireUserCreate, handlers.User.CreateUser) + users.PATCH("/:user_id", middleware.RequireUserUpdate, handlers.User.UpdateUser) + users.DELETE("/:user_id", middleware.RequireUserDelete, handlers.User.DeleteUser) + } + + roles := v2.Group("/roles", middleware.JWTAuth()) + { + permissions := roles.Group("/:role_id/permissions") + { + permissions.POST("/assign", middleware.RequireRoleGrant, handlers.RBAC.AssignRolePermissions) + permissions.POST("/remove", middleware.RequireRoleRevoke, handlers.RBAC.RemoveRolePermissions) + } + + users := roles.Group("/:role_id/users") + { + users.GET("", middleware.RequireRoleRead, handlers.RBAC.ListUsersFromRole) + } + + roleRead := roles.Group("", middleware.RequireRoleRead) + { + roleRead.GET("/:role_id", handlers.RBAC.GetRole) + roleRead.GET("", handlers.RBAC.ListRoles) + } + + roles.POST("", middleware.RequireRoleCreate, handlers.RBAC.CreateRole) + roles.PATCH("/:role_id", middleware.RequireRoleUpdate, handlers.RBAC.UpdateRole) + roles.DELETE("/:role_id", middleware.RequireRoleDelete, handlers.RBAC.DeleteRole) + } + + permissions := v2.Group("/permissions", middleware.JWTAuth()) + { + roles := permissions.Group("/:permission_id/roles") + { + roles.GET("", middleware.RequirePermissionRead, handlers.RBAC.ListRolesFromPermission) + } + + permRead := permissions.Group("", middleware.RequirePermissionRead) + { + permRead.GET("", handlers.RBAC.ListPermissions) + permRead.GET("/:permission_id", handlers.RBAC.GetPermission) + } + } + + resources := v2.Group("/resources", middleware.JWTAuth()) + { + resourceRead := resources.Group("", middleware.RequirePermissionRead) + { + permissions := resourceRead.Group("/:resource_id/permissions") + { + permissions.GET("", handlers.RBAC.ListResourcePermissions) + } + + resourceRead.GET("/:resource_id", handlers.RBAC.GetResource) + resourceRead.GET("", handlers.RBAC.ListResources) + } + } + + systems := v2.Group("/systems", middleware.JWTAuth()) + { + systemRead := systems.Group("", middleware.RequireSystemRead) + { + systemRead.GET("", handlers.ChaosSystem.ListSystems) + systemRead.GET("/:id", handlers.ChaosSystem.GetSystem) + systemRead.GET("/:id/metadata", handlers.ChaosSystem.ListMetadata) + } + + systemConfigure := systems.Group("", middleware.RequireSystemConfigure) + { + systemConfigure.POST("", handlers.ChaosSystem.CreateSystem) + systemConfigure.PUT("/:id", handlers.ChaosSystem.UpdateSystem) + systemConfigure.POST("/:id/metadata", handlers.ChaosSystem.UpsertMetadata) + } + + systems.DELETE("/:id", middleware.RequirePermission(consts.PermSystemManage), handlers.ChaosSystem.DeleteSystem) + } + + system := v2.Group("/system", middleware.JWTAuth(), middleware.RequireSystemRead) + { + system.GET("/metrics", handlers.SystemMetric.GetSystemMetrics) + system.GET("/metrics/history", handlers.SystemMetric.GetSystemMetricsHistory) + audit := system.Group("/audit", middleware.RequireAuditRead) + { + audit.GET("", handlers.System.ListAuditLogs) + audit.GET("/:id", handlers.System.GetAuditLog) + } + + configs := system.Group("/configs") + { + configsRead := configs.Group("", middleware.RequireConfigurationRead) + { + configsRead.GET("", handlers.System.ListConfigs) + configsRead.GET("/:config_id", handlers.System.GetConfig) + configsRead.GET("/:config_id/histories", handlers.System.ListConfigHistories) + } + + configs.PATCH("/:config_id", middleware.RequireConfigurationUpdate, handlers.System.UpdateConfigValue) + configs.POST("/:config_id/value/rollback", middleware.RequireConfigurationUpdate, handlers.System.RollbackConfigValue) + configs.PUT("/:config_id/metadata", middleware.RequireConfigurationConfigure, handlers.System.UpdateConfigMetadata) + configs.POST("/:config_id/metadata/rollback", middleware.RequireConfigurationConfigure, handlers.System.RollbackConfigMetadata) + } + + system.GET("/health", handlers.System.GetHealth) + + monitor := system.Group("/monitor") + monitor.POST("/metrics", handlers.System.GetMetrics) + monitor.GET("/info", handlers.System.GetSystemInfo) + monitor.GET("/namespaces/locks", handlers.System.ListNamespaceLocks) + monitor.GET("/tasks/queue", handlers.System.ListQueuedTasks) + } +} diff --git a/src/router/handlers.go b/src/router/handlers.go new file mode 100644 index 00000000..ea476b17 --- /dev/null +++ b/src/router/handlers.go @@ -0,0 +1,101 @@ +package router + +import ( + auth "aegis/module/auth" + chaossystem "aegis/module/chaossystem" + container "aegis/module/container" + dataset "aegis/module/dataset" + evaluation "aegis/module/evaluation" + execution "aegis/module/execution" + group "aegis/module/group" + injection "aegis/module/injection" + label "aegis/module/label" + metric "aegis/module/metric" + notification "aegis/module/notification" + pedestal "aegis/module/pedestal" + project "aegis/module/project" + ratelimiter "aegis/module/ratelimiter" + rbac "aegis/module/rbac" + sdk "aegis/module/sdk" + system "aegis/module/system" + systemmetric "aegis/module/systemmetric" + task "aegis/module/task" + team "aegis/module/team" + trace "aegis/module/trace" + user "aegis/module/user" +) + +type Handlers struct { + Auth *auth.Handler + Project *project.Handler + Task *task.Handler + Injection *injection.Handler + Execution *execution.Handler + Container *container.Handler + Dataset *dataset.Handler + Evaluation *evaluation.Handler + Trace *trace.Handler + Group *group.Handler + Metric *metric.Handler + User *user.Handler + RBAC *rbac.Handler + SDK *sdk.Handler + System *system.Handler + Notification *notification.Handler + Pedestal *pedestal.Handler + RateLimiter *ratelimiter.Handler + ChaosSystem *chaossystem.Handler + Team *team.Handler + Label *label.Handler + SystemMetric *systemmetric.Handler +} + +func NewHandlers( + auth *auth.Handler, + project *project.Handler, + task *task.Handler, + injection *injection.Handler, + execution *execution.Handler, + container *container.Handler, + dataset *dataset.Handler, + evaluation *evaluation.Handler, + trace *trace.Handler, + group *group.Handler, + metric *metric.Handler, + user *user.Handler, + rbac *rbac.Handler, + sdk *sdk.Handler, + system *system.Handler, + notification *notification.Handler, + pedestal *pedestal.Handler, + rateLimiter *ratelimiter.Handler, + chaosSystem *chaossystem.Handler, + team *team.Handler, + label *label.Handler, + systemMetric *systemmetric.Handler, +) *Handlers { + return &Handlers{ + Auth: auth, + Project: project, + Task: task, + Injection: injection, + Execution: execution, + Container: container, + Dataset: dataset, + Evaluation: evaluation, + Trace: trace, + Group: group, + Metric: metric, + User: user, + RBAC: rbac, + SDK: sdk, + System: system, + Notification: notification, + Pedestal: pedestal, + RateLimiter: rateLimiter, + ChaosSystem: chaosSystem, + Team: team, + Label: label, + SystemMetric: systemMetric, + } +} diff --git a/src/router/module.go b/src/router/module.go new file mode 100644 index 00000000..41a89cae --- /dev/null +++ b/src/router/module.go @@ -0,0 +1,7 @@ +package router + +import "go.uber.org/fx" + +var Module = fx.Module("router", + fx.Provide(NewHandlers), +) diff --git a/src/router/portal.go b/src/router/portal.go new file mode 100644 index 00000000..97e1ac01 --- /dev/null +++ b/src/router/portal.go @@ -0,0 +1,211 @@ +package router + +import ( + "aegis/middleware" + + "github.com/gin-gonic/gin" +) + +func SetupPortalV2Routes(v2 *gin.RouterGroup, handlers *Handlers) { + containers := v2.Group("/containers", middleware.JWTAuth()) + { + containerRead := containers.Group("", middleware.RequireContainerRead) + { + containerRead.GET("", handlers.Container.ListContainers) + containerRead.GET("/:container_id", handlers.Container.GetContainer) + } + + containers.POST("", middleware.RequireContainerCreate, handlers.Container.CreateContainer) + containers.PATCH("/:container_id", middleware.RequireContainerUpdate, handlers.Container.UpdateContainer) + containers.PATCH("/:container_id/labels", middleware.RequireContainerUpdate, handlers.Container.ManageContainerCustomLabels) + containers.DELETE("/:container_id", middleware.RequireContainerDelete, handlers.Container.DeleteContainer) + containers.POST("/build", middleware.RequireContainerExecute, handlers.Container.SubmitContainerBuilding) + + containerVersions := containers.Group("/:container_id/versions") + { + containerVersionRead := containerVersions.Group("", middleware.RequireContainerVersionRead) + { + containerVersionRead.GET("", handlers.Container.ListContainerVersions) + containerVersionRead.GET("/:version_id", handlers.Container.GetContainerVersion) + } + + containerVersions.POST("", middleware.RequireContainerVersionCreate, handlers.Container.CreateContainerVersion) + containerVersions.PATCH("/:version_id", middleware.RequireContainerVersionUpdate, handlers.Container.UpdateContainerVersion) + containerVersions.DELETE("/:version_id", middleware.RequireContainerVersionDelete, handlers.Container.DeleteContainerVersion) + containerVersions.POST("/:version_id/helm-chart", middleware.RequireContainerVersionUpload, handlers.Container.UploadHelmChart) + containerVersions.POST("/:version_id/helm-values", middleware.RequireContainerVersionUpload, handlers.Container.UploadHelmValueFile) + } + } + + // Flat container-versions group — operations keyed by version id alone + // (no parent container id in the URL). Used by `aegisctl container version + // set-image` to rewrite image reference columns. + flatContainerVersions := v2.Group("/container-versions", middleware.JWTAuth()) + { + flatContainerVersions.PATCH("/:id/image", middleware.RequireContainerVersionUpdate, handlers.Container.SetContainerVersionImage) + } + + datasets := v2.Group("/datasets", middleware.JWTAuth()) + { + datasetRead := datasets.Group("", middleware.RequireDatasetRead) + { + datasetRead.GET("", handlers.Dataset.ListDatasets) + datasetRead.GET("/:dataset_id", handlers.Dataset.GetDataset) + datasetRead.POST("/search", handlers.Dataset.SearchDataset) + } + + datasets.POST("", middleware.RequireDatasetCreate, handlers.Dataset.CreateDataset) + datasets.PATCH("/:dataset_id", middleware.RequireDatasetUpdate, handlers.Dataset.UpdateDataset) + datasets.PATCH("/:dataset_id/labels", middleware.RequireDatasetUpdate, handlers.Dataset.ManageDatasetCustomLabels) + datasets.DELETE("/:dataset_id", middleware.RequireDatasetDelete, handlers.Dataset.DeleteDataset) + + datasetVersions := datasets.Group("/:dataset_id/versions") + { + datasetVersionRead := datasetVersions.Group("", middleware.RequireDatasetVersionRead) + { + datasetVersionRead.GET("", handlers.Dataset.ListDatasetVersions) + datasetVersionRead.GET("/:version_id", handlers.Dataset.GetDatasetVersion) + } + + datasetVersions.POST("", middleware.RequireDatasetVersionCreate, handlers.Dataset.CreateDatasetVersion) + datasetVersions.PATCH("/:version_id", middleware.RequireDatasetVersionUpdate, handlers.Dataset.UpdateDatasetVersion) + datasetVersions.DELETE("/:version_id", middleware.RequireDatasetVersionDelete, handlers.Dataset.DeleteDatasetVersion) + } + } + + projects := v2.Group("/projects", middleware.JWTAuth()) + { + projects.POST("/:project_id/injections/search", middleware.RequireProjectRead, handlers.Injection.SearchProjectInjections) + + projectRead := projects.Group("", middleware.RequireProjectRead) + { + projectRead.GET("/:project_id", handlers.Project.GetProjectDetail) + projectRead.GET("", handlers.Project.ListProjects) + } + + projects.POST("", middleware.RequireProjectCreate, handlers.Project.CreateProject) + projects.PATCH("/:project_id", middleware.RequireProjectUpdate, handlers.Project.UpdateProject) + projects.PATCH("/:project_id/labels", middleware.RequireProjectUpdate, handlers.Project.ManageProjectCustomLabels) + projects.DELETE("/:project_id", middleware.RequireProjectDelete, handlers.Project.DeleteProject) + } + + teams := v2.Group("/teams", middleware.JWTAuth()) + { + teams.POST("", middleware.RequireTeamCreate, handlers.Team.CreateTeam) + teams.GET("", middleware.RequireTeamRead, handlers.Team.ListTeams) + + teamAdmin := teams.Group("/:team_id", middleware.RequireTeamAdminAccess) + { + teamAdmin.PATCH("", handlers.Team.UpdateTeam) + teamAdmin.DELETE("", handlers.Team.DeleteTeam) + + teamManagement := teamAdmin.Group("/members") + teamManagement.POST("", handlers.Team.AddTeamMember) + teamManagement.DELETE("/:user_id", handlers.Team.RemoveTeamMember) + teamManagement.PATCH("/:user_id/role", handlers.Team.UpdateTeamMemberRole) + } + + teamMember := teams.Group("", middleware.RequireTeamMemberAccess) + { + teamMember.GET("/:team_id", handlers.Team.GetTeamDetail) + teamMember.GET("/:team_id/members", handlers.Team.ListTeamMembers) + teamMember.GET("/:team_id/projects", handlers.Team.ListTeamProjects) + } + } + + labels := v2.Group("/labels", middleware.JWTAuth()) + { + labelRead := labels.Group("", middleware.RequireLabelRead) + { + labelRead.GET("/:label_id", handlers.Label.GetLabelDetail) + labelRead.GET("", handlers.Label.ListLabels) + } + + labels.POST("", middleware.RequireLabelCreate, handlers.Label.CreateLabel) + labels.PATCH("/:label_id", middleware.RequireLabelUpdate, handlers.Label.UpdateLabel) + labels.DELETE("/:label_id", middleware.RequireLabelDelete, handlers.Label.DeleteLabel) + labels.POST("/batch-delete", middleware.RequireLabelDelete, handlers.Label.BatchDeleteLabels) + } + + evaluations := v2.Group("/evaluations", middleware.JWTAuth()) + { + evaluations.DELETE("/:id", handlers.Evaluation.DeleteEvaluation) + } + + executions := v2.Group("/executions", middleware.JWTAuth()) + { + executions.GET("/labels", middleware.RequireAPIKeyScopesAny("sdk:*", "sdk:executions:*", "sdk:executions:read"), handlers.Execution.ListAvailableExecutionLabels) + executions.POST("/batch-delete", handlers.Execution.BatchDeleteExecutions) + } + + injections := v2.Group("/injections", middleware.JWTAuth()) + { + injections.PATCH("/labels/batch", handlers.Injection.BatchManageInjectionLabels) + injections.POST("/batch-delete", handlers.Injection.BatchDeleteInjections) + injections.POST("/upload", handlers.Injection.UploadDatapack) + injections.PUT("/:id/groundtruth", handlers.Injection.UpdateGroundtruth) + } + + notifications := v2.Group("/notifications", middleware.JWTAuth()) + { + notifications.GET("/stream", handlers.Notification.GetStream) + } + + tasks := v2.Group("/tasks", middleware.JWTAuth()) + { + taskRead := tasks.Group("", middleware.RequireTaskRead) + { + taskRead.GET("", handlers.Task.ListTasks) + taskRead.GET("/:task_id", handlers.Task.GetTask) + taskRead.GET("/:task_id/logs/ws", handlers.Task.GetTaskLogsWS) + } + + tasks.POST("/batch-delete", middleware.RequireTaskDelete, handlers.Task.BatchDelete) + tasks.POST("/:task_id/expedite", middleware.RequireTaskExecute, handlers.Task.ExpediteTask) + } + + pedestal := v2.Group("/pedestal", middleware.JWTAuth()) + { + helm := pedestal.Group("/helm") + { + helm.GET("/:container_version_id", handlers.Pedestal.GetPedestalHelmConfig) + helm.POST("/:container_version_id/verify", handlers.Pedestal.VerifyPedestalHelmConfig) + helm.PUT("/:container_version_id", middleware.RequireContainerVersionUpload, handlers.Pedestal.UpsertPedestalHelmConfig) + } + } + + rateLimiters := v2.Group("/rate-limiters", middleware.JWTAuth()) + { + rateLimiters.GET("", handlers.RateLimiter.ListRateLimiters) + rateLimiterAdmin := rateLimiters.Group("", middleware.RequireSystemAdmin()) + { + rateLimiterAdmin.DELETE("/:bucket", handlers.RateLimiter.ResetRateLimiter) + rateLimiterAdmin.POST("/gc", handlers.RateLimiter.GCRateLimiters) + } + } + + groups := v2.Group("/groups", middleware.JWTAuth(), middleware.RequireTraceRead) + { + groups.GET("/:group_id/stats", handlers.Group.GetGroupStats) + groups.GET("/:group_id/stream", handlers.Group.GetGroupStream) + } + + traces := v2.Group("/traces", middleware.JWTAuth(), middleware.RequireTraceRead) + { + traces.GET("", handlers.Trace.ListTraces) + traces.GET("/:trace_id", handlers.Trace.GetTrace) + traces.GET("/:trace_id/stream", handlers.Trace.GetTraceStream) + } + + accessKeys := v2.Group("/api-keys", middleware.JWTAuth(), middleware.RequireHumanUserAuth()) + { + accessKeys.GET("", handlers.Auth.ListAPIKeys) + accessKeys.POST("", handlers.Auth.CreateAPIKey) + accessKeys.GET("/:id", handlers.Auth.GetAPIKey) + accessKeys.DELETE("/:id", handlers.Auth.DeleteAPIKey) + accessKeys.POST("/:id/rotate", handlers.Auth.RotateAPIKey) + accessKeys.POST("/:id/disable", handlers.Auth.DisableAPIKey) + accessKeys.POST("/:id/enable", handlers.Auth.EnableAPIKey) + accessKeys.POST("/:id/revoke", handlers.Auth.RevokeAPIKey) + } +} diff --git a/src/router/public.go b/src/router/public.go new file mode 100644 index 00000000..bcbdd123 --- /dev/null +++ b/src/router/public.go @@ -0,0 +1,24 @@ +package router + +import ( + "aegis/middleware" + + "github.com/gin-gonic/gin" +) + +func SetupPublicV2Routes(v2 *gin.RouterGroup, handlers *Handlers) { + auth := v2.Group("/auth") + { + auth.POST("/login", handlers.Auth.Login) // User login + auth.POST("/register", handlers.Auth.Register) // User registration + auth.POST("/refresh", handlers.Auth.RefreshToken) // Token refresh + + // These require authentication + authProtected := auth.Group("", middleware.JWTAuth(), middleware.RequireHumanUserAuth()) + { + authProtected.POST("/logout", handlers.Auth.Logout) // User logout + authProtected.POST("/change-password", handlers.Auth.ChangePassword) // Change password + authProtected.GET("/profile", handlers.Auth.GetProfile) // Get current user profile + } + } +} diff --git a/src/router/router.go b/src/router/router.go index 5f21e701..f7f20bea 100644 --- a/src/router/router.go +++ b/src/router/router.go @@ -1,6 +1,7 @@ package router import ( + _ "aegis/docs/openapi2" "aegis/middleware" "github.com/gin-contrib/cors" @@ -9,30 +10,34 @@ import ( ginSwagger "github.com/swaggo/gin-swagger" ) -func New() *gin.Engine { +func New(handlers *Handlers, middlewareService middleware.Service) *gin.Engine { router := gin.Default() // CORS configuration config := cors.DefaultConfig() config.AllowAllOrigins = true - config.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "Cache-Control", "X-Requested-With"} + config.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "Cache-Control", "X-Request-Id"} config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"} config.AllowCredentials = true - config.ExposeHeaders = []string{"Content-Length", "Content-Type"} + config.ExposeHeaders = []string{"Content-Length", "Content-Type", "X-Request-Id"} // Middleware setup router.Use( + middleware.InjectService(middlewareService), + middleware.RequestID(), middleware.GroupID(), middleware.SSEPath(), cors.New(config), middleware.TracerMiddleware(), ) - // Set up system routes - SetupSystemRoutes(router) + middleware.StartCleanupRoutine() - // Set up API routes - SetupV2Routes(router) + v2 := router.Group("/api/v2") + SetupPublicV2Routes(v2, handlers) + SetupSDKV2Routes(v2, handlers) + SetupAdminV2Routes(v2, handlers) + SetupPortalV2Routes(v2, handlers) // Swagger documentation router.GET("/docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) diff --git a/src/router/router_test.go b/src/router/router_test.go new file mode 100644 index 00000000..f4692910 --- /dev/null +++ b/src/router/router_test.go @@ -0,0 +1,58 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestRouterSeparatesRouteGroups(t *testing.T) { + engine := New(&Handlers{}, nil) + routes := engine.Routes() + + requiredPrefixes := []string{ + "/api/v2/auth", + "/api/v2/projects", + "/api/v2/executions", + "/api/v2/users", + "/api/v2/sdk", + "/api/v2/system/audit", + "/api/v2/system/configs", + "/api/v2/system/monitor", + "/api/v2/system/health", + "/docs/", + } + + for _, prefix := range requiredPrefixes { + if !hasRoutePrefix(routes, prefix) { + t.Fatalf("expected route prefix %q to be registered", prefix) + } + } +} + +func hasRoutePrefix(routes []gin.RouteInfo, prefix string) bool { + for _, route := range routes { + if len(route.Path) >= len(prefix) && route.Path[:len(prefix)] == prefix { + return true + } + } + return false +} + +func TestSwaggerDocEndpointServesRegisteredSpec(t *testing.T) { + engine := New(&Handlers{}, nil) + + req := httptest.NewRequest(http.MethodGet, "/docs/doc.json", nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected swagger doc endpoint status 200, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "/api/v2/auth/login") { + t.Fatalf("expected swagger doc to include auth login path") + } +} diff --git a/src/router/sdk.go b/src/router/sdk.go new file mode 100644 index 00000000..ba1a2146 --- /dev/null +++ b/src/router/sdk.go @@ -0,0 +1,113 @@ +package router + +import ( + "aegis/middleware" + + "github.com/gin-gonic/gin" +) + +func SetupSDKV2Routes(v2 *gin.RouterGroup, handlers *Handlers) { + auth := v2.Group("/auth") + { + auth.POST("/api-key/token", handlers.Auth.ExchangeAPIKeyToken) + } + + sdkEval := v2.Group("/sdk/evaluations", middleware.JWTAuth(), middleware.RequireAPIKeyScopesAny("sdk:*", "sdk:evaluations:*", "sdk:evaluations:read")) + { + sdkEval.GET("", handlers.SDK.ListEvaluations) + sdkEval.GET("/experiments", handlers.SDK.ListExperiments) + sdkEval.GET("/:id", handlers.SDK.GetEvaluation) + } + + sdkData := v2.Group("/sdk/datasets", middleware.JWTAuth(), middleware.RequireAPIKeyScopesAny("sdk:*", "sdk:datasets:*", "sdk:datasets:read")) + { + sdkData.GET("", handlers.SDK.ListDatasetSamples) + } + + datasets := v2.Group("/datasets", middleware.JWTAuth()) + { + datasetVersions := datasets.Group("/:dataset_id/versions") + { + datasetVersions.GET("/:version_id/download", middleware.RequireDatasetVersionDownload, middleware.RequireAPIKeyScopesAny("sdk:*", "sdk:datasets:*", "sdk:datasets:read"), handlers.Dataset.DownloadDatasetVersion) + } + + datasets.PATCH("/:dataset_id/version/:version_id/injections", middleware.RequireDatasetVersionUpdate, middleware.RequireAPIKeyScopesAny("sdk:*", "sdk:datasets:*", "sdk:datasets:write"), handlers.Dataset.ManageDatasetVersionInjections) + } + + projects := v2.Group("/projects", middleware.JWTAuth()) + { + injections := projects.Group("/:project_id/injections") + { + injectionRead := injections.Group("", middleware.RequireProjectRead) + { + analysis := injectionRead.Group("/analysis") + { + analysis.GET("/no-issues", handlers.Injection.ListProjectFaultInjectionNoIssues) + analysis.GET("/with-issues", handlers.Injection.ListProjectFaultInjectionWithIssues) + } + + injectionRead.GET("", handlers.Injection.ListProjectInjections) + } + + injectionExecute := injections.Group("", middleware.RequireProjectInjectionExecute) + { + injectionExecute.POST("/inject", handlers.Injection.SubmitProjectFaultInjection) + injectionExecute.POST("/build", handlers.Injection.SubmitProjectDatapackBuilding) + } + } + + executions := projects.Group("/:project_id/executions") + { + executionRead := executions.Group("", middleware.RequireProjectRead) + { + executionRead.GET("", handlers.Execution.ListProjectExecutions) + } + + executionExecute := executions.Group("", middleware.RequireProjectExecutionExecute) + { + executionExecute.POST("/execute", handlers.Execution.SubmitAlgorithmExecution) + } + } + } + + evaluations := v2.Group("/evaluations", middleware.JWTAuth()) + { + evaluations.POST("/datapacks", middleware.RequireAPIKeyScopesAny("sdk:*", "sdk:evaluations:*", "sdk:evaluations:read"), handlers.Evaluation.ListDatapackEvaluationResults) + evaluations.POST("/datasets", middleware.RequireAPIKeyScopesAny("sdk:*", "sdk:evaluations:*", "sdk:evaluations:read"), handlers.Evaluation.ListDatasetEvaluationResults) + evaluations.GET("", middleware.RequireAPIKeyScopesAny("sdk:*", "sdk:evaluations:*", "sdk:evaluations:read"), handlers.Evaluation.ListEvaluations) + evaluations.GET("/:id", middleware.RequireAPIKeyScopesAny("sdk:*", "sdk:evaluations:*", "sdk:evaluations:read"), handlers.Evaluation.GetEvaluation) + } + + executions := v2.Group("/executions", middleware.JWTAuth()) + { + executions.GET("/:id", middleware.RequireAPIKeyScopesAny("sdk:*", "sdk:executions:*", "sdk:executions:read"), handlers.Execution.GetExecution) + executions.PATCH("/:id/labels", middleware.RequireAPIKeyScopesAny("sdk:*", "sdk:executions:*", "sdk:executions:write"), handlers.Execution.ManageExecutionCustomLabels) + } + + runtime := v2.Group("/executions", middleware.RequireServiceTokenAuth()) + { + runtime.POST("/:execution_id/detector_results", handlers.Execution.UploadDetectorResults) + runtime.POST("/:execution_id/granularity_results", handlers.Execution.UploadGranularityResults) + } + + injections := v2.Group("/injections", middleware.JWTAuth()) + { + injections.GET("/metadata", handlers.Injection.GetInjectionMetadata) + injections.GET("/systems", handlers.Injection.GetSystemMapping) + injections.POST("/translate", handlers.Injection.TranslateFaultSpecs) + injections.GET("/:id", handlers.Injection.GetInjection) + injections.POST("/:id/clone", handlers.Injection.CloneInjection) + injections.GET("/:id/download", handlers.Injection.DownloadDatapack) + injections.GET("/:id/files", handlers.Injection.ListDatapackFiles) + injections.GET("/:id/files/download", handlers.Injection.DownloadDatapackFile) + injections.GET("/:id/files/query", handlers.Injection.QueryDatapackFile) + injections.PATCH("/:id/labels", handlers.Injection.ManageInjectionCustomLabels) + } + + metrics := v2.Group("/metrics", middleware.JWTAuth()) + { + metrics.GET("/algorithms", middleware.RequireAPIKeyScopesAny("sdk:*", "sdk:metrics:*", "sdk:metrics:read"), handlers.Metric.GetAlgorithmMetrics) + metrics.GET("/executions", middleware.RequireAPIKeyScopesAny("sdk:*", "sdk:metrics:*", "sdk:metrics:read"), handlers.Metric.GetExecutionMetrics) + metrics.GET("/injections", middleware.RequireAPIKeyScopesAny("sdk:*", "sdk:metrics:*", "sdk:metrics:read"), handlers.Metric.GetInjectionMetrics) + } +} diff --git a/src/router/system.go b/src/router/system.go deleted file mode 100644 index 9db9644d..00000000 --- a/src/router/system.go +++ /dev/null @@ -1,49 +0,0 @@ -package router - -import ( - "aegis/handlers/system" - "aegis/middleware" - - "github.com/gin-gonic/gin" -) - -// SetupSystemRoutes sets up system routes -func SetupSystemRoutes(router *gin.Engine) { - audit := router.Group("/system/audit", middleware.JWTAuth(), middleware.RequireAuditRead) - { - audit.GET("", system.ListAuditLogs) - audit.GET("/:id", system.GetAuditLog) - } - - // Dynamic Configuration Management - configs := router.Group("/system/configs", middleware.JWTAuth()) - { - configsRead := configs.Group("", middleware.RequireConfigurationRead) - { - configsRead.GET("", system.ListConfigs) // Search configurations with filters - configsRead.GET("/:config_id", system.GetConfig) // Get configuration by ID - configsRead.GET("/:config_id/histories", system.ListConfigHistories) // Get configuration change history - } - - // Configuration Update operations - configs.PATCH("/:config_id", middleware.RequireConfigurationUpdate, system.UpdateConfigValue) // Update configuration value - configs.POST("/:config_id/value/rollback", middleware.RequireConfigurationUpdate, system.RollbackConfigValue) // Rollback configuration value - - // Configuration Configure operations (metadata management, higher privilege) - configs.PUT("/:config_id/metadata", middleware.RequireConfigurationConfigure, system.UpdateConfigMetadata) // Update configuration metadata (schema) - configs.POST("/:config_id/metadata/rollback", middleware.RequireConfigurationConfigure, system.RollbackConfigMetadata) // Rollback configuration metadata - } - - health := router.Group("/system/health") - { - health.GET("", system.GetHealth) - } - - monitor := router.Group("/system/monitor", middleware.JWTAuth(), middleware.RequireSystemRead) - { - monitor.POST("/metrics", system.GetMetrics) - monitor.GET("/info", system.GetSystemInfo) - monitor.GET("/namespaces/locks", system.ListNamespaceLocks) - monitor.GET("/tasks/queue", system.ListQueuedTasks) - } -} diff --git a/src/router/v2.go b/src/router/v2.go deleted file mode 100644 index 15a959a1..00000000 --- a/src/router/v2.go +++ /dev/null @@ -1,698 +0,0 @@ -package router - -import ( - v2handlers "aegis/handlers/v2" - "aegis/middleware" - - "github.com/gin-gonic/gin" -) - -/* -=================================================================================== -API v2 Design Specification - RESTful API Standard -=================================================================================== - -v2 API strictly adheres to RESTful design principles, contrasting with the disorganized design of v1. -v1 API design was rather arbitrary, with non-standard methods and paths. v2 will uniformly follow the standards below. - -📋 HTTP Method Usage Specification: -- GET : Query resources (idempotent, cacheable) -- POST : Create resources / Complex queries (non-idempotent) -- PUT : Full update of resources (idempotent) -- PATCH : Partial update of resources (idempotent) -- DELETE : Delete resources (idempotent) - -🎯 URL Design Specification: -1. Resource names use plural form - ✅ GET /api/v2/users ❌ GET /api/v2/user - ✅ GET /api/v2/projects ❌ GET /api/v2/project - -2. Clear hierarchical relationships - ✅ GET /api/v2/users/{id}/projects - ✅ GET /api/v2/projects/{id}/members - -3. Query parameter specification - ✅ GET /api/v2/users?page=1&size=10&status=active - ✅ GET /api/v2/tasks?project_id=123&type=injection - -📊 Standard CRUD Operation Modes: -- GET /api/v2/{resource} # List query (supports pagination, filtering, sorting) -- POST /api/v2/{resource} # Create resource -- GET /api/v2/{resource}/{id} # Get single resource details -- PUT /api/v2/{resource}/{id} # Full update of resource -- PATCH : Partial update of resource (idempotent) -- DELETE : Delete resource (idempotent) - -🔍 Complex Query Handling: -For complex search conditions, use dedicated search endpoints: -- POST /api/v2/{resource}/search # Complex condition search -- POST /api/v2/{resource}/query # Advanced query -- POST /api/v2/{resource}/batch # Batch operations - -🎨 Business Operation Endpoints: -Semantic business operations use verb forms: -- POST /api/v2/users/{id}/activate # Activate user -- POST /api/v2/tasks/{id}/cancel # Cancel task -- POST /api/v2/injections/{id}/start # Start fault injection -- POST /api/v2/containers/{id}/build # Build container - -📨 Response Format Specification: -1. Successful Response: - { - "code": 200, - "message": "success", - "data": {...}, - "timestamp": "2024-01-01T12:00:00Z" - } - -2. List Response: - { - "code": 200, - "message": "success", - "data": { - "items": [...], - "pagination": { - "page": 1, - "size": 10, - "total": 100, - "pages": 10 - } - } - } - -3. Error Response: - { - "code": 400, - "message": "validation failed", - "errors": ["field xxx is required"], - "timestamp": "2024-01-01T12:00:00Z" - } - -🔐 Authentication and Authorization Specification: -- Use JWT Bearer Token authentication -- Permission checks based on RBAC model -- Sensitive operations require secondary confirmation - -⚡ Performance Optimization: -- GET requests support ETag caching -- List queries default to pagination (page=1, size=20) -- Supports field selection ?fields=id,name,status -- Supports associated queries ?include=project,labels - -🔄 Version Compatibility: -- v2 API ensures backward compatibility -- Deprecated endpoints provide a 6-month transition period -- Major changes handled by new version numbers - -Note: v1 API design is chaotic and does not follow a unified standard. It will gradually migrate to v2 specification later. -=================================================================================== -*/ - -// SetupV2Routes sets up API v2 routes - stable version of the API -func SetupV2Routes(router *gin.Engine) { - middleware.StartCleanupRoutine() - - v2 := router.Group("/api/v2") - // Authentication routes (with auth rate limiting) - auth := v2.Group("/auth") - { - auth.POST("/login", v2handlers.Login) // User login - auth.POST("/register", v2handlers.Register) // User registration - auth.POST("/refresh", v2handlers.RefreshToken) // Token refresh - - // These require authentication - authProtected := auth.Group("", middleware.JWTAuth()) - { - authProtected.POST("/logout", v2handlers.Logout) // User logout - authProtected.POST("/change-password", v2handlers.ChangePassword) // Change password - authProtected.GET("/profile", v2handlers.GetProfile) // Get current user profile - } - } - - // ===================================================================== - // Admin Entity API Group - // ===================================================================== - - // Container Management - Container Entity - containers := v2.Group("/containers", middleware.JWTAuth()) - { - // Container Version sub-resource routes - versions := containers.Group("/:container_id/versions") - { - - // Container Version Read operations - versionRead := versions.Group("", middleware.RequireContainerVersionRead) - { - versionRead.GET("/:version_id", v2handlers.GetContainerVersion) // Get container version by ID - versionRead.GET("", v2handlers.ListContainerVersions) // List container versions - } - - // Container Version Create operations - versions.POST("", middleware.RequireContainerVersionCreate, v2handlers.CreateContainerVersion) // Create container version - - // Container Version Upload operations - versions.POST("/:version_id/helm-chart", middleware.RequireContainerVersionUpload, v2handlers.UploadHelmChart) // Upload Helm chart tgz file - versions.POST("/:version_id/helm-values", middleware.RequireContainerVersionUpload, v2handlers.UploadHelmValueFile) // Upload Helm values file - - // Container Version Update operations - versions.PATCH("/:version_id", middleware.RequireContainerVersionUpdate, v2handlers.UpdateContainerVersion) // Update container version - - // Container Version Delete operations - versions.DELETE("/:version_id", middleware.RequireContainerVersionDelete, v2handlers.DeleteContainerVersion) - } - - // Container Read operations - containerRead := containers.Group("", middleware.RequireContainerRead) - { - containerRead.GET("/:container_id", v2handlers.GetContainer) // Get container by ID - containerRead.GET("", v2handlers.ListContainers) // List containers - } - - // Container Create operations - containers.POST("", middleware.RequireContainerCreate, v2handlers.CreateContainer) // Create container - - // Container Execute operations (build requires execute permission) - containers.POST("/build", middleware.RequireContainerExecute, v2handlers.SubmitContainerBuilding) // Build container - - // Container Update operations - containers.PATCH("/:container_id", middleware.RequireContainerUpdate, v2handlers.UpdateContainer) // Update container - containers.PATCH("/:container_id/labels", middleware.RequireContainerUpdate, v2handlers.ManageContainerCustomLabels) // Manage container labels - - // Container Delete operations - containers.DELETE("/:container_id", middleware.RequireContainerDelete, v2handlers.DeleteContainer) // Delete container - } - - // Container Version flat resource — direct-by-version-id operations without - // the parent container id in the URL. Used by aegisctl `container version - // set-image` to rewrite the four image-reference columns of a single row. - containerVersions := v2.Group("/container-versions", middleware.JWTAuth()) - { - containerVersions.PATCH("/:id/image", middleware.RequireContainerVersionUpdate, v2handlers.SetContainerVersionImage) - } - - // Dataset Management - Dataset Entity - datasets := v2.Group("/datasets", middleware.JWTAuth()) - { - // Dataset Version sub-resource routes - versions := datasets.Group("/:dataset_id/versions") - { - versionRead := versions.Group("", middleware.RequireDatasetVersionRead) - { - versionRead.GET("", v2handlers.ListDatasetVersions) // List dataset versions - versionRead.GET("/:version_id", v2handlers.GetDatasetVersion) // Get dataset version by ID - versionRead.GET("/:version_id/download", v2handlers.DownloadDatasetVersion) // Download dataset version - } - - // Dataset Version Create operations - versions.POST("", middleware.RequireDatasetVersionCreate, v2handlers.CreateDatasetVersion) // Create dataset version - - // Dataset Version Update operations - versions.PATCH("/:version_id", middleware.RequireDatasetVersionUpdate, v2handlers.UpdateDatasetVersion) // Update dataset version - versions.PATCH("/:version_id/injections", middleware.RequireDatasetVersionUpdate, v2handlers.ManageDatasetVersionInjections) // Manage dataset version injections - - versions.DELETE("/:version_id", middleware.RequireDatasetVersionDelete, v2handlers.DeleteDatasetVersion) // Delete dataset version - } - - // Dataset Read operations - datasetRead := datasets.Group("", middleware.RequireDatasetRead) - { - datasetRead.GET("/:dataset_id", v2handlers.GetDataset) // Get dataset by ID - datasetRead.GET("", v2handlers.ListDatasets) // List datasets - } - - // Dataset Create operations - datasets.POST("", middleware.RequireDatasetCreate, v2handlers.CreateDataset) // Create dataset - - // Dataset Update operations - datasets.PATCH("/:dataset_id", middleware.RequireDatasetUpdate, v2handlers.UpdateDataset) // Update dataset - datasets.PATCH("/:dataset_id/labels", middleware.RequireDatasetUpdate, v2handlers.ManageDatasetCustomLabels) // Manage dataset labels - - // Dataset Delete operations - datasets.DELETE("/:dataset_id", middleware.RequireDatasetDelete, v2handlers.DeleteDataset) // Delete dataset - } - - // Project Management - Project Entity - projects := v2.Group("/projects", middleware.JWTAuth()) - { - injections := projects.Group("/:project_id/injections") - { - injectionRead := injections.Group("", middleware.RequireProjectRead) - { - analysis := injectionRead.Group("/analysis") - { - analysis.GET("/no-issues", v2handlers.ListFaultInjectionNoIssues) - analysis.GET("/with-issues", v2handlers.ListFaultInjectionWithIssues) - } - - injectionRead.GET("", v2handlers.ListProjectInjections) - injectionRead.POST("/search", v2handlers.SearchInjections) - } - - injectionExecute := injections.Group("", middleware.RequireProjectInjectionExecute) - { - injectionExecute.POST("/inject", v2handlers.SubmitProjectFaultInjection) - injectionExecute.POST("/build", v2handlers.SubmitProjectDatapackBuilding) - } - } - - executions := projects.Group("/:project_id/executions") - { - executionRead := executions.Group("", middleware.RequireProjectRead) - { - executionRead.GET("", v2handlers.ListProjectExecutions) - } - - executionExecute := executions.Group("", middleware.RequireProjectExecutionExecute) - { - executionExecute.POST("/execute", v2handlers.SubmitAlgorithmExecution) - } - } - - // Project Read operations - projectRead := projects.Group("", middleware.RequireProjectRead) - { - projectRead.GET("/:project_id", v2handlers.GetProjectDetail) // Get project by ID - projectRead.GET("", v2handlers.ListProjects) // List projects - } - - // Project Create operations - projects.POST("", middleware.RequireProjectCreate, v2handlers.CreateProject) // Create project - - // Project Update operations - projects.PATCH("/:project_id", middleware.RequireProjectUpdate, v2handlers.UpdateProject) // Update project - projects.PATCH("/:project_id/labels", middleware.RequireProjectUpdate, v2handlers.ManageProjectCustomLabels) // Manage project labels - - // Project Delete operations - projects.DELETE("/:project_id", middleware.RequireProjectDelete, v2handlers.DeleteProject) // Delete project - } - - // Team Management - Team Entity - teams := v2.Group("/teams", middleware.JWTAuth()) - { - // Anyone can create a team (will become team admin automatically) - teams.POST("", v2handlers.CreateTeam) - - // List teams - returns public teams + user's teams (no special permission needed) - teams.GET("", v2handlers.ListTeams) - - // Team Write/Delete/Manage operations - requires team admin OR system admin - TeamAdmin := teams.Group("/:team_id", middleware.RequireTeamAdminAccess) - { - TeamAdmin.PATCH("", v2handlers.UpdateTeam) // Update team - TeamAdmin.DELETE("", v2handlers.DeleteTeam) // Delete team - - // Team Member Management - only team admins can manage members - TeamManagement := TeamAdmin.Group("/members") - TeamManagement.POST("", v2handlers.AddTeamMember) // Add team member - TeamManagement.DELETE("/:user_id", v2handlers.RemoveTeamMember) // Remove team member - TeamManagement.PATCH("/:user_id/role", v2handlers.UpdateTeamMemberRole) // Update team member role - } - - // Team Read operations - requires being a member OR team is public OR system admin - TeamMember := teams.Group("", middleware.RequireTeamMemberAccess) - { - TeamMember.GET("/:team_id", v2handlers.GetTeamDetail) // Get team by ID - TeamMember.GET("/:team_id/members", v2handlers.ListTeamMembers) // List team members - TeamMember.GET("/:team_id/projects", v2handlers.ListTeamProjects) // List team projects - } - } - - // Label Management - Label Entity - labels := v2.Group("/labels", middleware.JWTAuth()) - { - // Label Read operations - labelRead := labels.Group("", middleware.RequireLabelRead) - { - labelRead.GET("/:label_id", v2handlers.GetLabelDetail) // Get label by ID - labelRead.GET("", v2handlers.ListLabels) // List labels - } - - // Label Create operations - labels.POST("", middleware.RequireLabelCreate, v2handlers.CreateLabel) // Create label - - // Label Update operations - labels.PATCH("/:label_id", middleware.RequireLabelUpdate, v2handlers.UpdateLabel) // Update label - - // Label Delete operations - labels.DELETE("/:label_id", middleware.RequireLabelDelete, v2handlers.DeleteLabel) // Delete label - labels.POST("/batch-delete", middleware.RequireLabelDelete, v2handlers.BatchDeleteLabels) // Batch delete labels - } - - // User Management - User Entity - users := v2.Group("/users", middleware.JWTAuth()) - { - // User-Role relationship routes (assign roles requires assign permission) - roles := users.Group("/:user_id/roles") - { - roles.POST("/:role_id", middleware.RequireUserAssign, v2handlers.AssignUserRole) // Assign role to user - roles.DELETE("/:role_id", middleware.RequireUserAssign, v2handlers.RemoveGlobalRole) // Remove role from user - } - - // User-Project relationship routes (assign requires assign permission) - projects := users.Group("/:user_id/projects") - { - projects.POST("/:project_id/roles/:role_id", middleware.RequireUserAssign, v2handlers.AssignUserProject) // Assign user to project - projects.DELETE("/:project_id", middleware.RequireUserAssign, v2handlers.RemoveUserProject) // Remove user from project - } - - // User-Permission relationship routes (assign requires assign permission) - permissions := users.Group("/:user_id/permissions") - { - permissions.POST("/assign", middleware.RequireUserAssign, v2handlers.AssignUserPermission) // Assign permission to user - permissions.POST("/remove", middleware.RequireUserAssign, v2handlers.RemoveUserPermission) // Remove permission from user - } - - // User-Container relationship routes (assign requires assign permission) - containers := users.Group("/:user_id/containers") - { - containers.POST("/:container_id/roles/:role_id", middleware.RequireUserAssign, v2handlers.AssignUserContainer) // Assign container to user - containers.DELETE("/:container_id", middleware.RequireUserAssign, v2handlers.RemoveUserContainer) // Remove container from user - } - - // User-Dataset relationship routes (assign requires assign permission) - datasets := users.Group("/:user_id/datasets") - { - datasets.POST("/:dataset_id/roles/:role_id", middleware.RequireUserAssign, v2handlers.AssignUserDataset) // Assign dataset to user - datasets.DELETE("/:dataset_id", middleware.RequireUserAssign, v2handlers.RemoveUserDataset) // Remove dataset from user - } - - // User Read operations - userRead := users.Group("", middleware.RequireUserRead) - { - userRead.GET("", v2handlers.ListUsersV2) // List users - userRead.GET("/:user_id/detail", middleware.RequireAdminOrUserOwnership, v2handlers.GetUserDetailV2) // Get user by ID - } - - // User Create operations - users.POST("", middleware.RequireUserCreate, v2handlers.CreateUser) // Create user - - // User Update operations - users.PATCH("/:user_id", middleware.RequireUserUpdate, v2handlers.UpdateUser) // Update user - - // User Delete operations - users.DELETE("/:user_id", middleware.RequireUserDelete, v2handlers.DeleteUser) // Delete user - } - - // ===================================================================== - // Authentication and Authorization API Group - // ===================================================================== - - // Role Management - Role Entity - roles := v2.Group("/roles", middleware.JWTAuth()) - { - // Role-Permission relationship routes (grant/revoke) - permissions := roles.Group("/:role_id/permissions") - { - permissions.POST("/assign", middleware.RequireRoleGrant, v2handlers.AssignRolePermission) // Assign permissions to role - permissions.POST("/remove", middleware.RequireRoleRevoke, v2handlers.RemovePermissionsFromRole) // Remove permissions from role - } - - // Role-User relationship routes - users := roles.Group("/:role_id/users") - { - users.GET("", middleware.RequireRoleRead, v2handlers.ListUsersFromRole) // List users with this role - } - - // Role Read operations - roleRead := roles.Group("", middleware.RequireRoleRead) - { - roleRead.GET("/:role_id", v2handlers.GetRole) // Get role by ID - roleRead.GET("", v2handlers.ListRoles) // List roles - } - - // Role Create operations - roles.POST("", middleware.RequireRoleCreate, v2handlers.CreateRole) // Create role - - // Role Update operations - roles.PATCH("/:role_id", middleware.RequireRoleUpdate, v2handlers.UpdateRole) // Update role - - // Role Delete operations - roles.DELETE("/:role_id", middleware.RequireRoleDelete, v2handlers.DeleteRole) // Delete role - } - - // Permission Management - Permission Entity - permissions := v2.Group("/permissions", middleware.JWTAuth()) - { - // Permission-Role relationship routes - roles := permissions.Group("/:permission_id/roles") - { - roles.GET("", middleware.RequirePermissionRead, v2handlers.ListRolesFromPermission) // List roles assigned to permission - } - - // Permission Read operations - permRead := permissions.Group("", middleware.RequirePermissionRead) - { - permRead.GET("", v2handlers.ListPermissions) // List permissions - permRead.GET("/:permission_id", v2handlers.GetPermission) // Get permission by ID - } - } - - // Resource Management - Resource Entity - resources := v2.Group("/resources", middleware.JWTAuth()) - { - // Resource-Permission relationship routes - permissions := resources.Group("/:resource_id/permissions") - { - permissions.GET("", v2handlers.ListResourcePermissions) // List permissions assigned to resource - } - - // Resource Read operations - resources.GET("/:resource_id", v2handlers.GetResourceDetail) // Get resource by ID - resources.GET("", v2handlers.ListResources) // List resources - } - - // ===================================================================== - // Core Business Entity API Group - // ===================================================================== - - // Task Management - Task Entity - tasks := v2.Group("/tasks") - { - taskWithAuth := tasks.Group("", middleware.JWTAuth()) - { - - // Task Read operations - taskRead := taskWithAuth.Group("", middleware.RequireTaskRead) - { - taskRead.GET("", v2handlers.ListTasks) // List tasks - taskRead.GET("/:task_id", v2handlers.GetTask) // Get task by ID - } - - // Task Delete operations - taskWithAuth.POST("/batch-delete", middleware.RequireTaskDelete, v2handlers.BatchDeleteTasks) // Batch delete tasks - - // Task Update/Execute operations - taskWithAuth.POST("/:task_id/expedite", middleware.RequireTaskExecute, v2handlers.ExpediteTask) // Expedite pending task - } - - // Task Log streaming (WebSocket) - auth via query param, not middleware - tasks.GET("/:task_id/logs/ws", v2handlers.GetTaskLogsWS) // Stream task logs via WebSocket - } - - // Fault Injection Management - FaultInjectionSchedule Entity - // Note: These global routes are for system admins only. Regular users should access injections via /projects/:project_id - injections := v2.Group("/injections", middleware.JWTAuth()) - { - injectionSystemAdmin := injections.Group("", middleware.RequireSystemAdmin()) - { - injectionSystemAdmin.GET("", v2handlers.ListInjections) // List injections - injectionSystemAdmin.POST("/search", v2handlers.SearchInjections) // Advanced search - } - - // Manual upload (must be before /:id routes) - injections.POST("/upload", v2handlers.UploadDatapack) // Upload manual datapack - - // DSL translation endpoints (must be before /:id routes) - injections.GET("/systems", v2handlers.GetSystemMapping) // Get system type mapping - injections.POST("/translate", v2handlers.TranslateFaultSpecs) // Translate fault specs to Nodes - - // Injection Read operations - injections.GET("/:id", v2handlers.GetInjection) // Get injection by ID - injections.GET("/:id/download", v2handlers.DownloadDatapack) // Download injection datapack - injections.GET("/:id/logs", v2handlers.GetInjectionLogs) // Get injection execution logs - injections.GET("/:id/files", v2handlers.ListDatapackFiles) // Get injection file structure - injections.GET("/:id/files/download", v2handlers.DownloadDatapackFile) // Download specific injection file - injections.GET("/:id/files/query", v2handlers.QueryDatapackFile) // Query parquet file content - injections.GET("/metadata", v2handlers.GetInjectionMetadata) // Get injection metadata - - // Injection Clone operations - injections.POST("/:id/clone", v2handlers.CloneInjection) // Clone injection - - // Injection Update operations (label management, ground truth) - injections.PUT("/:id/groundtruth", v2handlers.UpdateGroundtruth) // Update ground truth - injections.PATCH("/:id/labels", v2handlers.ManageInjectionCustomLabels) // Manage injection custom labels - injections.PATCH("/labels/batch", v2handlers.BatchManageInjectionLabels) // Batch manage injection labels - - // Injection Delete operations - injections.POST("/batch-delete", v2handlers.BatchDeleteInjections) // Batch delete injections - } - - // Execution Result Management - ExecutionResult Entity - // Note: These global routes are for system admins only. Regular users should access executions via /projects/:project_id - executions := v2.Group("/executions", middleware.JWTAuth()) - { - executionSystemAdmin := executions.Group("", middleware.RequireSystemAdmin()) - { - executionSystemAdmin.GET("", v2handlers.ListExecutions) // List executions - executionSystemAdmin.GET("/labels", v2handlers.ListAvaliableExecutionLabels) // List available execution labels - } - - // Execution Read operations - executions.GET("/:execution_id", v2handlers.GetExecution) // Get execution by ID - - // Execution Update operations (upload results and manage labels) - executions.POST("/:execution_id/detector_results", v2handlers.UploadDetectorResults) // Upload detector results - executions.POST("/:execution_id/granularity_results", v2handlers.UploadGranularityResults) // Upload granularity results - executions.PATCH("/:execution_id/labels", v2handlers.ManageExecutionCustomLabels) // Manage execution custom labels - - // Execution Delete operations - executions.POST("/batch-delete", v2handlers.BatchDeleteExecutions) // Batch delete executions - } - - // Trace Management - Trace Entity - traces := v2.Group("/traces", middleware.JWTAuth()) - { - traces.GET("", v2handlers.ListTraces) // List traces - traces.GET("/:trace_id", v2handlers.GetTrace) // Get trace by ID - traces.GET("/:trace_id/stream", v2handlers.GetTraceStream) // Get trace stream (SSE) - } - - // Group Management - Group stream for real-time batch progress - groups := v2.Group("/groups", middleware.JWTAuth()) - { - groups.GET("/:group_id/stats", v2handlers.GetAlgorithmMetrics) // Get group stats (can be used for progress tracking) - groups.GET("/:group_id/stream", v2handlers.GetGroupStream) // Stream group trace events (SSE) - } - - // ===================================================================== - // Notification API Group - // ===================================================================== - - // Notification Management - Global workflow notifications - notifications := v2.Group("/notifications", middleware.JWTAuth()) - { - notifications.GET("/stream", v2handlers.GetNotificationStream) // Stream global notifications (SSE) - } - - // ===================================================================== - // Analyzer Service API Group - // ===================================================================== - - // Analyzer related routes (placeholder for future expansion) - analyzer := v2.Group("/analyzer", middleware.JWTAuth()) - _ = analyzer // Temporarily unused to avoid compilation errors - - // ===================================================================== - // Evaluation API Group - // ===================================================================== - - // Evaluation API Group - evaluations := v2.Group("/evaluations", middleware.JWTAuth()) - { - // GET /api/v2/evaluations - List persisted evaluations with pagination - evaluations.GET("", v2handlers.ListEvaluations) - - // GET /api/v2/evaluations/:id - Get a single evaluation by ID - evaluations.GET("/:id", v2handlers.GetEvaluation) - - // DELETE /api/v2/evaluations/:id - Delete an evaluation by ID - evaluations.DELETE("/:id", v2handlers.DeleteEvaluation) - - // POST /api/v2/evaluations/datasets - Get algorithm evaluations on multiple datasets (requires dataset read permission) - evaluations.POST("/datasets", middleware.RequireDatasetRead, v2handlers.ListDatasetEvaluationResults) - - // POST /api/v2/evaluations/datapacks - Get algorithm evaluations on multiple datapacks (requires dataset read permission) - evaluations.POST("/datapacks", middleware.RequireDatasetRead, v2handlers.ListDatapackEvaluationResults) - } - - // ===================================================================== - // SDK Evaluation API Group (read-only access to Python SDK tables) - // ===================================================================== - - sdkEval := v2.Group("/sdk/evaluations", middleware.JWTAuth()) - { - sdkEval.GET("", v2handlers.ListSDKEvaluations) - sdkEval.GET("/experiments", v2handlers.ListSDKExperiments) - sdkEval.GET("/:id", v2handlers.GetSDKEvaluation) - } - - sdkData := v2.Group("/sdk/datasets", middleware.JWTAuth()) - { - sdkData.GET("", v2handlers.ListSDKDatasetSamples) - } - - // ===================================================================== - // Metrics API Group - // ===================================================================== - - // Metrics routes - metrics := v2.Group("/metrics", middleware.JWTAuth()) - { - metrics.GET("/injections", v2handlers.GetInjectionMetrics) // Get injection metrics - metrics.GET("/executions", v2handlers.GetExecutionMetrics) // Get execution metrics - metrics.GET("/algorithms", v2handlers.GetAlgorithmMetrics) // Get algorithm comparison metrics - } - - // ===================================================================== - // Pedestal Helm Config API Group - // ===================================================================== - // - // CRUD + dry-run verification over the helm_configs table, keyed by - // container_version_id. Used by `aegisctl pedestal helm` to fix bad - // repo URLs without running `mysql -e UPDATE` and without triggering - // a real restart_pedestal task. - pedestal := v2.Group("/pedestal", middleware.JWTAuth()) - { - helm := pedestal.Group("/helm") - { - helm.GET("/:container_version_id", v2handlers.GetPedestalHelmConfig) - helm.POST("/:container_version_id/verify", v2handlers.VerifyPedestalHelmConfig) - // Mutating route — admin/upload permission (same tier as helm-chart upload). - helm.PUT("/:container_version_id", middleware.RequireContainerVersionUpload, v2handlers.UpsertPedestalHelmConfig) - } - } - - // ===================================================================== - // Chaos Systems API Group - // ===================================================================== - - // Chaos System Management - System Entity - systems := v2.Group("/systems", middleware.JWTAuth()) - { - systems.GET("", v2handlers.ListChaosSystemsHandler) - systems.POST("", v2handlers.CreateChaosSystemHandler) - systems.GET("/:id", v2handlers.GetChaosSystemHandler) - systems.PUT("/:id", v2handlers.UpdateChaosSystemHandler) - systems.DELETE("/:id", v2handlers.DeleteChaosSystemHandler) - systems.POST("/:id/metadata", v2handlers.UpsertChaosSystemMetadataHandler) - systems.GET("/:id/metadata", v2handlers.ListChaosSystemMetadataHandler) - } - - // ===================================================================== - // System Metrics API Group - // ===================================================================== - - // System metrics routes - system := v2.Group("/system", middleware.JWTAuth()) - { - system.GET("/metrics", v2handlers.GetSystemMetrics) // Get current system metrics - system.GET("/metrics/history", v2handlers.GetSystemMetricsHistory) // Get historical system metrics - } - - // ===================================================================== - // Rate Limiter Admin API Group (OperationsPAI/aegis#21) - // ===================================================================== - - rateLimiters := v2.Group("/rate-limiters", middleware.JWTAuth()) - { - // status: any authenticated user - rateLimiters.GET("", v2handlers.ListRateLimiters) - - // reset + gc: system admin only - rateLimiterAdmin := rateLimiters.Group("", middleware.RequireSystemAdmin()) - { - rateLimiterAdmin.DELETE("/:bucket", v2handlers.ResetRateLimiter) - rateLimiterAdmin.POST("/gc", v2handlers.GCRateLimiters) - } - } -} diff --git a/src/searchx/query_builder.go b/src/searchx/query_builder.go new file mode 100644 index 00000000..63c5baef --- /dev/null +++ b/src/searchx/query_builder.go @@ -0,0 +1,248 @@ +package searchx + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + + "aegis/dto" + + "gorm.io/gorm" +) + +// QueryBuilder provides methods to build complex database queries from SearchRequest. +type QueryBuilder[F ~string] struct { + db *gorm.DB + query *gorm.DB + allowedSortFields map[F]string +} + +func NewQueryBuilder[F ~string](db *gorm.DB, allowedSortFields map[F]string) *QueryBuilder[F] { + return &QueryBuilder[F]{ + db: db, + query: db, + allowedSortFields: allowedSortFields, + } +} + +func (qb *QueryBuilder[F]) ApplySearchReq(filters []dto.SearchFilter, keyword string, sortOptions []dto.TypedSortOption[F], groupBy []F, modelType any) *gorm.DB { + qb.query = qb.db.Model(modelType) + qb.applyFilters(filters) + if keyword != "" { + qb.applyKeywordSearch(keyword, modelType) + } + qb.applySorting(sortOptions, groupBy) + return qb.query +} + +func (qb *QueryBuilder[F]) ApplyIncludes(includes []string) { + for _, include := range includes { + qb.query = qb.query.Preload(include) + } +} + +func (qb *QueryBuilder[F]) ApplyIncludeFields(includeFields []string) { + for _, field := range includeFields { + qb.query = qb.query.Select(field) + } +} + +func (qb *QueryBuilder[F]) ApplyExcludeFields(excludeFields []string, modelType any) { + t := reflect.TypeOf(modelType) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + var allFields []string + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + dbTag := field.Tag.Get("gorm") + if dbTag != "" { + dbField := strings.Split(dbTag, ";")[0] + allFields = append(allFields, dbField) + continue + } + allFields = append(allFields, field.Name) + } + + fieldsToSelect := make([]string, 0, len(allFields)) + excludeMap := make(map[string]struct{}, len(excludeFields)) + for _, field := range excludeFields { + excludeMap[field] = struct{}{} + } + for _, field := range allFields { + if _, excluded := excludeMap[field]; !excluded { + fieldsToSelect = append(fieldsToSelect, field) + } + } + if len(fieldsToSelect) > 0 { + qb.query = qb.query.Select(strings.Join(fieldsToSelect, ", ")) + } +} + +func (qb *QueryBuilder[F]) GetCount() (int64, error) { + var count int64 + err := qb.query.Count(&count).Error + return count, err +} + +func (qb *QueryBuilder[F]) Query() *gorm.DB { + return qb.query +} + +func (qb *QueryBuilder[F]) applyFilters(filters []dto.SearchFilter) { + for _, filter := range filters { + qb.applySingleFilter(filter) + } +} + +func (qb *QueryBuilder[F]) applyKeywordSearch(keyword string, modelType any) { + searchableFields := qb.getSearchableFields(modelType) + if len(searchableFields) == 0 { + return + } + + var conditions []string + var values []any + for _, field := range searchableFields { + conditions = append(conditions, fmt.Sprintf("%s LIKE ?", field)) + values = append(values, "%"+keyword+"%") + } + + qb.query = qb.query.Where(strings.Join(conditions, " OR "), values...) +} + +func (qb *QueryBuilder[F]) applySingleFilter(filter dto.SearchFilter) { + field := qb.sanitizeFieldName(filter.Field) + if field == "" { + return + } + + switch filter.Operator { + case dto.OpEqual: + qb.query = qb.query.Where(fmt.Sprintf("%s = ?", field), filter.Value) + case dto.OpNotEqual: + qb.query = qb.query.Where(fmt.Sprintf("%s != ?", field), filter.Value) + case dto.OpGreater: + qb.query = qb.query.Where(fmt.Sprintf("%s > ?", field), filter.Value) + case dto.OpGreaterEq: + qb.query = qb.query.Where(fmt.Sprintf("%s >= ?", field), filter.Value) + case dto.OpLess: + qb.query = qb.query.Where(fmt.Sprintf("%s < ?", field), filter.Value) + case dto.OpLessEq: + qb.query = qb.query.Where(fmt.Sprintf("%s <= ?", field), filter.Value) + case dto.OpLike: + qb.query = qb.query.Where(fmt.Sprintf("%s LIKE ?", field), "%"+fmt.Sprintf("%v", filter.Value)+"%") + case dto.OpStartsWith: + qb.query = qb.query.Where(fmt.Sprintf("%s LIKE ?", field), fmt.Sprintf("%v", filter.Value)+"%") + case dto.OpEndsWith: + qb.query = qb.query.Where(fmt.Sprintf("%s LIKE ?", field), "%"+fmt.Sprintf("%v", filter.Value)) + case dto.OpNotLike: + qb.query = qb.query.Where(fmt.Sprintf("%s NOT LIKE ?", field), "%"+fmt.Sprintf("%v", filter.Value)+"%") + case dto.OpIn: + if values := resolveMultiValues(filter); len(values) > 0 { + qb.query = qb.query.Where(fmt.Sprintf("%s IN (?)", field), values) + } + case dto.OpNotIn: + if values := resolveMultiValues(filter); len(values) > 0 { + qb.query = qb.query.Where(fmt.Sprintf("%s NOT IN (?)", field), values) + } + case dto.OpIsNull: + qb.query = qb.query.Where(fmt.Sprintf("%s IS NULL", field)) + case dto.OpIsNotNull: + qb.query = qb.query.Where(fmt.Sprintf("%s IS NOT NULL", field)) + case dto.OpDateEqual: + qb.query = qb.query.Where(fmt.Sprintf("DATE(%s) = DATE(?)", field), filter.Value) + case dto.OpDateAfter: + qb.query = qb.query.Where(fmt.Sprintf("DATE(%s) > DATE(?)", field), filter.Value) + case dto.OpDateBefore: + qb.query = qb.query.Where(fmt.Sprintf("DATE(%s) < DATE(?)", field), filter.Value) + case dto.OpDateBetween: + if len(filter.Values) == 2 { + qb.query = qb.query.Where(fmt.Sprintf("DATE(%s) BETWEEN DATE(?) AND DATE(?)", field), filter.Values[0], filter.Values[1]) + } + } +} + +func (qb *QueryBuilder[F]) applySorting(sortOptions []dto.TypedSortOption[F], groupBy []F) { + applied := false + + for _, field := range groupBy { + if dbField, ok := qb.allowedSortFields[field]; ok { + qb.query = qb.query.Order(dbField + " ASC") + applied = true + } + } + + for _, sort := range sortOptions { + dbField, ok := qb.allowedSortFields[sort.Field] + if !ok { + continue + } + direction := "ASC" + if strings.ToUpper(string(sort.Direction)) == "DESC" { + direction = "DESC" + } + qb.query = qb.query.Order(dbField + " " + direction) + applied = true + } + + if !applied { + qb.query = qb.query.Order("id DESC") + } +} + +func (qb *QueryBuilder[F]) getSearchableFields(modelType any) []string { + searchableFields := map[string][]string{ + "User": {"username", "email", "full_name"}, + "Role": {"name", "display_name", "description"}, + "Permission": {"name", "display_name", "description"}, + "Project": {"name", "description"}, + "Task": {"name", "description"}, + "Dataset": {"name", "description"}, + "Container": {"name"}, + } + + typeName := qb.getTypeName(modelType) + if fields, exists := searchableFields[typeName]; exists { + return fields + } + return []string{} +} + +func (qb *QueryBuilder[F]) getTypeName(modelType any) string { + t := reflect.TypeOf(modelType) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + return t.Name() +} + +func (qb *QueryBuilder[F]) sanitizeFieldName(field string) string { + if field == "" { + return "" + } + for _, c := range field { + if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '_' && c != '.' { + return "" + } + } + return field +} + +func resolveMultiValues(filter dto.SearchFilter) []string { + if len(filter.Values) > 0 { + return filter.Values + } + + if strings.TrimSpace(filter.Value) == "" { + return nil + } + + var items []string + if strings.HasPrefix(strings.TrimSpace(filter.Value), "[") && json.Unmarshal([]byte(filter.Value), &items) == nil { + return items + } + return []string{filter.Value} +} diff --git a/src/service/analyzer/evaluation.go b/src/service/analyzer/evaluation.go deleted file mode 100644 index dff991cb..00000000 --- a/src/service/analyzer/evaluation.go +++ /dev/null @@ -1,275 +0,0 @@ -package analyzer - -import ( - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "aegis/service/common" - "encoding/json" - "fmt" - - chaos "github.com/OperationsPAI/chaos-experiment/handler" - "github.com/sirupsen/logrus" -) - -// ListDatapackEvaluationResults retrieves evaluation data for multiple algorithm-datapack pairs -func ListDatapackEvaluationResults(req *dto.BatchEvaluateDatapackReq, userID int) (*dto.BatchEvaluateDatapackResp, error) { - if req == nil { - return nil, fmt.Errorf("batch evaluate datapack request is nil") - } - - algorithms := make([]*dto.ContainerRef, 0, len(req.Specs)) - for i := range req.Specs { - algorithms = append(algorithms, &req.Specs[i].Algorithm) - } - - algorithmVersionResults, err := common.MapRefsToContainerVersions(algorithms, consts.ContainerTypeAlgorithm, userID) - if err != nil { - return nil, fmt.Errorf("failed to map container refs to versions: %w", err) - } - - successItems := make([]dto.EvaluateDatapackItem, 0, len(req.Specs)) - failedItems := make([]string, 0) - - for i := range req.Specs { - spec := &req.Specs[i] - specIdentifier := fmt.Sprintf("spec[%d]: algorithm=%s, datapack=%s", i, spec.Algorithm.Name, spec.Datapack) - - algorithmVersion, exists := algorithmVersionResults[algorithms[i]] - if !exists { - failedItems = append(failedItems, fmt.Sprintf("%s - algorithm version not found", specIdentifier)) - continue - } - - labelConditions := make([]map[string]string, 0, len(spec.FilterLabels)) - for _, label := range spec.FilterLabels { - labelConditions = append(labelConditions, map[string]string{ - "key": label.Key, - "value": label.Value, - }) - } - - executions, err := repository.ListExecutionsByDatapackFilter(database.DB, algorithmVersion.ID, spec.Datapack, labelConditions) - if err != nil { - failedItems = append(failedItems, fmt.Sprintf("%s - failed to query executions: %v", specIdentifier, err)) - continue - } - - if len(executions) == 0 { - failedItems = append(failedItems, fmt.Sprintf("%s - no executions found", specIdentifier)) - continue - } - - refs := make([]dto.ExecutionRef, 0, len(executions)) - for _, execution := range executions { - refs = append(refs, dto.NewExecutionGranularityRef(&execution)) - } - - evaluateRef := dto.EvaluateDatapackRef{ - Datapack: spec.Datapack, - ExecutionRefs: refs, - } - - datapack := executions[0].Datapack - if datapack != nil { - groundtruths, err := getGroundtruths(datapack) - if err != nil { - logrus.Warnf("failed to get groundtruth for datapack %s: %v", spec.Datapack, err) - } else { - evaluateRef.Groundtruths = groundtruths - } - } - - item := dto.EvaluateDatapackItem{ - Algorithm: algorithmVersion.Container.Name, - AlgorithmVersion: algorithmVersion.Name, - EvaluateDatapackRef: evaluateRef, - } - successItems = append(successItems, item) - } - - // Persist successful evaluations to the database - persistEvaluations("datapack", successItems, func(item *dto.EvaluateDatapackItem) *database.Evaluation { - return &database.Evaluation{ - AlgorithmName: item.Algorithm, - AlgorithmVersion: item.AlgorithmVersion, - DatapackName: item.Datapack, - EvalType: consts.EvalTypeDatapack, - Status: consts.CommonEnabled, - } - }) - - resp := dto.BatchEvaluateDatapackResp{ - SuccessCount: len(successItems), - SuccessItems: successItems, - FailedCount: len(failedItems), - FailedItems: failedItems, - } - return &resp, nil -} - -// ListDatasetEvaluationResults retrieves evaluation results for multiple dataset-algorithm pairs -func ListDatasetEvaluationResults(req *dto.BatchEvaluateDatasetReq, userID int) (*dto.BatchEvaluateDatasetResp, error) { - if req == nil { - return nil, fmt.Errorf("batch evaluate datapack request is nil") - } - - algorithms := make([]*dto.ContainerRef, 0, len(req.Specs)) - datasets := make([]*dto.DatasetRef, 0, len(req.Specs)) - for i := range req.Specs { - algorithms = append(algorithms, &req.Specs[i].Algorithm) - datasets = append(datasets, &req.Specs[i].Dataset) - } - - algorithmVersionResults, err := common.MapRefsToContainerVersions(algorithms, consts.ContainerTypeAlgorithm, userID) - if err != nil { - return nil, fmt.Errorf("failed to map container refs to versions: %w", err) - } - - datasetVersionResults, err := common.MapRefsToDatasetVersions(datasets, userID) - if err != nil { - return nil, fmt.Errorf("failed to map dataset refs to versions: %w", err) - } - - successItems := make([]dto.EvaluateDatasetItem, 0, len(req.Specs)) - failedItems := make([]string, 0) - - for i := range req.Specs { - spec := &req.Specs[i] - specIdentifier := fmt.Sprintf("spec[%d]: algorithm=%s, dataset=%s", i, spec.Algorithm.Name, spec.Dataset.Name) - - algorithmVersion, exists := algorithmVersionResults[algorithms[i]] - if !exists { - failedItems = append(failedItems, fmt.Sprintf("%s - algorithm version not found", specIdentifier)) - continue - } - - datasetVersion, exists := datasetVersionResults[datasets[i]] - if !exists { - failedItems = append(failedItems, fmt.Sprintf("%s - dataset version not found", specIdentifier)) - continue - } - - labelConditions := dto.ConvertLabelItemsToConditions(spec.FilterLabels) - - executions, err := repository.ListExecutionsByDatasetFilter(database.DB, algorithmVersion.ID, datasetVersion.ID, labelConditions) - if err != nil { - failedItems = append(failedItems, fmt.Sprintf("%s - failed to query executions: %v", specIdentifier, err)) - continue - } - - if len(executions) == 0 { - failedItems = append(failedItems, fmt.Sprintf("%s - no executions found", specIdentifier)) - continue - } - - executionMap := make(map[string][]database.Execution) - for _, execution := range executions { - name := execution.Datapack.Name - if _, exists := executionMap[name]; !exists { - executionMap[name] = make([]database.Execution, 0) - } else { - executionMap[name] = append(executionMap[name], execution) - } - } - - notExecutedDatapacks := []string{} - for _, datapack := range datasetVersion.Datapacks { - if _, exists := executionMap[datapack.Name]; !exists { - notExecutedDatapacks = append(notExecutedDatapacks, datapack.Name) - } - } - - evaluateRefs := make([]dto.EvaluateDatapackRef, 0, len(executionMap)) - for datapack_name, groupedExecutions := range executionMap { - refs := make([]dto.ExecutionRef, 0, len(groupedExecutions)) - for _, execution := range groupedExecutions { - refs = append(refs, dto.NewExecutionGranularityRef(&execution)) - } - - evaluateRef := dto.EvaluateDatapackRef{ - Datapack: datapack_name, - ExecutionRefs: refs, - } - - datapack := groupedExecutions[0].Datapack - if datapack != nil { - groundtruths, err := getGroundtruths(datapack) - if err != nil { - logrus.Warnf("failed to get groundtruth for datapack %s: %v", datapack_name, err) - } else { - evaluateRef.Groundtruths = groundtruths - } - } - - evaluateRefs = append(evaluateRefs, evaluateRef) - } - - item := dto.EvaluateDatasetItem{ - Algorithm: algorithmVersion.Container.Name, - AlgorithmVersion: algorithmVersion.Name, - Dataset: datasetVersion.Dataset.Name, - DatasetVersion: datasetVersion.Name, - TotalCount: len(datasetVersion.Datapacks), - EvaluateRefs: evaluateRefs, - NotExecutedDatapacks: notExecutedDatapacks, - } - - successItems = append(successItems, item) - } - - // Persist successful evaluations to the database - persistEvaluations("dataset", successItems, func(item *dto.EvaluateDatasetItem) *database.Evaluation { - return &database.Evaluation{ - AlgorithmName: item.Algorithm, - AlgorithmVersion: item.AlgorithmVersion, - DatasetName: item.Dataset, - DatasetVersion: item.DatasetVersion, - EvalType: consts.EvalTypeDataset, - Status: consts.CommonEnabled, - } - }) - - resp := dto.BatchEvaluateDatasetResp{ - SuccessCount: len(successItems), - SuccessItems: successItems, - FailedCount: len(failedItems), - FailedItems: failedItems, - } - return &resp, nil -} - -// persistEvaluations batch-persists evaluation results to the database. -// The toEval function maps each item to a database.Evaluation (without ResultJSON). -func persistEvaluations[T any](evalType string, items []T, toEval func(*T) *database.Evaluation) { - if len(items) == 0 { - return - } - - evals := make([]database.Evaluation, 0, len(items)) - for i := range items { - eval := toEval(&items[i]) - resultJSON, err := json.Marshal(&items[i]) - if err != nil { - logrus.Warnf("failed to marshal %s evaluation result: %v", evalType, err) - eval.ResultJSON = "{}" - } else { - eval.ResultJSON = string(resultJSON) - } - evals = append(evals, *eval) - } - - if err := database.DB.Create(&evals).Error; err != nil { - logrus.Warnf("failed to batch persist %d %s evaluations: %v", len(evals), evalType, err) - } -} - -// getGroundtruths extracts the ground truth from a datapack's engine configuration -func getGroundtruths(datapack *database.FaultInjection) ([]chaos.Groundtruth, error) { - chaosGroundtruths := make([]chaos.Groundtruth, 0, len(datapack.Groundtruths)) - for _, gt := range datapack.Groundtruths { - chaosGroundtruths = append(chaosGroundtruths, *gt.ConvertToChaosGroundtruth()) - } - return chaosGroundtruths, nil -} diff --git a/src/service/common/config_listener.go b/src/service/common/config_listener.go index 45d6d8ae..ccc2f7f0 100644 --- a/src/service/common/config_listener.go +++ b/src/service/common/config_listener.go @@ -6,14 +6,13 @@ import ( "sync" "time" - "aegis/client" "aegis/config" "aegis/consts" - "aegis/database" - "aegis/repository" + etcd "aegis/infra/etcd" "github.com/sirupsen/logrus" clientv3 "go.etcd.io/etcd/client/v3" + "gorm.io/gorm" ) // scopePrefix maps configuration scopes to their etcd key prefix. @@ -28,42 +27,39 @@ var scopePrefix = map[consts.ConfigScope]string{ // It supports incremental scope activation via EnsureScope — each scope is // loaded and watched independently, making it safe for both, producer-only // and consumer-only modes. -type configUpdateListener struct { - ctx context.Context - cancel context.CancelFunc - mu sync.Mutex - active map[consts.ConfigScope]bool // scopes already loaded + watched +type ConfigUpdateListener struct { + ctx context.Context + cancel context.CancelFunc + mu sync.Mutex + active map[consts.ConfigScope]bool // scopes already loaded + watched + db *gorm.DB + gateway *etcd.Gateway } -var ( - configListenerInstance *configUpdateListener - configListenerOnce sync.Once -) +func NewConfigUpdateListener(ctx context.Context, db *gorm.DB, gateway *etcd.Gateway) *ConfigUpdateListener { + listenerCtx, cancel := context.WithCancel(ctx) + listener := &ConfigUpdateListener{ + ctx: listenerCtx, + cancel: cancel, + active: make(map[consts.ConfigScope]bool), + db: db, + gateway: gateway, + } -// GetConfigUpdateListener returns the singleton instance of configUpdateListener -func GetConfigUpdateListener(ctx context.Context) *configUpdateListener { - configListenerOnce.Do(func() { - listenerCtx, cancel := context.WithCancel(ctx) - configListenerInstance = &configUpdateListener{ - ctx: listenerCtx, - cancel: cancel, - active: make(map[consts.ConfigScope]bool), - } + go func() { + <-ctx.Done() + logrus.Info("Parent context cancelled, stopping config update listener...") + listener.Stop() + }() - go func() { - <-ctx.Done() - logrus.Info("Parent context cancelled, stopping config update listener...") - configListenerInstance.Stop() - }() - }) - return configListenerInstance + return listener } // EnsureScope loads initial config values from etcd and starts a watcher for // the given scope. The call is idempotent — invoking it multiple times for the // same scope is a safe no-op. Scopes without an etcd prefix (e.g. producer) // are silently skipped. -func (l *configUpdateListener) EnsureScope(scope consts.ConfigScope) error { +func (l *ConfigUpdateListener) EnsureScope(scope consts.ConfigScope) error { prefix, ok := scopePrefix[scope] if !ok { logrus.Debugf("Scope %s has no etcd prefix, skipping listener setup", @@ -94,15 +90,15 @@ func (l *configUpdateListener) EnsureScope(scope consts.ConfigScope) error { } // Stop cancels the listener context, stopping all watcher goroutines. -func (l *configUpdateListener) Stop() { +func (l *ConfigUpdateListener) Stop() { l.cancel() logrus.Info("Config update listener stopped") } // loadScopeFromEtcd loads all configs for a given scope from etcd into viper. // Falls back to MySQL defaults only if config doesn't exist in etcd. -func (l *configUpdateListener) loadScopeFromEtcd(scope consts.ConfigScope, prefix, scopeName string) error { - configMetadata, err := repository.ListConfigByScope(database.DB, scope) +func (l *ConfigUpdateListener) loadScopeFromEtcd(scope consts.ConfigScope, prefix, scopeName string) error { + configMetadata, err := newConfigStore(l.db).listConfigsByScope(scope) if err != nil { return fmt.Errorf("failed to list %s config metadata from database: %w", scopeName, err) } @@ -114,7 +110,7 @@ func (l *configUpdateListener) loadScopeFromEtcd(scope consts.ConfigScope, prefi etcdKey := fmt.Sprintf("%s%s", prefix, meta.Key) // Try to get current value from etcd first - etcdValue, err := client.EtcdGet(l.ctx, etcdKey) + etcdValue, err := l.gateway.Get(l.ctx, etcdKey) if err != nil { logrus.Errorf("Failed to get config %s from etcd: %v", meta.Key, err) continue @@ -123,7 +119,7 @@ func (l *configUpdateListener) loadScopeFromEtcd(scope consts.ConfigScope, prefi var valueToLoad string if etcdValue == "" { // Config doesn't exist in etcd, initialize it with MySQL default value - if err := client.EtcdPut(l.ctx, etcdKey, meta.DefaultValue, 0); err != nil { + if err := l.gateway.Put(l.ctx, etcdKey, meta.DefaultValue, 0); err != nil { logrus.Errorf("Failed to initialize config %s in etcd: %v", meta.Key, err) continue } @@ -151,8 +147,8 @@ func (l *configUpdateListener) loadScopeFromEtcd(scope consts.ConfigScope, prefi // watchPrefix watches a single etcd prefix for configuration changes. // Each scope gets its own goroutine calling this method. -func (l *configUpdateListener) watchPrefix(prefix, scopeName string) { - watchChan := client.EtcdWatch(l.ctx, prefix, true) +func (l *ConfigUpdateListener) watchPrefix(prefix, scopeName string) { + watchChan := l.gateway.Watch(l.ctx, prefix, true) logrus.Infof("Started watching etcd prefix %s for %s config changes", prefix, scopeName) for { @@ -165,19 +161,19 @@ func (l *configUpdateListener) watchPrefix(prefix, scopeName string) { if !ok { logrus.Warnf("etcd %s watch channel closed, restarting...", scopeName) time.Sleep(1 * time.Second) - watchChan = client.EtcdWatch(l.ctx, prefix, true) + watchChan = l.gateway.Watch(l.ctx, prefix, true) continue } if watchResp.Canceled { logrus.Warnf("etcd %s watch was canceled, restarting...", scopeName) time.Sleep(1 * time.Second) - watchChan = client.EtcdWatch(l.ctx, prefix, true) + watchChan = l.gateway.Watch(l.ctx, prefix, true) continue } if err := watchResp.Err(); err != nil { logrus.Errorf("etcd %s watch error: %v", scopeName, err) time.Sleep(1 * time.Second) - watchChan = client.EtcdWatch(l.ctx, prefix, true) + watchChan = l.gateway.Watch(l.ctx, prefix, true) continue } for _, event := range watchResp.Events { @@ -188,7 +184,7 @@ func (l *configUpdateListener) watchPrefix(prefix, scopeName string) { } // handleEtcdEvent handles a single etcd event from a given prefix -func (l *configUpdateListener) handleEtcdEvent(event *clientv3.Event, prefix string) { +func (l *ConfigUpdateListener) handleEtcdEvent(event *clientv3.Event, prefix string) { key := string(event.Kv.Key) newValue := string(event.Kv.Value) @@ -212,7 +208,7 @@ func (l *configUpdateListener) handleEtcdEvent(event *clientv3.Event, prefix str }).Info("received config change from etcd") // Apply config change via registry - if err := handleConfigChange(l.ctx, configKey, oldValue, newValue); err != nil { + if err := handleConfigChange(l.ctx, l.db, configKey, oldValue, newValue); err != nil { logrus.Errorf("failed to apply config update for %s: %v", configKey, err) return } diff --git a/src/service/common/config_registry.go b/src/service/common/config_registry.go index 4ca4a5b6..4e2641e2 100644 --- a/src/service/common/config_registry.go +++ b/src/service/common/config_registry.go @@ -5,14 +5,12 @@ import ( "fmt" "sync" - "aegis/client" "aegis/config" "aegis/consts" - "aegis/database" "aegis/dto" - "aegis/repository" "github.com/sirupsen/logrus" + "gorm.io/gorm" ) // ConfigHandler defines the interface for handling configuration changes. @@ -34,10 +32,11 @@ type configRegistry struct { handlers map[consts.ConfigScope]map[string]ConfigHandler } -var ( - registryInstance *configRegistry - registryOnce sync.Once -) +type ConfigPublisher interface { + Publish(ctx context.Context, channel string, message any) error +} + +var registryInstance = newConfigRegistry() // RegisterHandler registers a configuration handler. // External packages (e.g. consumer) call this to plug in their own handlers. @@ -45,16 +44,14 @@ func RegisterHandler(handler ConfigHandler) { getConfigRegistry().register(handler) } -var globalHandlersOnce sync.Once - // RegisterGlobalHandlers registers handlers for global-scope configurations. -// Safe to call multiple times — subsequent calls are a no-op. -func RegisterGlobalHandlers() { - globalHandlersOnce.Do(func() { - RegisterHandler(&algoConfigHandler{}) +// Safe to call multiple times — duplicate registrations are skipped. +func RegisterGlobalHandlers(publisher ConfigPublisher) { + registry := getConfigRegistry() + if registry.ensureRegistered(&algoConfigHandler{publisher: publisher}) { scope := consts.ConfigScopeGlobal logrus.Infof("Registered %d global config handler(s)", len(ListRegisteredConfigKeys(&scope))) - }) + } } // ListRegisteredConfigKeys returns all registered configuration keys for informational purposes (e.g. logging) @@ -77,11 +74,14 @@ func ListRegisteredConfigKeys(scope *consts.ConfigScope) []string { // PublishWrapper wraps a config update function and publishes the result to Redis. // Exported so consumer and producer can reuse it in their own handlers. -func PublishWrapper(ctx context.Context, function func() error) error { +func PublishWrapper(ctx context.Context, publisher ConfigPublisher, function func() error) error { updateResponse := dto.NewConfigUpdateResponse() defer func() { - if err := client.RedisPublish(ctx, consts.ConfigUpdateResponseChannel, updateResponse); err != nil { + if publisher == nil { + return + } + if err := publisher.Publish(ctx, consts.ConfigUpdateResponseChannel, updateResponse); err != nil { logrus.Errorf("failed to publish config update response to Redis: %v", err) } }() @@ -97,17 +97,22 @@ func PublishWrapper(ctx context.Context, function func() error) error { // getConfigRegistry returns the singleton config registry instance func getConfigRegistry() *configRegistry { - registryOnce.Do(func() { - registryInstance = &configRegistry{ - handlers: make(map[consts.ConfigScope]map[string]ConfigHandler), - } - }) return registryInstance } +func newConfigRegistry() *configRegistry { + return &configRegistry{ + handlers: make(map[consts.ConfigScope]map[string]ConfigHandler), + } +} + +func resetConfigRegistryForTest() { + registryInstance = newConfigRegistry() +} + // handleConfigChange routes a configuration change to the appropriate handler -func handleConfigChange(ctx context.Context, key, oldValue, newValue string) error { - existingConfig, err := repository.GetConfigByKey(database.DB, key, false) +func handleConfigChange(ctx context.Context, db *gorm.DB, key, oldValue, newValue string) error { + existingConfig, err := newConfigStore(db).getConfigByKey(key) if err != nil { return fmt.Errorf("failed to retrieve existing config %s from database: %w", key, err) } @@ -160,20 +165,40 @@ func (r *configRegistry) register(handler ConfigHandler) { consts.GetConfigScopeName(scope), category) } +func (r *configRegistry) ensureRegistered(handler ConfigHandler) bool { + r.mu.Lock() + defer r.mu.Unlock() + + scope := handler.Scope() + category := handler.Category() + + if _, ok := r.handlers[scope]; !ok { + r.handlers[scope] = make(map[string]ConfigHandler) + } + if _, exists := r.handlers[scope][category]; exists { + return false + } + + r.handlers[scope][category] = handler + logrus.Debugf("Registered config handler for scope=%s category=%s", + consts.GetConfigScopeName(scope), category) + return true +} + // ===================================================================== // AlgoConfigHandler - handles algo configuration (e.g. algo.detector) // ===================================================================== -// algoConfigHandler handles algo configuration changes and keeps the global -// config.DetectorName variable in sync whenever algo.detector is updated. -type algoConfigHandler struct{} +type algoConfigHandler struct { + publisher ConfigPublisher +} func (h *algoConfigHandler) Category() string { return "algo" } func (h *algoConfigHandler) Scope() consts.ConfigScope { return consts.ConfigScopeGlobal } func (h *algoConfigHandler) Handle(ctx context.Context, key, oldValue, newValue string) error { - return PublishWrapper(ctx, func() error { + return PublishWrapper(ctx, h.publisher, func() error { switch key { case consts.DetectorKey: config.SetDetectorName(newValue) diff --git a/src/service/common/config_registry_test.go b/src/service/common/config_registry_test.go new file mode 100644 index 00000000..203d078e --- /dev/null +++ b/src/service/common/config_registry_test.go @@ -0,0 +1,24 @@ +package common + +import ( + "testing" + + "aegis/consts" +) + +func TestRegisterGlobalHandlersIsIdempotent(t *testing.T) { + resetConfigRegistryForTest() + t.Cleanup(resetConfigRegistryForTest) + + RegisterGlobalHandlers(nil) + RegisterGlobalHandlers(nil) + + scope := consts.ConfigScopeGlobal + keys := ListRegisteredConfigKeys(&scope) + if len(keys) != 1 { + t.Fatalf("expected 1 global handler, got %d: %v", len(keys), keys) + } + if keys[0] != "algo" { + t.Fatalf("expected algo handler to be registered, got %v", keys) + } +} diff --git a/src/service/common/config_store.go b/src/service/common/config_store.go new file mode 100644 index 00000000..f712bbc7 --- /dev/null +++ b/src/service/common/config_store.go @@ -0,0 +1,34 @@ +package common + +import ( + "fmt" + + "aegis/consts" + "aegis/model" + + "gorm.io/gorm" +) + +type configStore struct { + db *gorm.DB +} + +func newConfigStore(db *gorm.DB) *configStore { + return &configStore{db: db} +} + +func (s *configStore) getConfigByKey(key string) (*model.DynamicConfig, error) { + var cfg model.DynamicConfig + if err := s.db.Where("config_key = ?", key).First(&cfg).Error; err != nil { + return nil, fmt.Errorf("failed to find config with key %s: %w", key, err) + } + return &cfg, nil +} + +func (s *configStore) listConfigsByScope(scope consts.ConfigScope) ([]model.DynamicConfig, error) { + var configs []model.DynamicConfig + if err := s.db.Where("scope = ?", scope).Order("config_key ASC").Find(&configs).Error; err != nil { + return nil, fmt.Errorf("failed to list configs by scope %s: %w", consts.GetConfigScopeName(scope), err) + } + return configs, nil +} diff --git a/src/service/common/dynamic_config.go b/src/service/common/dynamic_config.go index fb9700cf..deccfb85 100644 --- a/src/service/common/dynamic_config.go +++ b/src/service/common/dynamic_config.go @@ -2,8 +2,7 @@ package common import ( "aegis/consts" - "aegis/database" - "aegis/repository" + "aegis/model" "encoding/json" "errors" "fmt" @@ -51,8 +50,8 @@ var configTypeRules = map[consts.ConfigValueType]configTypeConstraints{ } // CreateConfig creates a new configuration with history tracking -func CreateConfig(db *gorm.DB, config *database.DynamicConfig) error { - if err := repository.CreateConfig(db, config); err != nil { +func CreateConfig(db *gorm.DB, config *model.DynamicConfig) error { + if err := db.Create(config).Error; err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return fmt.Errorf("%w: configuration with key '%s' already exists", consts.ErrAlreadyExists, config.Key) } @@ -62,7 +61,7 @@ func CreateConfig(db *gorm.DB, config *database.DynamicConfig) error { } // ValidateConfig validates a configuration against its type and constraints -func ValidateConfig(cfg *database.DynamicConfig, value string) error { +func ValidateConfig(cfg *model.DynamicConfig, value string) error { // Validate metadata constraints if err := ValidateConfigMetadataConstraints(cfg); err != nil { return err @@ -128,7 +127,7 @@ func ValidateConfig(cfg *database.DynamicConfig, value string) error { } // ValidateConfigMetadataConstraints validates that metadata fields are appropriate for the value type -func ValidateConfigMetadataConstraints(cfg *database.DynamicConfig) error { +func ValidateConfigMetadataConstraints(cfg *model.DynamicConfig) error { rules, exists := configTypeRules[cfg.ValueType] if !exists { return fmt.Errorf("unknown value type: %d", cfg.ValueType) @@ -158,7 +157,7 @@ func ValidateConfigMetadataConstraints(cfg *database.DynamicConfig) error { } // validateConfigOptions validates the config value against allowed options based on value type -func validateConfigOptions(cfg *database.DynamicConfig, value string) error { +func validateConfigOptions(cfg *model.DynamicConfig, value string) error { switch cfg.ValueType { case consts.ConfigValueTypeString: var allowedOptions []string diff --git a/src/service/common/injection.go b/src/service/common/injection.go index 94963943..df7cf1c9 100644 --- a/src/service/common/injection.go +++ b/src/service/common/injection.go @@ -3,14 +3,16 @@ package common import ( "aegis/consts" "aegis/dto" + redis "aegis/infra/redis" "aegis/utils" "context" "fmt" "time" + + "gorm.io/gorm" ) -// ProduceFaultInjectionTasks produces fault injection tasks into Redis based on the request specifications -func ProduceFaultInjectionTasks(ctx context.Context, task *dto.UnifiedTask, injectTime time.Time, payload map[string]any) error { +func ProduceFaultInjectionTasksWithDB(ctx context.Context, db *gorm.DB, redisGateway *redis.Gateway, task *dto.UnifiedTask, injectTime time.Time, payload map[string]any) error { newTask := &dto.UnifiedTask{ Type: consts.TaskTypeFaultInjection, Immediate: false, @@ -25,7 +27,7 @@ func ProduceFaultInjectionTasks(ctx context.Context, task *dto.UnifiedTask, inje TraceCarrier: task.TraceCarrier, GroupCarrier: task.GroupCarrier, } - err := SubmitTask(ctx, newTask) + err := SubmitTaskWithDB(ctx, db, redisGateway, newTask) if err != nil { return fmt.Errorf("failed to submit fault injection task: %w", err) } diff --git a/src/service/common/label.go b/src/service/common/label.go deleted file mode 100644 index 53d8a34e..00000000 --- a/src/service/common/label.go +++ /dev/null @@ -1,110 +0,0 @@ -package common - -import ( - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "aegis/utils" - "fmt" - "sort" - - "gorm.io/gorm" -) - -// ConvertLabelFiltersToConditions converts a slice of LabelFilter to a slice of map conditions -func ConvertLabelFiltersToConditions(labelItems []dto.LabelItem) []map[string]string { - if len(labelItems) == 0 { - return []map[string]string{} - } - - labelConditions := make([]map[string]string, 0, len(labelItems)) - for _, label := range labelItems { - labelConditions = append(labelConditions, map[string]string{ - "key": label.Key, - "value": label.Value, - }) - } - - return labelConditions -} - -// CreateOrUpdateLabelsFromItems creates or updates labels based on the provided label items -// Returns labels with correct IDs and updates usage_count for existing labels -func CreateOrUpdateLabelsFromItems(db *gorm.DB, labelItems []dto.LabelItem, category consts.LabelCategory) ([]database.Label, error) { - if len(labelItems) == 0 { - return []database.Label{}, nil - } - - // Build key -> value map for quick lookup - kvMap := make(map[string]dto.LabelItem, len(labelItems)) - for _, item := range labelItems { - kvMap[item.Key] = item - } - - // Find existing labels using slice conditions for repository - labelConditions := dto.ConvertLabelItemsToConditions(labelItems) - existingLabels, err := repository.ListLabelsByConditions(db, labelConditions) - if err != nil { - return nil, fmt.Errorf("failed to find existing labels: %w", err) - } - - // Separate existing and new labels - result := make([]database.Label, 0, len(labelItems)) - existingIDs := make([]int, 0, len(existingLabels)) - for _, existing := range existingLabels { - if item, ok := kvMap[existing.Key]; ok && item.Value == existing.Value { - result = append(result, existing) - existingIDs = append(existingIDs, existing.ID) - delete(kvMap, existing.Key) - } - } - - // Increase usage count for existing labels - if len(existingIDs) > 0 { - if err := repository.BatchIncreaseLabelUsages(db, existingIDs, 1); err != nil { - return nil, fmt.Errorf("failed to increase usage for existing labels: %w", err) - } - } - - // Create new labels (only those not found in existing) - if len(kvMap) > 0 { - newLabels := make([]database.Label, 0, len(kvMap)) - - for key, item := range kvMap { - newLabels = append(newLabels, database.Label{ - Key: key, - Value: item.Value, - Category: category, - Description: fmt.Sprintf(consts.CustomLabelDescriptionTemplate, key, consts.GetLabelCategoryName(category)), - Color: utils.GenerateColorFromKey(key), - Usage: consts.DefaultLabelUsage, - IsSystem: item.IsSystem, - Status: consts.CommonEnabled, - }) - } - - if err := repository.BatchCreateLabels(db, newLabels); err != nil { - return nil, fmt.Errorf("failed to create new labels: %w", err) - } - - result = append(result, newLabels...) - } - - // Sort by ID ascending - sort.Slice(result, func(i, j int) bool { - return result[i].ID < result[j].ID - }) - return result, nil -} - -func GetLabelConditionsByItems(labelItems []dto.LabelItem) []map[string]string { - labelConditions := make([]map[string]string, 0, len(labelItems)) - for _, item := range labelItems { - labelConditions = append(labelConditions, map[string]string{ - "key": item.Key, - "value": item.Value, - }) - } - return labelConditions -} diff --git a/src/service/common/metadata_store.go b/src/service/common/metadata_store.go index 1c5e6805..6f4b3b0b 100644 --- a/src/service/common/metadata_store.go +++ b/src/service/common/metadata_store.go @@ -5,20 +5,21 @@ import ( "fmt" "sync" - "aegis/database" - "aegis/repository" + "aegis/model" chaos "github.com/OperationsPAI/chaos-experiment/handler" + "gorm.io/gorm" ) // DBMetadataStore implements chaos.MetadataStore by reading from MySQL with in-memory caching. type DBMetadataStore struct { + db *gorm.DB cache sync.Map // key: "system:type:service" -> cached data } // NewDBMetadataStore creates a new DBMetadataStore instance. -func NewDBMetadataStore() *DBMetadataStore { - return &DBMetadataStore{} +func NewDBMetadataStore(db *gorm.DB) *DBMetadataStore { + return &DBMetadataStore{db: db} } func (s *DBMetadataStore) cacheKey(system, metaType, service string) string { @@ -31,7 +32,7 @@ func (s *DBMetadataStore) GetServiceEndpoints(system, serviceName string) ([]cha return cached.([]chaos.ServiceEndpointData), nil } - meta, err := repository.GetSystemMetadata(database.DB, system, "service_endpoint", serviceName) + meta, err := s.getSystemMetadata(system, "service_endpoint", serviceName) if err != nil { return nil, fmt.Errorf("failed to get service endpoints: %w", err) } @@ -54,7 +55,7 @@ func (s *DBMetadataStore) GetAllServiceNames(system string) ([]string, error) { return cached.([]string), nil } - names, err := repository.ListServiceNames(database.DB, system, "service_endpoint") + names, err := s.listServiceNames(system, "service_endpoint") if err != nil { return nil, fmt.Errorf("failed to get service names: %w", err) } @@ -69,7 +70,7 @@ func (s *DBMetadataStore) GetJavaClassMethods(system, serviceName string) ([]cha return cached.([]chaos.JavaClassMethodData), nil } - meta, err := repository.GetSystemMetadata(database.DB, system, "java_class_method", serviceName) + meta, err := s.getSystemMetadata(system, "java_class_method", serviceName) if err != nil { return nil, fmt.Errorf("failed to get java class methods: %w", err) } @@ -92,7 +93,7 @@ func (s *DBMetadataStore) GetDatabaseOperations(system, serviceName string) ([]c return cached.([]chaos.DatabaseOperationData), nil } - meta, err := repository.GetSystemMetadata(database.DB, system, "database_operation", serviceName) + meta, err := s.getSystemMetadata(system, "database_operation", serviceName) if err != nil { return nil, fmt.Errorf("failed to get database operations: %w", err) } @@ -115,7 +116,7 @@ func (s *DBMetadataStore) GetGRPCOperations(system, serviceName string) ([]chaos return cached.([]chaos.GRPCOperationData), nil } - meta, err := repository.GetSystemMetadata(database.DB, system, "grpc_operation", serviceName) + meta, err := s.getSystemMetadata(system, "grpc_operation", serviceName) if err != nil { return nil, fmt.Errorf("failed to get gRPC operations: %w", err) } @@ -138,7 +139,7 @@ func (s *DBMetadataStore) GetNetworkPairs(system string) ([]chaos.NetworkPairDat return cached.([]chaos.NetworkPairData), nil } - metas, err := repository.ListSystemMetadata(database.DB, system, "network_dependency") + metas, err := s.listSystemMetadata(system, "network_dependency") if err != nil { return nil, fmt.Errorf("failed to get network pairs: %w", err) } @@ -158,8 +159,7 @@ func (s *DBMetadataStore) GetNetworkPairs(system string) ([]chaos.NetworkPairDat // GetRuntimeMutatorTargets returns runtime mutator targets for the given system. // Not yet backed by persisted metadata; returns empty so chaos-experiment's -// routing layer falls back to bundled defaults. When/if we persist mutator -// targets, mirror the GetNetworkPairs pattern using a new meta type. +// routing layer falls back to bundled defaults. func (s *DBMetadataStore) GetRuntimeMutatorTargets(system string) ([]chaos.RuntimeMutatorTargetData, error) { return nil, nil } @@ -171,3 +171,39 @@ func (s *DBMetadataStore) InvalidateCache() { return true }) } + +func (s *DBMetadataStore) getSystemMetadata(systemName, metadataType, serviceName string) (*model.SystemMetadata, error) { + var meta model.SystemMetadata + if err := s.db.Where("system_name = ? AND metadata_type = ? AND service_name = ?", systemName, metadataType, serviceName). + First(&meta).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, fmt.Errorf("failed to get system metadata: %w", err) + } + return &meta, nil +} + +func (s *DBMetadataStore) listSystemMetadata(systemName, metadataType string) ([]model.SystemMetadata, error) { + var metas []model.SystemMetadata + query := s.db.Where("system_name = ?", systemName) + if metadataType != "" { + query = query.Where("metadata_type = ?", metadataType) + } + if err := query.Find(&metas).Error; err != nil { + return nil, fmt.Errorf("failed to list system metadata: %w", err) + } + return metas, nil +} + +func (s *DBMetadataStore) listServiceNames(systemName, metadataType string) ([]string, error) { + var names []string + query := s.db.Model(&model.SystemMetadata{}).Where("system_name = ?", systemName) + if metadataType != "" { + query = query.Where("metadata_type = ?", metadataType) + } + if err := query.Distinct("service_name").Pluck("service_name", &names).Error; err != nil { + return nil, fmt.Errorf("failed to list service names: %w", err) + } + return names, nil +} diff --git a/src/service/common/task.go b/src/service/common/task.go index 218ad988..59a9727b 100644 --- a/src/service/common/task.go +++ b/src/service/common/task.go @@ -1,23 +1,28 @@ package common import ( - "aegis/client" "aegis/consts" - "aegis/database" "aegis/dto" - "aegis/repository" + redis "aegis/infra/redis" + "aegis/model" "context" "encoding/json" "fmt" "time" "github.com/google/uuid" - "github.com/redis/go-redis/v9" "github.com/robfig/cron/v3" "github.com/sirupsen/logrus" "gorm.io/gorm" + "gorm.io/gorm/clause" ) +// logEmitScheduledErr logs a failed task.scheduled event emission. +func logEmitScheduledErr(taskID string, err error) { + logrus.WithField("task_id", taskID). + Warnf("failed to emit task.scheduled event: %v", err) +} + // cronNextTime calculates the next execution time from a cron expression func CronNextTime(expr string) (time.Time, error) { parser := cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) @@ -48,7 +53,14 @@ func CronNextTime(expr string) (time.Time, error) { // -> Task 3 // -> Task 4 // -> Task 5 -func SubmitTask(ctx context.Context, t *dto.UnifiedTask) error { +func SubmitTaskWithDB(ctx context.Context, db *gorm.DB, redisGateway *redis.Gateway, t *dto.UnifiedTask) error { + if db == nil { + return fmt.Errorf("task db is nil") + } + if redisGateway == nil { + return fmt.Errorf("task redis gateway is nil") + } + if t.TraceID == "" { t.TraceID = uuid.NewString() } @@ -58,7 +70,7 @@ func SubmitTask(ctx context.Context, t *dto.UnifiedTask) error { } if t.ParentTaskID != nil && t.State != consts.TaskRescheduled { - parentLevel, err := repository.GetParentTaskLevelByID(database.DB, *t.ParentTaskID) + parentLevel, err := getParentTaskLevelByID(db, *t.ParentTaskID) if err != nil { return fmt.Errorf("failed to get parent task level: %w", err) } @@ -71,7 +83,7 @@ func SubmitTask(ctx context.Context, t *dto.UnifiedTask) error { } } - var trace *database.Trace + var trace *model.Trace var err error if t.ParentTaskID == nil && t.State != consts.TaskRescheduled { withAlgorithms := false @@ -96,14 +108,14 @@ func SubmitTask(ctx context.Context, t *dto.UnifiedTask) error { return fmt.Errorf("failed to convert to task: %w", err) } - err = database.DB.Transaction(func(tx *gorm.DB) error { + err = db.Transaction(func(tx *gorm.DB) error { if trace != nil { - if err := repository.UpsertTrace(tx, trace); err != nil { + if err := upsertTrace(tx, trace); err != nil { return fmt.Errorf("failed to upsert trace to database: %w", err) } } - if err := repository.UpsertTask(tx, task); err != nil { + if err := upsertTask(tx, task); err != nil { return fmt.Errorf("failed to upsert task to database: %w", err) } @@ -119,15 +131,15 @@ func SubmitTask(ctx context.Context, t *dto.UnifiedTask) error { } if t.Immediate { - err = repository.SubmitImmediateTask(ctx, taskData, t.TaskID) + err = redisGateway.SubmitImmediateTask(ctx, taskData, t.TaskID) } else { - err = repository.SubmitDelayedTask(ctx, taskData, t.TaskID, t.ExecuteTime) + err = redisGateway.SubmitDelayedTask(ctx, taskData, t.TaskID, t.ExecuteTime) if err == nil { reason := dto.TaskScheduledReasonPreDurationWait if t.Type == consts.TaskTypeCronJob { reason = dto.TaskScheduledReasonCronNext } - EmitTaskScheduled(ctx, t, t.ExecuteTime, reason) + EmitTaskScheduled(ctx, redisGateway, t, t.ExecuteTime, reason) } } @@ -139,11 +151,9 @@ func SubmitTask(ctx context.Context, t *dto.UnifiedTask) error { } // EmitTaskScheduled publishes a task.scheduled trace event to the task's -// trace stream. It records the execute_time and the reason the task was -// deferred. Failures are logged but not propagated — scheduling is the -// primary concern and the event is observational. -func EmitTaskScheduled(ctx context.Context, t *dto.UnifiedTask, executeTime int64, reason string) { - if t == nil || t.TraceID == "" { +// trace stream. Best-effort — failures are logged but not propagated. +func EmitTaskScheduled(ctx context.Context, gateway *redis.Gateway, t *dto.UnifiedTask, executeTime int64, reason string) { + if t == nil || t.TraceID == "" || gateway == nil { return } event := dto.TraceStreamEvent{ @@ -156,12 +166,8 @@ func EmitTaskScheduled(ctx context.Context, t *dto.UnifiedTask, executeTime int6 }, } stream := fmt.Sprintf(consts.StreamTraceLogKey, t.TraceID) - if err := client.RedisXAdd(ctx, stream, event.ToRedisStream()); err != nil { - if err == redis.Nil { - return - } - logrus.WithField("task_id", t.TaskID). - Warnf("failed to emit task.scheduled event: %v", err) + if err := gateway.XAdd(ctx, stream, event.ToRedisStream()); err != nil { + logEmitScheduledErr(t.TaskID, err) } } @@ -177,3 +183,46 @@ func calculateExecuteTime(task *dto.UnifiedTask) error { } return nil } + +func getParentTaskLevelByID(db *gorm.DB, parentTaskID string) (int, error) { + var result model.Task + if err := db.Select("level"). + Where("id = ? AND status != ?", parentTaskID, consts.CommonDeleted). + First(&result).Error; err != nil { + return 0, fmt.Errorf("failed to find parent task with id %s: %w", parentTaskID, err) + } + return result.Level, nil +} + +func upsertTask(db *gorm.DB, task *model.Task) error { + if err := db.Clauses( + clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "execute_time", + "state", + "updated_at", + }), + }, + ).Create(task).Error; err != nil { + return fmt.Errorf("failed to upsert task: %w", err) + } + return nil +} + +func upsertTrace(db *gorm.DB, trace *model.Trace) error { + if err := db.Clauses( + clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "last_event", + "end_time", + "state", + "updated_at", + }), + }, + ).Create(trace).Error; err != nil { + return fmt.Errorf("failed to upsert trace: %w", err) + } + return nil +} diff --git a/src/service/common/template.go b/src/service/common/template.go deleted file mode 100644 index 1b71d18e..00000000 --- a/src/service/common/template.go +++ /dev/null @@ -1,62 +0,0 @@ -package common - -import ( - "aegis/utils" - "fmt" - "reflect" - "regexp" - "strings" -) - -var templateVarRegex = regexp.MustCompile(`{{\s*\.([a-zA-Z0-9_]+)\s*}}`) - -// extractTemplateVars extracts all variable names used in the template string -func extractTemplateVars(templateString string) []string { - matches := templateVarRegex.FindAllStringSubmatch(templateString, -1) - if matches == nil { - return nil - } - - variables := make([]string, 0, len(matches)) - for _, match := range matches { - if len(match) > 1 { - variables = append(variables, match[1]) - } - } - - return variables -} - -// renderTemplate renders the template string by replacing variables with values from the context structure -func renderTemplate(templateStr string, vars []string, context any) (string, error) { - contextValue := reflect.ValueOf(context) - if contextValue.Kind() == reflect.Ptr { - contextValue = contextValue.Elem() - } - - renderedString := templateStr - contextType := contextValue.Type() - - for _, varName := range vars { - fieldValue := contextValue.FieldByName(varName) - - if !fieldValue.IsValid() { - return "", fmt.Errorf("variable '%s' not found in context structure", varName) - } - - fieldType, found := contextType.FieldByName(varName) - if !found || fieldType.PkgPath != "" { - return "", fmt.Errorf("variable '%s' is not an exported field in context", varName) - } - - strValue, err := utils.ConvertSimpleTypeToString(fieldValue.Interface()) - if err != nil { - return "", fmt.Errorf("failed to convert context value for %s: %w", varName, err) - } - - renderedString = strings.ReplaceAll(renderedString, fmt.Sprintf("{{ .%s }}", varName), strValue) - renderedString = strings.ReplaceAll(renderedString, fmt.Sprintf("{{.%s}}", varName), strValue) - } - - return renderedString, nil -} diff --git a/src/service/consumer/algo_execution.go b/src/service/consumer/algo_execution.go index c0e2bafd..53d1daa7 100644 --- a/src/service/consumer/algo_execution.go +++ b/src/service/consumer/algo_execution.go @@ -3,7 +3,6 @@ package consumer import ( "context" "encoding/json" - "errors" "fmt" "math/rand" "path/filepath" @@ -11,12 +10,12 @@ import ( "strings" "time" - "aegis/client/k8s" "aegis/config" "aegis/consts" - "aegis/database" "aegis/dto" - "aegis/repository" + k8s "aegis/infra/k8s" + redis "aegis/infra/redis" + execution "aegis/module/execution" "aegis/service/common" "aegis/tracing" "aegis/utils" @@ -59,7 +58,7 @@ func (p *algoJobCreationParams) toK8sJobConfig(envVars []corev1.EnvVar, initCont } // executeAlgorithm handles the execution of an algorithm task -func executeAlgorithm(ctx context.Context, task *dto.UnifiedTask) error { +func executeAlgorithm(ctx context.Context, task *dto.UnifiedTask, deps RuntimeDeps) error { return tracing.WithSpan(ctx, func(childCtx context.Context) error { span := trace.SpanFromContext(childCtx) span.AddEvent(fmt.Sprintf("Starting algorithm execution attempt %d", task.ReStartNum+1)) @@ -67,8 +66,19 @@ func executeAlgorithm(ctx context.Context, task *dto.UnifiedTask) error { "task_id": task.TaskID, "trace_id": task.TraceID, }) + k8sGateway := deps.K8sGateway + if k8sGateway == nil { + return handleExecutionError(span, logEntry, "k8s gateway not initialized", fmt.Errorf("k8s gateway not initialized")) + } + redisGateway := deps.RedisGateway + if redisGateway == nil { + return handleExecutionError(span, logEntry, "redis gateway not initialized", fmt.Errorf("redis gateway not initialized")) + } - rateLimiter := GetAlgoExecutionRateLimiter() + rateLimiter := deps.AlgorithmRateLimiter + if rateLimiter == nil { + return handleExecutionError(span, logEntry, "algorithm execution rate limiter not initialized", fmt.Errorf("algorithm execution rate limiter not initialized")) + } acquired, err := rateLimiter.AcquireToken(childCtx, task.TaskID, task.TraceID) if err != nil { return handleExecutionError(span, logEntry, "failed to acquire rate limit token", err) @@ -84,7 +94,7 @@ func executeAlgorithm(ctx context.Context, task *dto.UnifiedTask) error { } if !acquired { - if err := rescheduleAlgoExecutionTask(childCtx, task, "failed to acquire algorithm execution token within timeout, retrying later"); err != nil { + if err := rescheduleAlgoExecutionTask(childCtx, deps.DB, redisGateway, task, "failed to acquire algorithm execution token within timeout, retrying later"); err != nil { return err } return nil @@ -96,7 +106,7 @@ func executeAlgorithm(ctx context.Context, task *dto.UnifiedTask) error { return handleExecutionError(span, logEntry, "failed to parse execution payload", err) } - executionID, err := createExecution(task.TaskID, payload.algorithm.ID, payload.datapack.ID, payload.datasetVersionID, payload.labels) + executionID, err := createExecution(childCtx, deps, task.TaskID, payload.algorithm.ID, payload.datapack.ID, payload.datasetVersionID, payload.labels) if err != nil { return handleExecutionError(span, logEntry, "failed to create execution result", err) } @@ -136,7 +146,7 @@ func executeAlgorithm(ctx context.Context, task *dto.UnifiedTask) error { executionID: executionID, payload: payload, } - if err := createAlgoJob(childCtx, params); err != nil { + if err := createAlgoJob(childCtx, k8sGateway, params); err != nil { return err } @@ -145,7 +155,7 @@ func executeAlgorithm(ctx context.Context, task *dto.UnifiedTask) error { } // rescheduleAlgoExecutionTask reschedules a algorithm execution task with a random delay between 1 to 5 minutes -func rescheduleAlgoExecutionTask(ctx context.Context, task *dto.UnifiedTask, reason string) error { +func rescheduleAlgoExecutionTask(ctx context.Context, db *gorm.DB, redisGateway *redis.Gateway, task *dto.UnifiedTask, reason string) error { return tracing.WithSpan(ctx, func(childCtx context.Context) error { span := trace.SpanFromContext(childCtx) @@ -169,11 +179,11 @@ func rescheduleAlgoExecutionTask(ctx context.Context, task *dto.UnifiedTask, rea consts.TaskTypeRunAlgorithm, consts.TaskRescheduled, reason, - ).withEvent(consts.EventNoTokenAvailable, executeTime.String()), + ).withEvent(consts.EventNoTokenAvailable, executeTime.String()).withDB(db).withRedis(redisGateway), ) task.Reschedule(executeTime) - if err := common.SubmitTask(childCtx, task); err != nil { + if err := common.SubmitTaskWithDB(childCtx, db, redisGateway, task); err != nil { span.RecordError(err) span.AddEvent("failed to submit rescheduled task") return fmt.Errorf("failed to submit rescheduled algorithm execution task: %w", err) @@ -216,7 +226,7 @@ func parseExecutionPayload(payload map[string]any) (*executionPayload, error) { } // createAlgoJob creates and submits a Kubernetes job for algorithm execution -func createAlgoJob(ctx context.Context, params *algoJobCreationParams) error { +func createAlgoJob(ctx context.Context, gateway *k8s.Gateway, params *algoJobCreationParams) error { return tracing.WithSpan(ctx, func(childCtx context.Context) error { span := trace.SpanFromContext(childCtx) logEntry := logrus.WithFields(logrus.Fields{ @@ -224,7 +234,7 @@ func createAlgoJob(ctx context.Context, params *algoJobCreationParams) error { "execution_id": params.executionID, }) - volumeMountConfigs, err := getRequiredVolumeMountConfigs([]consts.VolumeMountName{ + volumeMountConfigs, err := getRequiredVolumeMountConfigs(gateway, []consts.VolumeMountName{ consts.VolumeMountDataset, consts.VolumeMountExperimentStorage, }) @@ -262,7 +272,7 @@ func createAlgoJob(ctx context.Context, params *algoJobCreationParams) error { }, } - return k8s.CreateJob(childCtx, params.toK8sJobConfig(jobEnvVars, initContainers, volumeMountConfigs)) + return gateway.CreateJob(childCtx, params.toK8sJobConfig(jobEnvVars, initContainers, volumeMountConfigs)) }) } @@ -331,48 +341,15 @@ func getAlgoJobEnvVars(taskID string, executionID int, datapackPathPrefix, expPa } // createExecution creates a new execution record with associated labels -func createExecution(taskID string, algorithmVersionID, datapackID int, datasetVersionID *int, labelItems []dto.LabelItem) (int, error) { - var createdExecutionID int - - err := database.DB.Transaction(func(tx *gorm.DB) error { - execution := &database.Execution{ - TaskID: &taskID, - AlgorithmVersionID: algorithmVersionID, - DatapackID: datapackID, - DatasetVersionID: datasetVersionID, - State: consts.ExecutionInitial, - Status: consts.CommonEnabled, - } - - if err := repository.CreateExecution(tx, execution); err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return fmt.Errorf("%w: execution with algorithm_version_id %d and datapack_id %d already exists", consts.ErrAlreadyExists, algorithmVersionID, datapackID) - } - return fmt.Errorf("failed to create execution: %w", err) - } - - if len(labelItems) > 0 { - labels, err := common.CreateOrUpdateLabelsFromItems(tx, labelItems, consts.ExecutionCategory) - if err != nil { - return fmt.Errorf("failed to create or update labels: %w", err) - } - - labelIDs := make([]int, 0, len(labels)) - for _, label := range labels { - labelIDs = append(labelIDs, label.ID) - } - - if err := repository.AddExecutionLabels(tx, execution.ID, labelIDs); err != nil { - return fmt.Errorf("failed to add execution labels: %w", err) - } - } - - createdExecutionID = execution.ID - return nil - }) - if err != nil { - return 0, err +func createExecution(ctx context.Context, deps RuntimeDeps, taskID string, algorithmVersionID, datapackID int, datasetVersionID *int, labelItems []dto.LabelItem) (int, error) { + if deps.ExecutionOwner == nil { + return 0, fmt.Errorf("execution owner service is nil") } - - return createdExecutionID, nil + return deps.ExecutionOwner.CreateExecution(ctx, &execution.RuntimeCreateExecutionReq{ + TaskID: taskID, + AlgorithmVersionID: algorithmVersionID, + DatapackID: datapackID, + DatasetVersionID: datasetVersionID, + Labels: labelItems, + }) } diff --git a/src/service/consumer/build_container.go b/src/service/consumer/build_container.go index 11d7cccf..831c96e0 100644 --- a/src/service/consumer/build_container.go +++ b/src/service/consumer/build_container.go @@ -8,9 +8,10 @@ import ( "path/filepath" "time" - "aegis/config" "aegis/consts" "aegis/dto" + buildkit "aegis/infra/buildkit" + redis "aegis/infra/redis" "aegis/service/common" "aegis/tracing" "aegis/utils" @@ -29,6 +30,7 @@ import ( "github.com/tonistiigi/fsutil" "go.opentelemetry.io/otel/trace" "golang.org/x/sync/errgroup" + "gorm.io/gorm" ) type containerPayload struct { @@ -38,7 +40,7 @@ type containerPayload struct { } // executeBuildContainer handles the execution of a build container task -func executeBuildContainer(ctx context.Context, task *dto.UnifiedTask) error { +func executeBuildContainer(ctx context.Context, task *dto.UnifiedTask, deps RuntimeDeps) error { return tracing.WithSpan(ctx, func(childCtx context.Context) error { span := trace.SpanFromContext(childCtx) span.AddEvent(fmt.Sprintf("Starting build attempt %d", task.ReStartNum+1)) @@ -46,8 +48,19 @@ func executeBuildContainer(ctx context.Context, task *dto.UnifiedTask) error { "task_id": task.TaskID, "trace_id": task.TraceID, }) + buildKitGateway := deps.BuildKitGateway + if buildKitGateway == nil { + return handleExecutionError(span, logEntry, "buildkit gateway not initialized", fmt.Errorf("buildkit gateway not initialized")) + } + redisGateway := deps.RedisGateway + if redisGateway == nil { + return handleExecutionError(span, logEntry, "redis gateway not initialized", fmt.Errorf("redis gateway not initialized")) + } - rateLimiter := GetBuildContainerRateLimiter() + rateLimiter := deps.BuildRateLimiter + if rateLimiter == nil { + return handleExecutionError(span, logEntry, "build container rate limiter not initialized", fmt.Errorf("build container rate limiter not initialized")) + } acquired, err := rateLimiter.AcquireToken(childCtx, task.TaskID, task.TraceID) if err != nil { return handleExecutionError(span, logEntry, "failed to acquire rate limit token", err) @@ -63,7 +76,7 @@ func executeBuildContainer(ctx context.Context, task *dto.UnifiedTask) error { } if !acquired { - if err := rescheduleContainerBuildingTask(childCtx, task, "failed to acquire build token within timeout, retrying later"); err != nil { + if err := rescheduleContainerBuildingTask(childCtx, deps.DB, redisGateway, task, "failed to acquire build token within timeout, retrying later"); err != nil { return err } return nil @@ -83,7 +96,7 @@ func executeBuildContainer(ctx context.Context, task *dto.UnifiedTask) error { return handleExecutionError(span, logEntry, "failed to parse build payload", err) } - if err := buildImageAndPush(childCtx, payload, logEntry); err != nil { + if err := buildImageAndPush(childCtx, buildKitGateway, payload, logEntry); err != nil { return err } @@ -94,7 +107,7 @@ func executeBuildContainer(ctx context.Context, task *dto.UnifiedTask) error { task.Type, consts.TaskCompleted, fmt.Sprintf("Container image %s built and pushed successfully", payload.imageRef), - ).withEvent(consts.EventImageBuildSucceed, payload.imageRef), + ).withEvent(consts.EventImageBuildSucceed, payload.imageRef).withDB(deps.DB).withRedis(redisGateway), ) if err := os.RemoveAll(payload.sourcePath); err != nil { @@ -107,7 +120,7 @@ func executeBuildContainer(ctx context.Context, task *dto.UnifiedTask) error { } // rescheduleContainerBuildingTask reschedules a container building task with a random delay between 1 to 5 minutes -func rescheduleContainerBuildingTask(ctx context.Context, task *dto.UnifiedTask, reason string) error { +func rescheduleContainerBuildingTask(ctx context.Context, db *gorm.DB, redisGateway *redis.Gateway, task *dto.UnifiedTask, reason string) error { return tracing.WithSpan(ctx, func(childCtx context.Context) error { span := trace.SpanFromContext(childCtx) @@ -131,11 +144,11 @@ func rescheduleContainerBuildingTask(ctx context.Context, task *dto.UnifiedTask, task.Type, consts.TaskRescheduled, reason, - ).withEvent(consts.EventNoTokenAvailable, executeTime.String()), + ).withEvent(consts.EventNoTokenAvailable, executeTime.String()).withDB(db).withRedis(redisGateway), ) task.Reschedule(executeTime) - if err := common.SubmitTask(childCtx, task); err != nil { + if err := common.SubmitTaskWithDB(childCtx, db, redisGateway, task); err != nil { span.RecordError(err) span.AddEvent("failed to submit rescheduled task") return fmt.Errorf("failed to submit rescheduled container building task: %v", err) @@ -172,17 +185,11 @@ func parseContainerPayload(payload map[string]any) (*containerPayload, error) { } // buildImageAndPush builds the container image using BuildKit and pushes it to the registry -func buildImageAndPush(ctx context.Context, payload *containerPayload, logEntry *logrus.Entry) error { +func buildImageAndPush(ctx context.Context, buildKitGateway *buildkit.Gateway, payload *containerPayload, logEntry *logrus.Entry) error { return tracing.WithSpan(ctx, func(childCtx context.Context) error { span := trace.SpanFromContext(childCtx) - address := fmt.Sprintf("tcp://%s", config.GetString("buildkit.address")) - if address == "" { - err := fmt.Errorf("buildkit address is not configured") - return handleExecutionError(span, logEntry, "buildkit address is not configured", err) - } - - c, err := buildkitclient.New(childCtx, address) + c, err := buildKitGateway.NewClient(childCtx) if err != nil { return handleExecutionError(span, logEntry, "failed to create buildkit client", err) } diff --git a/src/service/consumer/build_datapack.go b/src/service/consumer/build_datapack.go index f8dcae51..f5a60b04 100644 --- a/src/service/consumer/build_datapack.go +++ b/src/service/consumer/build_datapack.go @@ -13,11 +13,11 @@ import ( "go.opentelemetry.io/otel/trace" corev1 "k8s.io/api/core/v1" - "aegis/client/k8s" "aegis/config" "aegis/consts" - "aegis/database" "aegis/dto" + db "aegis/infra/db" + k8s "aegis/infra/k8s" "aegis/tracing" "aegis/utils" ) @@ -35,7 +35,7 @@ type datapackJobCreationParams struct { annotations map[string]string labels map[string]string payload *datapackPayload - dbConfig *database.DatabaseConfig + dbConfig *db.DatabaseConfig } func (p *datapackJobCreationParams) toK8sJobConfig(envVars []corev1.EnvVar, volumeMountConfigs []k8s.VolumeMountConfig) *k8s.JobConfig { @@ -51,11 +51,14 @@ func (p *datapackJobCreationParams) toK8sJobConfig(envVars []corev1.EnvVar, volu } } -// executeBuildDatapack handles the execution of a datapack building task -func executeBuildDatapack(ctx context.Context, task *dto.UnifiedTask) error { +func executeBuildDatapackWithDeps(ctx context.Context, task *dto.UnifiedTask, deps RuntimeDeps) error { return tracing.WithSpan(ctx, func(childCtx context.Context) error { span := trace.SpanFromContext(childCtx) logEntry := logrus.WithFields(logrus.Fields{"task_id": task.TaskID, "trace_id": task.TraceID}) + k8sGateway := deps.K8sGateway + if k8sGateway == nil { + return handleExecutionError(span, logEntry, "k8s gateway not initialized", fmt.Errorf("k8s gateway not initialized")) + } payload, err := parseDatapackPayload(task.Payload) if err != nil { @@ -88,9 +91,9 @@ func executeBuildDatapack(ctx context.Context, task *dto.UnifiedTask) error { annotations: annotations, labels: jobLabels, payload: payload, - dbConfig: database.NewDatabaseConfig("clickhouse"), + dbConfig: db.NewDatabaseConfig("clickhouse"), } - return createDatapackJob(childCtx, params) + return createDatapackJob(childCtx, k8sGateway, params) }) } @@ -124,7 +127,7 @@ func parseDatapackPayload(payload map[string]any) (*datapackPayload, error) { }, nil } -func createDatapackJob(ctx context.Context, params *datapackJobCreationParams) error { +func createDatapackJob(ctx context.Context, gateway *k8s.Gateway, params *datapackJobCreationParams) error { return tracing.WithSpan(ctx, func(childCtx context.Context) error { span := trace.SpanFromContext(childCtx) logEntry := logrus.WithFields(logrus.Fields{ @@ -132,7 +135,7 @@ func createDatapackJob(ctx context.Context, params *datapackJobCreationParams) e "datapack_id": params.payload.datapack.ID, }) - volumeMountConfigs, err := getRequiredVolumeMountConfigs([]consts.VolumeMountName{ + volumeMountConfigs, err := getRequiredVolumeMountConfigs(gateway, []consts.VolumeMountName{ consts.VolumeMountDataset, }) if err != nil { @@ -146,11 +149,11 @@ func createDatapackJob(ctx context.Context, params *datapackJobCreationParams) e return handleExecutionError(span, logEntry, "failed to get job environment variables", err) } - return k8s.CreateJob(childCtx, params.toK8sJobConfig(jobEnvVars, volumeMountConfigs)) + return gateway.CreateJob(childCtx, params.toK8sJobConfig(jobEnvVars, volumeMountConfigs)) }) } -func getDatapackJobEnvVars(taskID string, datapackPathPrefix string, payload *datapackPayload, dbConfig *database.DatabaseConfig) ([]corev1.EnvVar, error) { +func getDatapackJobEnvVars(taskID string, datapackPathPrefix string, payload *datapackPayload, dbConfig *db.DatabaseConfig) ([]corev1.EnvVar, error) { tz := config.GetString("system.timezone") if tz == "" { tz = time.Local.String() diff --git a/src/service/consumer/collect_result.go b/src/service/consumer/collect_result.go index fcc73793..83d4fedf 100644 --- a/src/service/consumer/collect_result.go +++ b/src/service/consumer/collect_result.go @@ -4,18 +4,18 @@ import ( "context" "fmt" - "aegis/client" "aegis/config" "aegis/consts" - "aegis/database" "aegis/dto" - "aegis/repository" + redis "aegis/infra/redis" + execution "aegis/module/execution" "aegis/service/common" "aegis/tracing" "aegis/utils" "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/trace" + "gorm.io/gorm" ) type collectionPayload struct { @@ -24,8 +24,17 @@ type collectionPayload struct { executionID int } -func executeCollectResult(ctx context.Context, task *dto.UnifiedTask) error { +func executeCollectResult(ctx context.Context, task *dto.UnifiedTask, deps RuntimeDeps) error { return tracing.WithSpan(ctx, func(childCtx context.Context) error { + db := deps.DB + if db == nil { + return fmt.Errorf("consumer runtime db is nil") + } + redisGateway := deps.RedisGateway + if redisGateway == nil { + return fmt.Errorf("consumer redis gateway is nil") + } + logEntry := logrus.WithField("task_id", task.TaskID) span := trace.SpanFromContext(childCtx) @@ -38,7 +47,7 @@ func executeCollectResult(ctx context.Context, task *dto.UnifiedTask) error { } if collectPayload.algorithm.ContainerName == config.GetDetectorName() { - results, err := repository.ListDetectorResultsByExecutionID(database.DB, collectPayload.executionID) + results, err := loadDetectorResults(childCtx, deps, db, collectPayload.executionID) if err != nil { logEntry.Errorf("failed to get detector results by execution ID: %v", err) span.AddEvent("failed to get detector results by execution ID") @@ -72,18 +81,20 @@ func executeCollectResult(ctx context.Context, task *dto.UnifiedTask) error { span.AddEvent(message) } - updateTaskState(childCtx, taskCompletedWithEvent(task, eventName, results)) + updateTaskState(childCtx, taskCompletedWithEvent(task, eventName, results).withDB(db).withRedis(redisGateway)) logEntry.Info("Collect detector result task completed successfully") - if hasIssues && client.CheckCachedField(childCtx, consts.InjectionAlgorithmsKey, task.GroupID) { - var algorithms []dto.ContainerVersionItem - err := client.GetHashField(childCtx, consts.InjectionAlgorithmsKey, task.GroupID, &algorithms) + if hasIssues { + algorithms, cached, err := loadCachedInjectionAlgorithms(redisGateway, childCtx, task.GroupID) if err != nil { span.AddEvent("failed to get algorithms from redis") span.RecordError(err) return fmt.Errorf("failed to get algorithms from redis: %w", err) } + if !cached { + return nil + } for idx, algorithm := range algorithms { payload := map[string]any{ @@ -91,7 +102,7 @@ func executeCollectResult(ctx context.Context, task *dto.UnifiedTask) error { consts.ExecuteDatapack: collectPayload.datapack, } - if err := produceAlgorithmExeuctionTask(childCtx, task, payload, idx); err != nil { + if err := produceAlgorithmExeuctionTask(childCtx, db, deps.RedisGateway, task, payload, idx); err != nil { span.AddEvent("failed to submit algorithm execution task") span.RecordError(err) return fmt.Errorf("failed to submit algorithm execution task: %w", err) @@ -104,7 +115,7 @@ func executeCollectResult(ctx context.Context, task *dto.UnifiedTask) error { return nil } - results, err := repository.ListGranularityResultsByExecutionID(database.DB, collectPayload.executionID) + results, err := loadGranularityResults(childCtx, deps, db, collectPayload.executionID) if err != nil { span.AddEvent("failed to get detector results by execution ID") span.RecordError(err) @@ -119,13 +130,35 @@ func executeCollectResult(ctx context.Context, task *dto.UnifiedTask) error { span.AddEvent(message) } - updateTaskState(childCtx, taskCompletedWithEvent(task, eventName, results)) + updateTaskState(childCtx, taskCompletedWithEvent(task, eventName, results).withDB(db).withRedis(redisGateway)) logEntry.Info("Collect algorithm result task completed successfully") return nil }) } +func loadDetectorResults(ctx context.Context, deps RuntimeDeps, _ *gorm.DB, executionID int) ([]execution.DetectorResultItem, error) { + if deps.ExecutionOwner == nil { + return nil, fmt.Errorf("execution owner service is nil") + } + resp, err := deps.ExecutionOwner.GetExecution(ctx, executionID) + if err != nil { + return nil, err + } + return resp.DetectorResults, nil +} + +func loadGranularityResults(ctx context.Context, deps RuntimeDeps, _ *gorm.DB, executionID int) ([]execution.GranularityResultItem, error) { + if deps.ExecutionOwner == nil { + return nil, fmt.Errorf("execution owner service is nil") + } + resp, err := deps.ExecutionOwner.GetExecution(ctx, executionID) + if err != nil { + return nil, err + } + return resp.GranularityResults, nil +} + // parseCollectPayload parses the payload for collect result tasks func parseCollectPayload(payload map[string]any) (*collectionPayload, error) { algorithm, err := utils.ConvertToType[dto.ContainerVersionItem](payload[consts.CollectAlgorithm]) @@ -152,7 +185,7 @@ func parseCollectPayload(payload map[string]any) (*collectionPayload, error) { } // produceAlgorithmExeuctionTask produces an algorithm execution task into Redis -func produceAlgorithmExeuctionTask(ctx context.Context, task *dto.UnifiedTask, payload map[string]any, index int) error { +func produceAlgorithmExeuctionTask(ctx context.Context, db *gorm.DB, redisGateway *redis.Gateway, task *dto.UnifiedTask, payload map[string]any, index int) error { newTask := &dto.UnifiedTask{ Type: consts.TaskTypeRunAlgorithm, Immediate: true, @@ -166,7 +199,7 @@ func produceAlgorithmExeuctionTask(ctx context.Context, task *dto.UnifiedTask, p State: consts.TaskPending, TraceCarrier: task.TraceCarrier, } - err := common.SubmitTask(ctx, newTask) + err := common.SubmitTaskWithDB(ctx, db, redisGateway, newTask) if err != nil { return fmt.Errorf("failed to submit algorithm exectuion task: %w", err) } diff --git a/src/service/consumer/common.go b/src/service/consumer/common.go index 39002190..da3d8a40 100644 --- a/src/service/consumer/common.go +++ b/src/service/consumer/common.go @@ -1,8 +1,8 @@ package consumer import ( - "aegis/client/k8s" "aegis/consts" + k8s "aegis/infra/k8s" "fmt" "github.com/sirupsen/logrus" @@ -16,8 +16,12 @@ const ( ) // getRequiredVolumeMountConfigs retrieves the volume mount configurations for the specified required keys -func getRequiredVolumeMountConfigs(requiredKeys []consts.VolumeMountName) ([]k8s.VolumeMountConfig, error) { - volumeMountConfigMap, err := k8s.GetVolumeMountConfigMap() +func getRequiredVolumeMountConfigs(gateway *k8s.Gateway, requiredKeys []consts.VolumeMountName) ([]k8s.VolumeMountConfig, error) { + if gateway == nil { + return nil, fmt.Errorf("k8s gateway is nil") + } + + volumeMountConfigMap, err := gateway.GetVolumeMountConfigMap() if err != nil { return nil, fmt.Errorf("failed to get volume mount configuration map: %w", err) } diff --git a/src/service/consumer/config_handlers.go b/src/service/consumer/config_handlers.go index 4d129539..55d9ead3 100644 --- a/src/service/consumer/config_handlers.go +++ b/src/service/consumer/config_handlers.go @@ -5,9 +5,9 @@ import ( "fmt" "time" - "aegis/client/k8s" "aegis/config" "aegis/consts" + k8s "aegis/infra/k8s" "aegis/service/common" "github.com/sirupsen/logrus" @@ -15,13 +15,21 @@ import ( // RegisterConsumerHandlers registers all consumer-scoped configuration handlers. // Should be called during consumer initialization, after RegisterGlobalHandlers. -func RegisterConsumerHandlers() { +func RegisterConsumerHandlers( + controller *k8s.Controller, + monitor NamespaceMonitor, + publisher common.ConfigPublisher, + restartLimiter *TokenBucketRateLimiter, + buildLimiter *TokenBucketRateLimiter, + algoLimiter *TokenBucketRateLimiter, +) { scope := consts.ConfigScopeConsumer - common.RegisterHandler(newChaosSystemCountHandler(GetMonitor(), k8s.GetK8sController())) + common.RegisterHandler(newChaosSystemCountHandler(monitor, controller, publisher)) common.RegisterHandler(newRateLimitingConfigHandler( - GetRestartPedestalRateLimiter(), - GetBuildContainerRateLimiter(), - GetAlgoExecutionRateLimiter(), + publisher, + restartLimiter, + buildLimiter, + algoLimiter, )) logrus.Infof("Registered consumer config handlers: %v", common.ListRegisteredConfigKeys(&scope)) } @@ -53,19 +61,20 @@ func UpdateK8sController(controller *k8s.Controller, toAdd, toRemove []string) e // ===================================================================== type chaosSystemCountHandler struct { - monitor *monitor + monitor NamespaceMonitor controller *k8s.Controller + publisher common.ConfigPublisher } -func newChaosSystemCountHandler(m *monitor, c *k8s.Controller) *chaosSystemCountHandler { - return &chaosSystemCountHandler{monitor: m, controller: c} +func newChaosSystemCountHandler(m NamespaceMonitor, c *k8s.Controller, publisher common.ConfigPublisher) *chaosSystemCountHandler { + return &chaosSystemCountHandler{monitor: m, controller: c, publisher: publisher} } func (h *chaosSystemCountHandler) Category() string { return "injection.system.count" } func (h *chaosSystemCountHandler) Scope() consts.ConfigScope { return consts.ConfigScopeConsumer } func (h *chaosSystemCountHandler) Handle(ctx context.Context, key, oldValue, newValue string) error { - return common.PublishWrapper(ctx, func() error { + return common.PublishWrapper(ctx, h.publisher, func() error { return config.GetChaosSystemConfigManager().Reload(h.onUpdate) }) } @@ -115,17 +124,20 @@ func (h *chaosSystemCountHandler) onUpdate() error { // ===================================================================== type rateLimitingConfigHandler struct { + publisher common.ConfigPublisher restartLimiter *TokenBucketRateLimiter buildLimiter *TokenBucketRateLimiter algoLimiter *TokenBucketRateLimiter } func newRateLimitingConfigHandler( + publisher common.ConfigPublisher, restartLimiter *TokenBucketRateLimiter, buildLimiter *TokenBucketRateLimiter, algoLimiter *TokenBucketRateLimiter, ) *rateLimitingConfigHandler { return &rateLimitingConfigHandler{ + publisher: publisher, restartLimiter: restartLimiter, buildLimiter: buildLimiter, algoLimiter: algoLimiter, @@ -136,7 +148,7 @@ func (h *rateLimitingConfigHandler) Category() string { return "rate_li func (h *rateLimitingConfigHandler) Scope() consts.ConfigScope { return consts.ConfigScopeConsumer } func (h *rateLimitingConfigHandler) Handle(ctx context.Context, key, oldValue, newValue string) error { - return common.PublishWrapper(ctx, func() error { + return common.PublishWrapper(ctx, h.publisher, func() error { logrus.WithFields(logrus.Fields{ "key": key, "old_value": oldValue, diff --git a/src/service/consumer/distribute_tasks.go b/src/service/consumer/distribute_tasks.go index fc35b4ea..10be995c 100644 --- a/src/service/consumer/distribute_tasks.go +++ b/src/service/consumer/distribute_tasks.go @@ -12,7 +12,7 @@ import ( "github.com/sirupsen/logrus" ) -func dispatchTask(ctx context.Context, task *dto.UnifiedTask) error { +func dispatchTask(ctx context.Context, task *dto.UnifiedTask, deps RuntimeDeps) error { defer func() { if r := recover(); r != nil { logrus.Errorf("Task panic: %v\n%s", r, debug.Stack()) @@ -23,7 +23,7 @@ func dispatchTask(ctx context.Context, task *dto.UnifiedTask) error { tracing.SetSpanAttribute(ctx, consts.TaskTypeKey, consts.GetTaskTypeName(task.Type)) tracing.SetSpanAttribute(ctx, consts.TaskStateKey, consts.GetTaskStateName(consts.TaskPending)) - publishEvent(ctx, fmt.Sprintf(consts.StreamTraceLogKey, task.TraceID), dto.TraceStreamEvent{ + publishEvent(deps.RedisGateway, ctx, fmt.Sprintf(consts.StreamTraceLogKey, task.TraceID), dto.TraceStreamEvent{ TaskID: task.TaskID, TaskType: task.Type, EventName: consts.EventTaskStarted, @@ -33,17 +33,17 @@ func dispatchTask(ctx context.Context, task *dto.UnifiedTask) error { var err error switch task.Type { case consts.TaskTypeBuildContainer: - err = executeBuildContainer(ctx, task) + err = executeBuildContainer(ctx, task, deps) case consts.TaskTypeRestartPedestal: - err = executeRestartPedestal(ctx, task) + err = executeRestartPedestal(ctx, task, deps) case consts.TaskTypeFaultInjection: - err = executeFaultInjection(ctx, task) + err = executeFaultInjection(ctx, task, deps) case consts.TaskTypeBuildDatapack: - err = executeBuildDatapack(ctx, task) + err = executeBuildDatapackWithDeps(ctx, task, deps) case consts.TaskTypeRunAlgorithm: - err = executeAlgorithm(ctx, task) + err = executeAlgorithm(ctx, task, deps) case consts.TaskTypeCollectResult: - err = executeCollectResult(ctx, task) + err = executeCollectResult(ctx, task, deps) default: err = fmt.Errorf("unknown task type: %d", task.Type) } diff --git a/src/service/consumer/fault_injection.go b/src/service/consumer/fault_injection.go index 03b4c0ca..8b0e8955 100644 --- a/src/service/consumer/fault_injection.go +++ b/src/service/consumer/fault_injection.go @@ -9,10 +9,9 @@ import ( "time" "aegis/consts" - "aegis/database" "aegis/dto" - "aegis/repository" - "aegis/service/common" + "aegis/model" + injection "aegis/module/injection" "aegis/tracing" "aegis/utils" @@ -20,7 +19,6 @@ import ( "github.com/OperationsPAI/chaos-experiment/pkg/guidedcli" "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/trace" - "gorm.io/gorm" ) // injectionPayload contains all necessary data for executing a fault injection batch @@ -29,8 +27,7 @@ type injectionPayload struct { preDuration int nodes []chaos.Node // guidedConfigs is populated when the inject task came from the guided-cli - // path (chaos_type-bearing specs). Mutually exclusive with nodes. The - // executor calls guidedcli.BuildInjection to produce InjectionConf. + // path. Mutually exclusive with nodes. guidedConfigs []guidedcli.GuidedConfig namespace string pedestal chaos.SystemType @@ -39,41 +36,33 @@ type injectionPayload struct { system chaos.SystemType } -type batchManager struct { +type FaultBatchManager struct { mu sync.RWMutex batchCounts map[string]int batchInjections map[string][]string } -var ( - batchManagerInstance *batchManager - batchManagerOnce sync.Once -) - -func getBatchManager() *batchManager { - batchManagerOnce.Do(func() { - batchManagerInstance = &batchManager{ - batchCounts: make(map[string]int), - batchInjections: make(map[string][]string), - } - }) - return batchManagerInstance +func NewFaultBatchManager() *FaultBatchManager { + return &FaultBatchManager{ + batchCounts: make(map[string]int), + batchInjections: make(map[string][]string), + } } -func (bm *batchManager) deleteBatch(batchID string) { +func (bm *FaultBatchManager) deleteBatch(batchID string) { bm.mu.Lock() defer bm.mu.Unlock() delete(bm.batchCounts, batchID) delete(bm.batchInjections, batchID) } -func (bm *batchManager) incrementBatchCount(batchID string) { +func (bm *FaultBatchManager) incrementBatchCount(batchID string) { bm.mu.Lock() defer bm.mu.Unlock() bm.batchCounts[batchID]++ } -func (bm *batchManager) isFinished(batchID string) bool { +func (bm *FaultBatchManager) isFinished(batchID string) bool { bm.mu.RLock() defer bm.mu.RUnlock() @@ -89,7 +78,7 @@ func (bm *batchManager) isFinished(batchID string) bool { return count >= len(injectionNames) } -func (bm *batchManager) setBatchInjections(batchID string, injectionNames []string) { +func (bm *FaultBatchManager) setBatchInjections(batchID string, injectionNames []string) { bm.mu.Lock() defer bm.mu.Unlock() bm.batchCounts[batchID] = 0 @@ -108,8 +97,13 @@ func (bm *batchManager) setBatchInjections(batchID string, injectionNames []stri // Storage format: // - engine_config: JSON array of all chaos.Node objects // - display_config: JSON array of display maps for each fault -func executeFaultInjection(ctx context.Context, task *dto.UnifiedTask) error { +func executeFaultInjection(ctx context.Context, task *dto.UnifiedTask, deps RuntimeDeps) error { return tracing.WithSpan(ctx, func(childCtx context.Context) error { + batchManager := deps.FaultBatchManager + if batchManager == nil { + return fmt.Errorf("fault batch manager is nil") + } + span := trace.SpanFromContext(childCtx) logEntry := logrus.WithFields(logrus.Fields{ "task_id": task.TaskID, @@ -121,7 +115,10 @@ func executeFaultInjection(ctx context.Context, task *dto.UnifiedTask) error { return handleExecutionError(span, logEntry, "failed to parse injection payload", err) } - monitor := GetMonitor() + monitor := deps.Monitor + if monitor == nil { + return handleExecutionError(span, logEntry, "monitor not initialized", fmt.Errorf("monitor not initialized")) + } toReleased := false if err := monitor.CheckNamespaceToInject(payload.namespace, time.Now(), task.TraceID); err != nil { toReleased = true @@ -140,55 +137,53 @@ func executeFaultInjection(ctx context.Context, task *dto.UnifiedTask) error { }() // Process all faults in the batch. Guided and legacy paths converge on - // []InjectionConf; only the upstream conversion differs. The engine - // config we persist differs too: guided path stores the GuidedConfig - // slice (human-auditable), legacy path stores the Node slice. + // []InjectionConf; only the upstream conversion differs. batchLen := len(payload.nodes) if len(payload.guidedConfigs) > 0 { batchLen = len(payload.guidedConfigs) } injectionConfs := make([]chaos.InjectionConf, 0, batchLen) displayMaps := make([]map[string]any, 0, batchLen) - groundtruths := make([]database.Groundtruth, 0, batchLen) + groundtruths := make([]model.Groundtruth, 0, batchLen) if len(payload.guidedConfigs) > 0 { for i, cfg := range payload.guidedConfigs { - conf, _, err := guidedcli.BuildInjection(childCtx, cfg) + conf, _, err := guidedcli.BuildInjection(ctx, cfg) if err != nil { return handleExecutionError(span, logEntry, fmt.Sprintf("failed to build guided injection %d", i), err) } - displayMap, err := conf.GetDisplayConfig(childCtx) + displayMap, err := conf.GetDisplayConfig(ctx) if err != nil { return handleExecutionError(span, logEntry, fmt.Sprintf("failed to get display config for guided config %d", i), err) } - chaosGroundtruth, err := conf.GetGroundtruth(childCtx) + chaosGroundtruth, err := conf.GetGroundtruth(ctx) if err != nil { return handleExecutionError(span, logEntry, fmt.Sprintf("failed to get groundtruth for guided config %d", i), err) } injectionConfs = append(injectionConfs, conf) displayMaps = append(displayMaps, displayMap) - groundtruths = append(groundtruths, *database.NewDBGroundtruth(&chaosGroundtruth)) + groundtruths = append(groundtruths, *model.NewDBGroundtruth(&chaosGroundtruth)) } } else { for i, node := range payload.nodes { - injectionConf, err := chaos.NodeToStruct[chaos.InjectionConf](childCtx, &node) + injectionConf, err := chaos.NodeToStruct[chaos.InjectionConf](ctx, &node) if err != nil { return handleExecutionError(span, logEntry, fmt.Sprintf("failed to convert node %d to injection conf", i), err) } - displayMap, err := injectionConf.GetDisplayConfig(childCtx) + displayMap, err := injectionConf.GetDisplayConfig(ctx) if err != nil { return handleExecutionError(span, logEntry, fmt.Sprintf("failed to get display config for node %d", i), err) } - chaosGroundtruth, err := injectionConf.GetGroundtruth(childCtx) + chaosGroundtruth, err := injectionConf.GetGroundtruth(ctx) if err != nil { return handleExecutionError(span, logEntry, fmt.Sprintf("failed to get groundtruth for node %d", i), err) } injectionConfs = append(injectionConfs, *injectionConf) displayMaps = append(displayMaps, displayMap) - groundtruths = append(groundtruths, *database.NewDBGroundtruth(&chaosGroundtruth)) + groundtruths = append(groundtruths, *model.NewDBGroundtruth(&chaosGroundtruth)) } } @@ -198,8 +193,8 @@ func executeFaultInjection(ctx context.Context, task *dto.UnifiedTask) error { return handleExecutionError(span, logEntry, "failed to marshal injection specs to display config", err) } - // Marshal engine config as array — the raw user-facing form of each - // injection. Guided path: GuidedConfig slice. Legacy path: Node slice. + // Marshal engine config as array — guided path stores GuidedConfigs + // (human-auditable); legacy stores Nodes. var engineData []byte if len(payload.guidedConfigs) > 0 { engineData, err = json.Marshal(payload.guidedConfigs) @@ -244,7 +239,7 @@ func executeFaultInjection(ctx context.Context, task *dto.UnifiedTask) error { if len(names) > 1 { name = batchID faultType = consts.Hybrid - getBatchManager().setBatchInjections(batchID, names) + batchManager.setBatchInjections(batchID, names) } else { name = names[0] switch { @@ -259,44 +254,30 @@ func executeFaultInjection(ctx context.Context, task *dto.UnifiedTask) error { } } - return database.DB.Transaction(func(tx *gorm.DB) error { - injection := &database.FaultInjection{ - Name: name, - FaultType: faultType, - Category: payload.pedestal, - Description: fmt.Sprintf("Fault batch for task %s (%d faults)", task.TaskID, len(injectionConfs)), - DisplayConfig: utils.StringPtr(string(displayData)), - EngineConfig: string(engineData), - Groundtruths: groundtruths, - GroundtruthSource: consts.GroundtruthSourceAuto, - PreDuration: payload.preDuration, - State: consts.DatapackInitial, - Status: consts.CommonEnabled, - TaskID: &task.TaskID, - BenchmarkID: utils.IntPtr(payload.benchmark.ID), - PedestalID: utils.IntPtr(payload.pedestalID), - } - - if err = repository.CreateInjection(database.DB, injection); err != nil { - return handleExecutionError(span, logEntry, "failed to write fault injection schedule to database", err) - } - - labels, err := common.CreateOrUpdateLabelsFromItems(tx, payload.labels, consts.InjectionCategory) - if err != nil { - return handleExecutionError(span, logEntry, "failed to create or update labels", err) - } - - labelIDs := make([]int, 0, len(labels)) - for _, label := range labels { - labelIDs = append(labelIDs, label.ID) - } - - if err := repository.AddInjectionLabels(tx, injection.ID, labelIDs); err != nil { - return handleExecutionError(span, logEntry, "failed to associate labels with injection", err) - } + if deps.InjectionOwner == nil { + return handleExecutionError(span, logEntry, "injection owner service is nil", fmt.Errorf("missing injection owner service")) + } - return nil + _, err = deps.InjectionOwner.CreateInjection(childCtx, &injection.RuntimeCreateInjectionReq{ + Name: name, + FaultType: faultType, + Category: payload.pedestal, + Description: fmt.Sprintf("Fault batch for task %s (%d faults)", task.TaskID, len(payload.nodes)), + DisplayConfig: string(displayData), + EngineConfig: string(engineData), + Groundtruths: groundtruths, + GroundtruthSource: consts.GroundtruthSourceAuto, + PreDuration: payload.preDuration, + TaskID: task.TaskID, + BenchmarkID: utils.IntPtr(payload.benchmark.ID), + PedestalID: utils.IntPtr(payload.pedestalID), + Labels: payload.labels, + State: consts.DatapackInitial, }) + if err != nil { + return handleExecutionError(span, logEntry, "failed to write fault injection schedule to owner service", err) + } + return nil }) } @@ -337,12 +318,10 @@ func parseInjectionPayload(payload map[string]any) (*injectionPayload, error) { return nil, fmt.Errorf("at least one guided config is required in %s", consts.InjectGuidedConfigs) } } else { - // Parse nodes array - now supports multiple fault nodes nodes, err = utils.ConvertToType[[]chaos.Node](payload[consts.InjectNodes]) if err != nil { return nil, fmt.Errorf(message, consts.InjectNodes) } - if len(nodes) == 0 { return nil, fmt.Errorf("at least one fault node is required in %s", consts.InjectNodes) } diff --git a/src/service/consumer/jvm_runtime_mutator.go b/src/service/consumer/jvm_runtime_mutator.go index 4d33ee56..087f25fc 100644 --- a/src/service/consumer/jvm_runtime_mutator.go +++ b/src/service/consumer/jvm_runtime_mutator.go @@ -10,7 +10,7 @@ import ( "encoding/json" "fmt" - "aegis/database" + "aegis/model" "github.com/OperationsPAI/chaos-experiment/handler" "github.com/sirupsen/logrus" ) @@ -38,7 +38,7 @@ type JVMRuntimeMutatorConfig struct { } // ExecuteJVMRuntimeMutatorChaos executes a JVM runtime mutator chaos injection -func (c *Consumer) ExecuteJVMRuntimeMutatorChaos(task *database.Task) error { +func (c *Consumer) ExecuteJVMRuntimeMutatorChaos(task *model.Task) error { logrus.Infof("Executing JVM runtime mutator chaos for task %s", task.ID) // Parse task parameters @@ -81,7 +81,7 @@ func (c *Consumer) ExecuteJVMRuntimeMutatorChaos(task *database.Task) error { } // Execute chaos injection - ctx := context.Background() + ctx := consumerDetachedContext() chaosName, err := spec.Create(c.k8sClient, handler.WithNamespace(mutatorTask.Target.Namespace), handler.WithContext(ctx), diff --git a/src/service/consumer/k8s_handler.go b/src/service/consumer/k8s_handler.go index e0c8d208..0ffb2a6c 100644 --- a/src/service/consumer/k8s_handler.go +++ b/src/service/consumer/k8s_handler.go @@ -3,17 +3,16 @@ package consumer import ( "context" "encoding/json" - "errors" "fmt" "strconv" "time" - "aegis/client/k8s" "aegis/config" "aegis/consts" - "aegis/database" "aegis/dto" - "aegis/repository" + k8s "aegis/infra/k8s" + redis "aegis/infra/redis" + container "aegis/module/container" "aegis/service/common" "aegis/utils" @@ -33,14 +32,16 @@ const ( // errorContext holds common context for error handling type errorContext struct { - ctx context.Context - span trace.Span - logEntry *logrus.Entry - labels *taskIdentifiers + ctx context.Context + span trace.Span + logEntry *logrus.Entry + labels *taskIdentifiers + db *gorm.DB + redisGateway *redis.Gateway } // NewErrorContext creates an ErrorContext from parsed labels -func NewErrorContext(ctx context.Context, span trace.Span, labels *taskIdentifiers) *errorContext { +func NewErrorContext(ctx context.Context, db *gorm.DB, redisGateway *redis.Gateway, span trace.Span, labels *taskIdentifiers) *errorContext { return &errorContext{ ctx: ctx, span: span, @@ -48,7 +49,9 @@ func NewErrorContext(ctx context.Context, span trace.Span, labels *taskIdentifie "task_id": labels.taskID, "trace_id": labels.traceID, }), - labels: labels, + labels: labels, + db: db, + redisGateway: redisGateway, } } @@ -74,7 +77,7 @@ func (e *errorContext) Fatal(logEntry *logrus.Entry, message string, err error) e.labels.taskType, consts.TaskError, message, - ), + ).withDB(e.db).withRedis(e.redisGateway), ) } @@ -124,10 +127,25 @@ type jobLabels struct { } type k8sHandler struct { + db *gorm.DB + store *stateStore + monitor NamespaceMonitor + algoLimiter *TokenBucketRateLimiter + k8sGateway *k8s.Gateway + redisGateway *redis.Gateway + batchManager *FaultBatchManager } -func NewHandler() *k8sHandler { - return &k8sHandler{} +func NewHandler(db *gorm.DB, monitor NamespaceMonitor, algoLimiter *TokenBucketRateLimiter, k8sGateway *k8s.Gateway, redisGateway *redis.Gateway, batchManager *FaultBatchManager, execution ExecutionOwner, injection InjectionOwner) *k8sHandler { + return &k8sHandler{ + db: db, + store: newStateStore(execution, injection), + monitor: monitor, + algoLimiter: algoLimiter, + k8sGateway: k8sGateway, + redisGateway: redisGateway, + batchManager: batchManager, + } } func (h *k8sHandler) HandleCRDAdd(name string, annotations map[string]string, labels map[string]string) { @@ -143,7 +161,7 @@ func (h *k8sHandler) HandleCRDAdd(name string, annotations map[string]string, la return } - taskCtx := otel.GetTextMapPropagator().Extract(context.Background(), parsedAnnotations.taskCarrier) + taskCtx := otel.GetTextMapPropagator().Extract(consumerDetachedContext(), parsedAnnotations.taskCarrier) updateTaskState(taskCtx, newTaskStateUpdate( parsedLabels.traceID, @@ -151,7 +169,7 @@ func (h *k8sHandler) HandleCRDAdd(name string, annotations map[string]string, la parsedLabels.taskType, consts.TaskRunning, fmt.Sprintf("injecting fault for task %s", parsedLabels.taskID), - ).withEvent(consts.EventFaultInjectionStarted, name), + ).withEvent(consts.EventFaultInjectionStarted, name).withDB(h.db).withRedis(h.redisGateway), ) } @@ -168,8 +186,12 @@ func (h *k8sHandler) HandleCRDDelete(namespace string, annotations map[string]st return } - taskCtx := otel.GetTextMapPropagator().Extract(context.Background(), parsedAnnotations.taskCarrier) - if err := GetMonitor().ReleaseLock(taskCtx, namespace, parsedLabels.traceID); err != nil { + taskCtx := otel.GetTextMapPropagator().Extract(consumerDetachedContext(), parsedAnnotations.taskCarrier) + if h.monitor == nil { + logrus.Warn("namespace monitor not initialized, skipping lock release") + return + } + if err := h.monitor.ReleaseLock(taskCtx, namespace, parsedLabels.traceID); err != nil { logrus.Errorf("failed to release lock for namespace %s: %v", namespace, err) } } @@ -187,7 +209,7 @@ func (h *k8sHandler) HandleCRDFailed(name string, annotations map[string]string, return } - taskCtx := otel.GetTextMapPropagator().Extract(context.Background(), parsedAnnotations.taskCarrier) + taskCtx := otel.GetTextMapPropagator().Extract(consumerDetachedContext(), parsedAnnotations.taskCarrier) taskSpan := trace.SpanFromContext(taskCtx) updateTaskState(taskCtx, @@ -202,13 +224,13 @@ func (h *k8sHandler) HandleCRDFailed(name string, annotations map[string]string, State: consts.GetTaskStateName(consts.TaskError), Msg: errMsg, }, - ), + ).withDB(h.db).withRedis(h.redisGateway), ) - errCtx := NewErrorContext(taskCtx, taskSpan, &parsedLabels.taskIdentifiers) + errCtx := NewErrorContext(taskCtx, h.db, h.redisGateway, taskSpan, &parsedLabels.taskIdentifiers) postprocess := func(injectionName string) { - if err := updateInjectionState(injectionName, consts.DatapackInjectFailed); err != nil { + if err := h.store.updateInjectionState(taskCtx, injectionName, consts.DatapackInjectFailed); err != nil { errCtx.Warn(nil, "update injection state failed", err) } } @@ -216,7 +238,11 @@ func (h *k8sHandler) HandleCRDFailed(name string, annotations map[string]string, if !parsedLabels.IsHybrid { postprocess(name) } else { - bm := getBatchManager() + bm := h.batchManager + if bm == nil { + errCtx.Warn(nil, "fault batch manager not initialized", fmt.Errorf("fault batch manager not initialized")) + return + } bm.incrementBatchCount(parsedLabels.batchID) // Check if batch is finished and delete if done @@ -240,8 +266,8 @@ func (h *k8sHandler) HandleCRDSucceeded(namespace, pod, name string, startTime, return } - taskCtx := otel.GetTextMapPropagator().Extract(context.Background(), parsedAnnotations.taskCarrier) - traceCtx := otel.GetTextMapPropagator().Extract(context.Background(), parsedAnnotations.traceCarrier) + taskCtx := otel.GetTextMapPropagator().Extract(consumerDetachedContext(), parsedAnnotations.taskCarrier) + traceCtx := otel.GetTextMapPropagator().Extract(consumerDetachedContext(), parsedAnnotations.traceCarrier) logEntry := logrus.WithFields(logrus.Fields{ "task_id": parsedLabels.taskID, @@ -259,17 +285,17 @@ func (h *k8sHandler) HandleCRDSucceeded(namespace, pod, name string, startTime, parsedLabels.taskType, consts.TaskCompleted, fmt.Sprintf(consts.TaskMsgCompleted, parsedLabels.taskID), - ).withEvent(consts.EventFaultInjectionCompleted, name), + ).withEvent(consts.EventFaultInjectionCompleted, name).withDB(h.db).withRedis(h.redisGateway), ) - errCtx := NewErrorContext(taskCtx, taskSpan, &parsedLabels.taskIdentifiers) + errCtx := NewErrorContext(taskCtx, h.db, h.redisGateway, taskSpan, &parsedLabels.taskIdentifiers) postProcess := func(injectionName string) { - if err := updateInjectionState(injectionName, consts.DatapackInjectSuccess); err != nil { + if err := h.store.updateInjectionState(taskCtx, injectionName, consts.DatapackInjectSuccess); err != nil { errCtx.Warn(nil, "update injection state failed", err) } - datapack, err := updateInjectionTimestamp(injectionName, startTime, endTime) + datapack, err := h.store.updateInjectionTimestamp(taskCtx, injectionName, startTime, endTime) if err != nil { errCtx.Warn(nil, "update injection timestamps failed", err) return @@ -298,7 +324,7 @@ func (h *k8sHandler) HandleCRDSucceeded(namespace, pod, name string, startTime, } task.SetTraceCtx(traceCtx) - if err = common.SubmitTask(taskCtx, task); err != nil { + if err = common.SubmitTaskWithDB(taskCtx, h.db, h.redisGateway, task); err != nil { errCtx.Fatal(nil, "failed to submit datapack build task", err) } } @@ -306,7 +332,11 @@ func (h *k8sHandler) HandleCRDSucceeded(namespace, pod, name string, startTime, if !parsedLabels.IsHybrid { postProcess(name) } else { - bm := getBatchManager() + bm := h.batchManager + if bm == nil { + errCtx.Warn(nil, "fault batch manager not initialized", fmt.Errorf("fault batch manager not initialized")) + return + } bm.incrementBatchCount(parsedLabels.batchID) if bm.isFinished(parsedLabels.batchID) { @@ -351,7 +381,7 @@ func (h *k8sHandler) HandleJobAdd(name string, annotations map[string]string, la } } - taskCtx := otel.GetTextMapPropagator().Extract(context.Background(), parsedAnnotations.taskCarrier) + taskCtx := otel.GetTextMapPropagator().Extract(consumerDetachedContext(), parsedAnnotations.taskCarrier) updateTaskState(taskCtx, newTaskStateUpdate( parsedLabels.traceID, @@ -359,7 +389,7 @@ func (h *k8sHandler) HandleJobAdd(name string, annotations map[string]string, la parsedLabels.taskType, consts.TaskRunning, message, - ).withEvent(eventType, payload), + ).withEvent(eventType, payload).withDB(h.db).withRedis(h.redisGateway), ) } @@ -380,17 +410,21 @@ func (h *k8sHandler) HandleJobFailed(job *batchv1.Job, annotations map[string]st "task_id": parsedLabels.taskID, "trace_id": parsedLabels.traceID, }) - taskCtx := otel.GetTextMapPropagator().Extract(context.Background(), parsedAnnotations.taskCarrier) + taskCtx := otel.GetTextMapPropagator().Extract(consumerDetachedContext(), parsedAnnotations.taskCarrier) taskSpan := trace.SpanFromContext(taskCtx) - errCtx := NewErrorContext(taskCtx, taskSpan, &parsedLabels.taskIdentifiers) + errCtx := NewErrorContext(taskCtx, h.db, h.redisGateway, taskSpan, &parsedLabels.taskIdentifiers) if parsedAnnotations.datapack == nil { errCtx.Fatal(nil, "missing datapack information in annotations", nil) return } - logMap, err := k8s.GetJobPodLogs(taskCtx, job.Namespace, job.Name) + if h.k8sGateway == nil { + errCtx.Warn(nil, "k8s gateway not initialized", fmt.Errorf("k8s gateway not initialized")) + return + } + logMap, err := h.k8sGateway.GetJobPodLogs(taskCtx, job.Namespace, job.Name) if err != nil { errCtx.Warn(logrus.WithField("job_name", job.Name), "failed to get job logs", err) } @@ -406,7 +440,7 @@ func (h *k8sHandler) HandleJobFailed(job *batchv1.Job, annotations map[string]st taskSpan.AddEvent("job failed", spanAttrs...) } - publishEvent(taskCtx, fmt.Sprintf(consts.StreamTraceLogKey, parsedLabels.traceID), dto.TraceStreamEvent{ + publishEvent(h.redisGateway, taskCtx, fmt.Sprintf(consts.StreamTraceLogKey, parsedLabels.traceID), dto.TraceStreamEvent{ TaskID: parsedLabels.taskID, TaskType: parsedLabels.taskType, EventName: consts.EventJobFailed, @@ -430,12 +464,16 @@ func (h *k8sHandler) HandleJobFailed(job *batchv1.Job, annotations map[string]st JobName: job.Name, } - if err := updateInjectionState(parsedAnnotations.datapack.Name, consts.DatapackBuildFailed); err != nil { + if err := h.store.updateInjectionState(taskCtx, parsedAnnotations.datapack.Name, consts.DatapackBuildFailed); err != nil { errCtx.Warn(nil, "update injection state failed", err) } case consts.TaskTypeRunAlgorithm: - rateLimiter := GetAlgoExecutionRateLimiter() + rateLimiter := h.algoLimiter + if rateLimiter == nil { + errCtx.Warn(nil, "algorithm execution rate limiter not initialized on job failure", fmt.Errorf("algorithm execution rate limiter not initialized")) + return + } if releaseErr := rateLimiter.ReleaseToken(taskCtx, parsedLabels.taskID, parsedLabels.traceID); releaseErr != nil { errCtx.Warn(nil, "failed to release algorithm execution token on job failure", releaseErr) } else { @@ -462,12 +500,12 @@ func (h *k8sHandler) HandleJobFailed(job *batchv1.Job, annotations map[string]st } if parsedAnnotations.algorithm.ContainerName == config.GetDetectorName() { - if err := updateInjectionState(parsedAnnotations.datapack.Name, consts.DatapackDetectorFailed); err != nil { + if err := h.store.updateInjectionState(taskCtx, parsedAnnotations.datapack.Name, consts.DatapackDetectorFailed); err != nil { errCtx.Warn(nil, "update injection state failed", err) } } - if err := updateExecutionState(*parsedLabels.ExecutionID, consts.ExecutionFailed); err != nil { + if err := h.store.updateExecutionState(taskCtx, *parsedLabels.ExecutionID, consts.ExecutionFailed); err != nil { errCtx.Fatal(nil, "update execution state failed", err) return } @@ -480,7 +518,7 @@ func (h *k8sHandler) HandleJobFailed(job *batchv1.Job, annotations map[string]st parsedLabels.taskType, consts.TaskError, fmt.Sprintf(consts.TaskMsgFailed, parsedLabels.taskID), - ).withEvent(eventName, payload), + ).withEvent(eventName, payload).withDB(h.db).withRedis(h.redisGateway), ) } @@ -499,8 +537,8 @@ func (h *k8sHandler) HandleJobSucceeded(job *batchv1.Job, annotations map[string stream := fmt.Sprintf(consts.StreamTraceLogKey, parsedLabels.traceID) - taskCtx := otel.GetTextMapPropagator().Extract(context.Background(), parsedAnnotations.taskCarrier) - traceCtx := otel.GetTextMapPropagator().Extract(context.Background(), parsedAnnotations.traceCarrier) + taskCtx := otel.GetTextMapPropagator().Extract(consumerDetachedContext(), parsedAnnotations.taskCarrier) + traceCtx := otel.GetTextMapPropagator().Extract(consumerDetachedContext(), parsedAnnotations.traceCarrier) logEntry := logrus.WithFields(logrus.Fields{ "task_id": parsedLabels.taskID, @@ -508,14 +546,14 @@ func (h *k8sHandler) HandleJobSucceeded(job *batchv1.Job, annotations map[string }) taskSpan := trace.SpanFromContext(taskCtx) - errCtx := NewErrorContext(taskCtx, taskSpan, &parsedLabels.taskIdentifiers) + errCtx := NewErrorContext(taskCtx, h.db, h.redisGateway, taskSpan, &parsedLabels.taskIdentifiers) if parsedAnnotations.datapack == nil { errCtx.Fatal(nil, "missing datapack information in annotations", nil) return } - publishEvent(taskCtx, stream, dto.TraceStreamEvent{ + publishEvent(h.redisGateway, taskCtx, stream, dto.TraceStreamEvent{ TaskID: parsedLabels.taskID, TaskType: parsedLabels.taskType, EventName: consts.EventJobSucceed, @@ -530,7 +568,7 @@ func (h *k8sHandler) HandleJobSucceeded(job *batchv1.Job, annotations map[string logEntry.Info("datapack build successfully") taskSpan.AddEvent("datapack build successfully") - if err := updateInjectionState(parsedAnnotations.datapack.Name, consts.DatapackBuildSuccess); err != nil { + if err := h.store.updateInjectionState(taskCtx, parsedAnnotations.datapack.Name, consts.DatapackBuildSuccess); err != nil { errCtx.Fatal(nil, "update injection state failed", err) return } @@ -548,14 +586,14 @@ func (h *k8sHandler) HandleJobSucceeded(job *batchv1.Job, annotations map[string Datapack: parsedAnnotations.datapack.Name, JobName: job.Name, }, - ), + ).withDB(h.db).withRedis(h.redisGateway), ) ref := &dto.ContainerRef{ Name: config.GetDetectorName(), } - algorithmVersionResults, err := common.MapRefsToContainerVersions([]*dto.ContainerRef{ref}, consts.ContainerTypeAlgorithm, parsedLabels.userID) + algorithmVersionResults, err := container.NewRepository(h.db).ResolveContainerVersions([]*dto.ContainerRef{ref}, consts.ContainerTypeAlgorithm, parsedLabels.userID) if err != nil { errCtx.Fatal(nil, "failed to map container refs to versions", err) return @@ -589,12 +627,16 @@ func (h *k8sHandler) HandleJobSucceeded(job *batchv1.Job, annotations map[string } task.SetTraceCtx(traceCtx) - if err := common.SubmitTask(taskCtx, task); err != nil { + if err := common.SubmitTaskWithDB(taskCtx, h.db, h.redisGateway, task); err != nil { errCtx.Warn(nil, "submit algorithm execution task failed", err) } case consts.TaskTypeRunAlgorithm: - rateLimiter := GetAlgoExecutionRateLimiter() + rateLimiter := h.algoLimiter + if rateLimiter == nil { + errCtx.Warn(nil, "algorithm execution rate limiter not initialized on job success", fmt.Errorf("algorithm execution rate limiter not initialized")) + return + } if releaseErr := rateLimiter.ReleaseToken(taskCtx, parsedLabels.taskID, parsedLabels.traceID); releaseErr != nil { errCtx.Warn(nil, "failed to release algorithm execution token on job success", releaseErr) } else { @@ -616,13 +658,13 @@ func (h *k8sHandler) HandleJobSucceeded(job *batchv1.Job, annotations map[string taskSpan.AddEvent("algorithm execute successfully") if parsedAnnotations.algorithm.ContainerName == config.GetDetectorName() { - if err := updateInjectionState(parsedAnnotations.datapack.Name, consts.DatapackDetectorSuccess); err != nil { + if err := h.store.updateInjectionState(taskCtx, parsedAnnotations.datapack.Name, consts.DatapackDetectorSuccess); err != nil { errCtx.Fatal(nil, "update injection state failed", err) return } } - if err := updateExecutionState(*parsedLabels.ExecutionID, consts.ExecutionSuccess); err != nil { + if err := h.store.updateExecutionState(taskCtx, *parsedLabels.ExecutionID, consts.ExecutionSuccess); err != nil { errCtx.Fatal(nil, "update execution state failed", err) return } @@ -640,7 +682,7 @@ func (h *k8sHandler) HandleJobSucceeded(job *batchv1.Job, annotations map[string Algorithm: parsedAnnotations.algorithm.ContainerName, JobName: job.Name, }, - ), + ).withDB(h.db).withRedis(h.redisGateway), ) payload := map[string]any{ @@ -661,7 +703,7 @@ func (h *k8sHandler) HandleJobSucceeded(job *batchv1.Job, annotations map[string } task.SetTraceCtx(traceCtx) - if err := common.SubmitTask(taskCtx, task); err != nil { + if err := common.SubmitTaskWithDB(taskCtx, h.db, h.redisGateway, task); err != nil { errCtx.Warn(nil, "submit result collection task failed", err) } } @@ -822,81 +864,3 @@ func parseJobLabels(labels map[string]string) (*jobLabels, error) { return data, nil } - -// updateExecutionState updates the state of an execution -func updateExecutionState(executionID int, newState consts.ExecutionState) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - execution, err := repository.GetExecutionByID(tx, executionID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("%w: execution %d not found", consts.ErrNotFound, executionID) - } - return fmt.Errorf("execution %d not found: %w", executionID, err) - } - - if execution.State != consts.ExecutionInitial { - return fmt.Errorf("cannot change state of execution %d from %s to %s", executionID, consts.GetExecutionStateName(execution.State), consts.GetExecutionStateName(newState)) - } - - if err := repository.UpdateExecution(tx, executionID, map[string]any{ - "state": newState, - }); err != nil { - return fmt.Errorf("failed to update execution %d duration: %w", executionID, err) - } - - return nil - }) -} - -// updateInjectionState updates the state of a fault injection -func updateInjectionState(injectionName string, newState consts.DatapackState) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - injection, err := repository.GetInjectionByName(tx, injectionName, false) - if err != nil { - return fmt.Errorf("failed to get injection %s: %w", injectionName, err) - } - - if err := repository.UpdateInjection(tx, injection.ID, map[string]any{ - "state": newState, - }); err != nil { - return fmt.Errorf("failed to update injection %s state: %w", injectionName, err) - } - - return nil - }) -} - -// updateInjectionTimestamp updates the start and end timestamps of a fault injection -func updateInjectionTimestamp(injectionName string, startTime time.Time, endTime time.Time) (*dto.InjectionItem, error) { - var updatedInjection *database.FaultInjection - err := database.DB.Transaction(func(tx *gorm.DB) error { - injection, err := repository.GetInjectionByName(tx, injectionName, false) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("injection %s not found", injectionName) - } - return fmt.Errorf("failed to get injection %s: %w", injectionName, err) - } - - if err = repository.UpdateInjection(tx, injection.ID, map[string]any{ - "start_time": startTime, - "end_time": endTime, - }); err != nil { - return fmt.Errorf("update injection timestamps failed: %w", err) - } - - reloadedInjection, err := repository.GetInjectionByID(tx, injection.ID) - if err != nil { - return fmt.Errorf("failed to reload injection %d after update: %w", injection.ID, err) - } - - updatedInjection = reloadedInjection - return nil - }) - if err != nil { - return nil, err - } - - injectionItem := dto.NewInjectionItem(updatedInjection) - return &injectionItem, err -} diff --git a/src/service/consumer/monitor.go b/src/service/consumer/monitor.go index f674604d..b4de5990 100644 --- a/src/service/consumer/monitor.go +++ b/src/service/consumer/monitor.go @@ -5,17 +5,16 @@ import ( "fmt" "regexp" "slices" - "strconv" "sync" "time" - "aegis/client" "aegis/config" "aegis/consts" "aegis/dto" + redisinfra "aegis/infra/redis" "aegis/utils" - "github.com/redis/go-redis/v9" + goredis "github.com/redis/go-redis/v9" "github.com/sirupsen/logrus" ) @@ -45,38 +44,70 @@ type NamespaceInitResult struct { Initialized []string // Namespaces that were re-initialized (all enabled namespaces) } +type NamespaceMonitor interface { + SetContext(ctx context.Context) + InitializeNamespaces() ([]string, error) + RefreshNamespaces() (*NamespaceRefreshResult, error) + ReleaseLock(ctx context.Context, namespace string, traceID string) error + CheckNamespaceToInject(namespace string, executeTime time.Time, traceID string) error + GetNamespaceToRestart(endTime time.Time, nsPattern, traceID string) string +} + // monitor manages namespace locks and status using Redis type monitor struct { - redisClient *redis.Client - ctx context.Context - mu sync.RWMutex // Protects namespace operations + ctx context.Context + redisGateway *redisinfra.Gateway + namespaces namespaceCatalogStore + locks namespaceLockStore + status namespaceStatusStore + mu sync.RWMutex // Protects namespace operations } -// Singleton instance and initialization control -var ( - monitorInstance *monitor - monitorOnce sync.Once -) +func NewMonitor(gateway *redisinfra.Gateway) NamespaceMonitor { + return &monitor{ + ctx: context.TODO(), + redisGateway: gateway, + namespaces: newNamespaceCatalogStore(gateway), + locks: newNamespaceLockStore(gateway), + status: newNamespaceStatusStore(gateway), + } +} -// GetMonitor returns the singleton Monitor instance, -// ensuring initialization is only performed once across all processes -func GetMonitor() *monitor { - // Local process singleton pattern - monitorOnce.Do(func() { - monitorInstance = &monitor{ - redisClient: client.GetRedisClient(), - ctx: context.Background(), - } - }) +func (m *monitor) SetContext(ctx context.Context) { + if ctx == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.ctx = ctx +} + +func (m *monitor) currentContext() context.Context { + m.mu.RLock() + defer m.mu.RUnlock() + if m.ctx != nil { + return m.ctx + } + return context.TODO() +} - return monitorInstance +func (m *monitor) listNamespaces() ([]string, error) { + return m.namespaces.list(m.currentContext()) +} + +func (m *monitor) namespaceExists(namespace string) (bool, error) { + return m.namespaces.exists(m.currentContext(), namespace) +} + +func (m *monitor) seedNamespace(namespace string, endTime time.Time) error { + return m.namespaces.seed(m.currentContext(), namespace, endTime) } // AcquireLock attempts to acquire a lock on a namespace // Returns nil on success, error if the lock cannot be acquired func (m *monitor) AcquireLock(namespace string, endTime time.Time, traceID string, taskType consts.TaskType) (err error) { defer func() { - publishEvent(context.Background(), fmt.Sprintf(consts.StreamTraceLogKey, namespace), dto.TraceStreamEvent{ + publishEvent(m.redisGateway, m.currentContext(), fmt.Sprintf(consts.StreamTraceLogKey, namespace), dto.TraceStreamEvent{ TaskType: taskType, EventName: consts.EventAcquireLock, Payload: LockMessage{ @@ -87,16 +118,15 @@ func (m *monitor) AcquireLock(namespace string, endTime time.Time, traceID strin }) }() - nsKey := fmt.Sprintf(consts.NamespaceKeyPattern, namespace) nowTime := time.Now().Unix() // Check if namespace exists - exists, err := m.redisClient.Exists(m.ctx, nsKey).Result() + exists, err := m.namespaceExists(namespace) if err != nil { return fmt.Errorf("failed to check namespace existence: %v", err) } - if exists == 0 { + if !exists { // Lazy loading: verify namespace is valid in current configuration latestNamespaces, err := config.GetAllNamespaces() if err != nil { @@ -128,37 +158,7 @@ func (m *monitor) AcquireLock(namespace string, endTime time.Time, traceID strin } // All lock checking and acquisition happens in a single atomic transaction - err = m.redisClient.Watch(m.ctx, func(tx *redis.Tx) error { - // Check if the lock is still available - currentEndTimeStr, e := tx.HGet(m.ctx, nsKey, "end_time").Result() - if e != nil && e != redis.Nil { - return e - } - - currentEndTime, e := strconv.ParseInt(currentEndTimeStr, 10, 64) - if e != nil { - return e - } - - currentTraceID, e := tx.HGet(m.ctx, nsKey, "trace_id").Result() - if e != nil && e != redis.Nil { - return e - } - - // If lock is held by someone else and not expired - if currentTraceID != "" && currentTraceID != traceID && nowTime < currentEndTime { - return fmt.Errorf("namespace %s is locked by %s until %v", - namespace, currentTraceID, time.Unix(currentEndTime, 0).Format(time.RFC3339)) - } - - // Try to acquire the lock - _, e = tx.TxPipelined(m.ctx, func(pipe redis.Pipeliner) error { - pipe.HSet(m.ctx, nsKey, "end_time", endTime.Unix()) - pipe.HSet(m.ctx, nsKey, "trace_id", traceID) - return nil - }) - return e - }, nsKey) + err = m.locks.acquire(m.currentContext(), namespace, endTime, traceID, time.Unix(nowTime, 0)) logEntry := logrus.WithFields( logrus.Fields{ @@ -170,7 +170,7 @@ func (m *monitor) AcquireLock(namespace string, endTime time.Time, traceID strin if err == nil { logEntry.Info("acquired namespace lock") - } else if err != redis.TxFailedErr { + } else if err != goredis.TxFailedErr { logEntry.Warn("failed to acquire namespace lock") } @@ -180,7 +180,7 @@ func (m *monitor) AcquireLock(namespace string, endTime time.Time, traceID strin // ReleaseLock releases a lock on a namespace if it's owned by the specified traceID func (m *monitor) ReleaseLock(ctx context.Context, namespace string, traceID string) (err error) { defer func() { - publishEvent(ctx, fmt.Sprintf(consts.StreamTraceLogKey, namespace), dto.TraceStreamEvent{ + publishEvent(m.redisGateway, ctx, fmt.Sprintf(consts.StreamTraceLogKey, namespace), dto.TraceStreamEvent{ TaskType: consts.TaskTypeRestartPedestal, EventName: consts.EventReleaseLock, Payload: LockMessage{ @@ -205,41 +205,21 @@ func (m *monitor) ReleaseLock(ctx context.Context, namespace string, traceID str return fmt.Errorf("namespace or trace_id is empty") } - nsKey := fmt.Sprintf(consts.NamespaceKeyPattern, namespace) - // Check if namespace exists - var exists int64 - exists, err = m.redisClient.Exists(m.ctx, nsKey).Result() + exists, existsErr := m.namespaceExists(namespace) + err = existsErr if err != nil { err = fmt.Errorf("failed to check namespace existence: %v", err) return } - if exists == 0 { + if !exists { err = fmt.Errorf("namespace %s not found", namespace) return } // Check if the lock is actually held by this traceID - currentTraceID, err := m.redisClient.HGet(m.ctx, nsKey, "trace_id").Result() - if err != nil && err != redis.Nil { - err = fmt.Errorf("failed to get current trace_id: %v", err) - return - } - - // If the lock is held by someone else or is already released - if currentTraceID != traceID && currentTraceID != "" { - err = fmt.Errorf("cannot release lock: namespace %s is not owned by trace_id %s (current owner: %s)", - namespace, traceID, currentTraceID) - return - } - - // Update namespace lock info - release by setting current time and empty trace ID - _, err = m.redisClient.Pipelined(m.ctx, func(pipe redis.Pipeliner) error { - pipe.HSet(m.ctx, nsKey, "end_time", time.Now().Unix()) - pipe.HSet(m.ctx, nsKey, "trace_id", "") - return nil - }) + err = m.locks.release(m.currentContext(), namespace, traceID, time.Now()) return } @@ -252,7 +232,7 @@ func (m *monitor) CheckNamespaceToInject(namespace string, executeTime time.Time // Try to acquire the lock - all availability checking is done inside acquireNamespaceLock err := m.AcquireLock(namespace, proposedEndTime, traceID, consts.TaskTypeFaultInjection) if err != nil { - if err == redis.TxFailedErr { + if err == goredis.TxFailedErr { return fmt.Errorf("cannot inject fault: namespace %s was concurrently acquired by another client", namespace) } return fmt.Errorf("cannot inject fault: %v", err) @@ -263,7 +243,7 @@ func (m *monitor) CheckNamespaceToInject(namespace string, executeTime time.Time // GetNamespaceToRestart finds an available namespace for restart and acquires it func (m *monitor) GetNamespaceToRestart(endTime time.Time, nsPattern, traceID string) string { - namespaces, err := m.redisClient.SMembers(m.ctx, consts.NamespacesKey).Result() + namespaces, err := m.listNamespaces() if err != nil { logrus.Errorf("failed to get namespaces from Redis: %v", err) return "" @@ -312,7 +292,7 @@ func (m *monitor) InitializeNamespaces() ([]string, error) { } // Get all enabled namespaces from Redis - allNamespaces, err := m.redisClient.SMembers(m.ctx, consts.NamespacesKey).Result() + allNamespaces, err := m.listNamespaces() if err != nil { return nil, fmt.Errorf("failed to get namespaces from Redis: %w", err) } @@ -358,7 +338,7 @@ func (m *monitor) RefreshNamespaces() (*NamespaceRefreshResult, error) { } // Get existing namespaces from Redis - existingNamespaces, err := m.redisClient.SMembers(m.ctx, consts.NamespacesKey).Result() + existingNamespaces, err := m.listNamespaces() if err != nil { return nil, fmt.Errorf("failed to get existing namespaces: %w", err) } @@ -449,70 +429,20 @@ func (m *monitor) RefreshNamespaces() (*NamespaceRefreshResult, error) { // addNamespace adds a new namespace to Redis with initial state (idempotent) func (m *monitor) addNamespace(namespace string, endTime time.Time) error { - nsKey := fmt.Sprintf(consts.NamespaceKeyPattern, namespace) - - _, err := m.redisClient.Pipelined(m.ctx, func(pipe redis.Pipeliner) error { - pipe.SAdd(m.ctx, consts.NamespacesKey, namespace) - pipe.HSetNX(m.ctx, nsKey, "end_time", endTime.Unix()) - pipe.HSetNX(m.ctx, nsKey, "trace_id", "") - pipe.HSetNX(m.ctx, nsKey, "status", int(consts.CommonEnabled)) - return nil - }) - - return err + return m.seedNamespace(namespace, endTime) } // isNamespaceLocked checks if a namespace currently has an active lock func (m *monitor) isNamespaceLocked(namespace string) (bool, error) { - nsKey := fmt.Sprintf(consts.NamespaceKeyPattern, namespace) - - traceID, err := m.redisClient.HGet(m.ctx, nsKey, "trace_id").Result() - if err == redis.Nil { - return false, nil - } - if err != nil { - return false, err - } - if traceID == "" { - return false, nil - } - - // Check if lock has expired - endTimeStr, err := m.redisClient.HGet(m.ctx, nsKey, "end_time").Result() - if err != nil { - return false, err - } - - endTime, err := strconv.ParseInt(endTimeStr, 10, 64) - if err != nil { - return false, err - } - - return time.Now().Unix() < endTime, nil + return m.locks.isActive(m.currentContext(), namespace, time.Now()) } // getNamespaceStatus gets the status of a namespace func (m *monitor) getNamespaceStatus(namespace string) (consts.StatusType, error) { - nsKey := fmt.Sprintf(consts.NamespaceKeyPattern, namespace) - statusStr, err := m.redisClient.HGet(m.ctx, nsKey, "status").Result() - if err == redis.Nil { - // For backward compatibility, assume enabled if status field doesn't exist - return consts.CommonEnabled, nil - } - if err != nil { - return 0, err - } - - status, err := strconv.Atoi(statusStr) - if err != nil { - return 0, fmt.Errorf("invalid status value: %w", err) - } - - return consts.StatusType(status), nil + return m.status.get(m.currentContext(), namespace) } // setNamespaceStatus sets the status of a namespace func (m *monitor) setNamespaceStatus(namespace string, status consts.StatusType) error { - nsKey := fmt.Sprintf(consts.NamespaceKeyPattern, namespace) - return m.redisClient.HSet(m.ctx, nsKey, "status", int(status)).Err() + return m.status.set(m.currentContext(), namespace, status) } diff --git a/src/service/consumer/namespace_catalog_store.go b/src/service/consumer/namespace_catalog_store.go new file mode 100644 index 00000000..6d934806 --- /dev/null +++ b/src/service/consumer/namespace_catalog_store.go @@ -0,0 +1,34 @@ +package consumer + +import ( + "context" + "fmt" + "time" + + "aegis/consts" + redis "aegis/infra/redis" +) + +type namespaceCatalogStore struct { + client *redis.Gateway +} + +func newNamespaceCatalogStore(client *redis.Gateway) namespaceCatalogStore { + return namespaceCatalogStore{client: client} +} + +func (s namespaceCatalogStore) key(namespace string) string { + return fmt.Sprintf(consts.NamespaceKeyPattern, namespace) +} + +func (s namespaceCatalogStore) list(ctx context.Context) ([]string, error) { + return s.client.SetMembers(ctx, consts.NamespacesKey) +} + +func (s namespaceCatalogStore) exists(ctx context.Context, namespace string) (bool, error) { + return s.client.Exists(ctx, s.key(namespace)) +} + +func (s namespaceCatalogStore) seed(ctx context.Context, namespace string, endTime time.Time) error { + return s.client.SeedNamespaceState(ctx, s.key(namespace), namespace, endTime.Unix(), int(consts.CommonEnabled)) +} diff --git a/src/service/consumer/namespace_lock_store.go b/src/service/consumer/namespace_lock_store.go new file mode 100644 index 00000000..18854014 --- /dev/null +++ b/src/service/consumer/namespace_lock_store.go @@ -0,0 +1,128 @@ +package consumer + +import ( + "context" + "fmt" + "strconv" + "time" + + "aegis/consts" + redisinfra "aegis/infra/redis" + + goredis "github.com/redis/go-redis/v9" +) + +type namespaceLockState struct { + EndTime int64 + TraceID string +} + +type namespaceLockStore struct { + client *redisinfra.Gateway +} + +func newNamespaceLockStore(client *redisinfra.Gateway) namespaceLockStore { + return namespaceLockStore{client: client} +} + +func (s namespaceLockStore) key(namespace string) string { + return fmt.Sprintf(consts.NamespaceKeyPattern, namespace) +} + +func (s namespaceLockStore) read(ctx context.Context, namespace string) (*namespaceLockState, error) { + endTimeStr, err := s.client.HashGet(ctx, s.key(namespace), "end_time") + if err != nil && err != goredis.Nil { + return nil, err + } + + traceID, err := s.client.HashGet(ctx, s.key(namespace), "trace_id") + if err != nil && err != goredis.Nil { + return nil, err + } + + if endTimeStr == "" { + return &namespaceLockState{TraceID: traceID}, nil + } + + endTime, err := strconv.ParseInt(endTimeStr, 10, 64) + if err != nil { + return nil, err + } + + return &namespaceLockState{EndTime: endTime, TraceID: traceID}, nil +} + +func (s namespaceLockStore) readFromHash(reader goredis.HashCmdable, ctx context.Context, namespace string) (*namespaceLockState, error) { + endTimeStr, err := reader.HGet(ctx, s.key(namespace), "end_time").Result() + if err != nil && err != goredis.Nil { + return nil, err + } + + traceID, err := reader.HGet(ctx, s.key(namespace), "trace_id").Result() + if err != nil && err != goredis.Nil { + return nil, err + } + + if endTimeStr == "" { + return &namespaceLockState{TraceID: traceID}, nil + } + + endTime, err := strconv.ParseInt(endTimeStr, 10, 64) + if err != nil { + return nil, err + } + + return &namespaceLockState{EndTime: endTime, TraceID: traceID}, nil +} + +func (s namespaceLockStore) write(ctx context.Context, namespace string, endTime int64, traceID string) error { + return s.client.HashSet(ctx, s.key(namespace), map[string]any{ + "end_time": endTime, + "trace_id": traceID, + }) +} + +func (s namespaceLockStore) acquire(ctx context.Context, namespace string, endTime time.Time, traceID string, now time.Time) error { + return s.client.Watch(ctx, func(tx *goredis.Tx) error { + state, err := s.readFromHash(tx, ctx, namespace) + if err != nil { + return err + } + if state.TraceID != "" && state.TraceID != traceID && now.Unix() < state.EndTime { + return fmt.Errorf("namespace %s is locked by %s until %v", + namespace, state.TraceID, time.Unix(state.EndTime, 0).Format(time.RFC3339)) + } + _, err = tx.TxPipelined(ctx, func(pipe goredis.Pipeliner) error { + pipe.HSet(ctx, s.key(namespace), "end_time", endTime.Unix()) + pipe.HSet(ctx, s.key(namespace), "trace_id", traceID) + return nil + }) + return err + }, s.key(namespace)) +} + +func (s namespaceLockStore) release(ctx context.Context, namespace, traceID string, releasedAt time.Time) error { + state, err := s.read(ctx, namespace) + if err != nil && err != goredis.Nil { + return fmt.Errorf("failed to get current trace_id: %v", err) + } + if state != nil && state.TraceID != traceID && state.TraceID != "" { + return fmt.Errorf("cannot release lock: namespace %s is not owned by trace_id %s (current owner: %s)", + namespace, traceID, state.TraceID) + } + return s.write(ctx, namespace, releasedAt.Unix(), "") +} + +func (s namespaceLockStore) isActive(ctx context.Context, namespace string, now time.Time) (bool, error) { + state, err := s.read(ctx, namespace) + if err == goredis.Nil { + return false, nil + } + if err != nil { + return false, err + } + if state.TraceID == "" { + return false, nil + } + return now.Unix() < state.EndTime, nil +} diff --git a/src/service/consumer/namespace_status_store.go b/src/service/consumer/namespace_status_store.go new file mode 100644 index 00000000..c38fe22d --- /dev/null +++ b/src/service/consumer/namespace_status_store.go @@ -0,0 +1,43 @@ +package consumer + +import ( + "context" + "fmt" + "strconv" + + "aegis/consts" + redisinfra "aegis/infra/redis" + goredis "github.com/redis/go-redis/v9" +) + +type namespaceStatusStore struct { + client *redisinfra.Gateway +} + +func newNamespaceStatusStore(client *redisinfra.Gateway) namespaceStatusStore { + return namespaceStatusStore{client: client} +} + +func (s namespaceStatusStore) key(namespace string) string { + return fmt.Sprintf(consts.NamespaceKeyPattern, namespace) +} + +func (s namespaceStatusStore) get(ctx context.Context, namespace string) (consts.StatusType, error) { + statusStr, err := s.client.HashGet(ctx, s.key(namespace), "status") + if err == goredis.Nil { + return consts.CommonEnabled, nil + } + if err != nil { + return 0, err + } + + status, err := strconv.Atoi(statusStr) + if err != nil { + return 0, fmt.Errorf("invalid status value: %w", err) + } + return consts.StatusType(status), nil +} + +func (s namespaceStatusStore) set(ctx context.Context, namespace string, status consts.StatusType) error { + return s.client.HashSet(ctx, s.key(namespace), map[string]any{"status": int(status)}) +} diff --git a/src/service/consumer/owner_adapter.go b/src/service/consumer/owner_adapter.go new file mode 100644 index 00000000..43e6def3 --- /dev/null +++ b/src/service/consumer/owner_adapter.go @@ -0,0 +1,171 @@ +package consumer + +import ( + "context" + "fmt" + + "aegis/dto" + "aegis/internalclient/orchestratorclient" + execution "aegis/module/execution" + injection "aegis/module/injection" + + "go.uber.org/fx" +) + +// ExecutionOwner captures the execution owner operations used by runtime code. +type ExecutionOwner interface { + CreateExecution(context.Context, *execution.RuntimeCreateExecutionReq) (int, error) + GetExecution(context.Context, int) (*execution.ExecutionDetailResp, error) + UpdateExecutionState(context.Context, *execution.RuntimeUpdateExecutionStateReq) error +} + +// InjectionOwner captures the injection owner operations used by runtime code. +type InjectionOwner interface { + CreateInjection(context.Context, *injection.RuntimeCreateInjectionReq) (*dto.InjectionItem, error) + UpdateInjectionState(context.Context, *injection.RuntimeUpdateInjectionStateReq) error + UpdateInjectionTimestamps(context.Context, *injection.RuntimeUpdateInjectionTimestampReq) (*dto.InjectionItem, error) +} + +type executionOwnerAdapter struct { + orchestrator *orchestratorclient.Client + local *execution.Service + requireRemote bool +} + +type executionOwnerParams struct { + fx.In + + Orchestrator *orchestratorclient.Client + Local *execution.Service `optional:"true"` +} + +type injectionOwnerParams struct { + fx.In + + Orchestrator *orchestratorclient.Client + Local *injection.Service `optional:"true"` +} + +func NewExecutionOwner(params executionOwnerParams) ExecutionOwner { + return executionOwnerAdapter{ + orchestrator: params.Orchestrator, + local: params.Local, + requireRemote: false, + } +} + +func NewInjectionOwner(params injectionOwnerParams) InjectionOwner { + return injectionOwnerAdapter{ + orchestrator: params.Orchestrator, + local: params.Local, + requireRemote: false, + } +} + +func newRemoteExecutionOwner(params executionOwnerParams) ExecutionOwner { + return executionOwnerAdapter{ + orchestrator: params.Orchestrator, + local: params.Local, + requireRemote: true, + } +} + +func newRemoteInjectionOwner(params injectionOwnerParams) InjectionOwner { + return injectionOwnerAdapter{ + orchestrator: params.Orchestrator, + local: params.Local, + requireRemote: true, + } +} + +func (a executionOwnerAdapter) CreateExecution(ctx context.Context, req *execution.RuntimeCreateExecutionReq) (int, error) { + if a.orchestrator != nil && a.orchestrator.Enabled() { + return a.orchestrator.CreateExecution(ctx, req) + } + if a.requireRemote { + return 0, fmt.Errorf("orchestrator-service owner is not configured") + } + if a.local == nil { + return 0, fmt.Errorf("missing execution owner service") + } + return a.local.CreateExecutionRecord(ctx, req) +} + +func (a executionOwnerAdapter) GetExecution(ctx context.Context, executionID int) (*execution.ExecutionDetailResp, error) { + if a.orchestrator != nil && a.orchestrator.Enabled() { + return a.orchestrator.GetExecution(ctx, executionID) + } + if a.requireRemote { + return nil, fmt.Errorf("orchestrator-service owner is not configured") + } + if a.local == nil { + return nil, fmt.Errorf("missing execution owner service") + } + return a.local.GetExecution(ctx, executionID) +} + +func (a executionOwnerAdapter) UpdateExecutionState(ctx context.Context, req *execution.RuntimeUpdateExecutionStateReq) error { + if a.orchestrator != nil && a.orchestrator.Enabled() { + return a.orchestrator.UpdateExecutionState(ctx, req) + } + if a.requireRemote { + return fmt.Errorf("orchestrator-service owner is not configured") + } + if a.local == nil { + return fmt.Errorf("missing execution owner service") + } + return a.local.UpdateExecutionState(ctx, req) +} + +type injectionOwnerAdapter struct { + orchestrator *orchestratorclient.Client + local *injection.Service + requireRemote bool +} + +func (a injectionOwnerAdapter) CreateInjection(ctx context.Context, req *injection.RuntimeCreateInjectionReq) (*dto.InjectionItem, error) { + if a.orchestrator != nil && a.orchestrator.Enabled() { + return a.orchestrator.CreateInjection(ctx, req) + } + if a.requireRemote { + return nil, fmt.Errorf("orchestrator-service owner is not configured") + } + if a.local == nil { + return nil, fmt.Errorf("missing injection owner service") + } + return a.local.CreateInjectionRecord(ctx, req) +} + +func (a injectionOwnerAdapter) UpdateInjectionState(ctx context.Context, req *injection.RuntimeUpdateInjectionStateReq) error { + if a.orchestrator != nil && a.orchestrator.Enabled() { + return a.orchestrator.UpdateInjectionState(ctx, req) + } + if a.requireRemote { + return fmt.Errorf("orchestrator-service owner is not configured") + } + if a.local == nil { + return fmt.Errorf("missing injection owner service") + } + return a.local.UpdateInjectionState(ctx, req) +} + +func (a injectionOwnerAdapter) UpdateInjectionTimestamps(ctx context.Context, req *injection.RuntimeUpdateInjectionTimestampReq) (*dto.InjectionItem, error) { + if a.orchestrator != nil && a.orchestrator.Enabled() { + return a.orchestrator.UpdateInjectionTimestamps(ctx, req) + } + if a.requireRemote { + return nil, fmt.Errorf("orchestrator-service owner is not configured") + } + if a.local == nil { + return nil, fmt.Errorf("missing injection owner service") + } + return a.local.UpdateInjectionTimestamps(ctx, req) +} + +// RemoteOwnerOptions forces the dedicated runtime-worker-service path to use orchestrator RPC only. +func RemoteOwnerOptions() fx.Option { + return fx.Options( + fx.Decorate(newRemoteExecutionOwner), + fx.Decorate(newRemoteInjectionOwner), + ) +} diff --git a/src/service/consumer/rate_limiter.go b/src/service/consumer/rate_limiter.go index 7f06ad5f..317f51bd 100644 --- a/src/service/consumer/rate_limiter.go +++ b/src/service/consumer/rate_limiter.go @@ -2,15 +2,13 @@ package consumer import ( "context" - "fmt" "sync" "time" - "aegis/client" "aegis/config" "aegis/consts" + redis "aegis/infra/redis" - "github.com/redis/go-redis/v9" "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/trace" ) @@ -26,14 +24,23 @@ type RateLimiterConfig struct { // TokenBucketRateLimiter token bucket rate limiter type TokenBucketRateLimiter struct { - redisClient *redis.Client bucketKey string + store tokenBucketStore mu sync.RWMutex maxTokens int waitTimeout time.Duration serviceName string } +type RateLimiterSnapshot struct { + ServiceName string + BucketKey string + MaxTokens int + WaitTimeout time.Duration + InUseTokens int64 + InUseTokensLoadErr error +} + // GetConfig returns the current configuration func (r *TokenBucketRateLimiter) GetConfig() (maxTokens int, waitTimeout time.Duration) { r.mu.RLock() @@ -41,6 +48,20 @@ func (r *TokenBucketRateLimiter) GetConfig() (maxTokens int, waitTimeout time.Du return r.maxTokens, r.waitTimeout } +func (r *TokenBucketRateLimiter) Snapshot(ctx context.Context) RateLimiterSnapshot { + maxTokens, waitTimeout := r.GetConfig() + inUseTokens, err := r.store.inUse(ctx) + + return RateLimiterSnapshot{ + ServiceName: r.serviceName, + BucketKey: r.bucketKey, + MaxTokens: maxTokens, + WaitTimeout: waitTimeout, + InUseTokens: inUseTokens, + InUseTokensLoadErr: err, + } +} + // UpdateConfig dynamically updates the rate limiter configuration func (r *TokenBucketRateLimiter) UpdateConfig(maxTokens int, waitTimeout time.Duration) { r.mu.Lock() @@ -75,34 +96,11 @@ func (r *TokenBucketRateLimiter) AcquireToken(ctx context.Context, taskID, trace maxTokens := r.maxTokens r.mu.RUnlock() - script := redis.NewScript(` - local bucket_key = KEYS[1] - local max_tokens = tonumber(ARGV[1]) - local task_id = ARGV[2] - local trace_id = ARGV[3] - local expire_time = tonumber(ARGV[4]) - - local current_tokens = redis.call('SCARD', bucket_key) - - if current_tokens < max_tokens then - redis.call('SADD', bucket_key, task_id) - redis.call('EXPIRE', bucket_key, expire_time) - return 1 - else - return 0 - end - `) - - expireTime := 10 * 60 - - result, err := script.Run(ctx, r.redisClient, []string{r.bucketKey}, - maxTokens, taskID, traceID, expireTime).Result() + acquired, err := r.store.acquire(ctx, maxTokens, taskID, traceID) if err != nil { span.RecordError(err) - return false, fmt.Errorf("failed to acquire token: %v", err) + return false, err } - - acquired := result.(int64) == 1 if acquired { span.AddEvent("token acquired successfully") logrus.WithFields(logrus.Fields{ @@ -120,10 +118,10 @@ func (r *TokenBucketRateLimiter) AcquireToken(ctx context.Context, taskID, trace func (r *TokenBucketRateLimiter) ReleaseToken(ctx context.Context, taskID, traceID string) error { span := trace.SpanFromContext(ctx) - result, err := r.redisClient.SRem(ctx, r.bucketKey, taskID).Result() + result, err := r.store.release(ctx, taskID) if err != nil { span.RecordError(err) - return fmt.Errorf("failed to release token: %v", err) + return err } if result > 0 { @@ -178,50 +176,28 @@ func (r *TokenBucketRateLimiter) WaitForToken(ctx context.Context, taskID, trace } } -var ( - restartPedestalRateLimiter *TokenBucketRateLimiter - buildContainerRateLimiter *TokenBucketRateLimiter - algoExecutionRateLimiter *TokenBucketRateLimiter - rateLimiterOnce sync.Once -) - -// GetRestartPedestalRateLimiter returns the singleton restart pedestal rate limiter -func GetRestartPedestalRateLimiter() *TokenBucketRateLimiter { - rateLimiterOnce.Do(initRateLimiters) - return restartPedestalRateLimiter -} - -// GetBuildContainerRateLimiter returns the singleton build container rate limiter -func GetBuildContainerRateLimiter() *TokenBucketRateLimiter { - rateLimiterOnce.Do(initRateLimiters) - return buildContainerRateLimiter -} - -// GetAlgoExecutionRateLimiter returns the singleton algorithm execution rate limiter -func GetAlgoExecutionRateLimiter() *TokenBucketRateLimiter { - rateLimiterOnce.Do(initRateLimiters) - return algoExecutionRateLimiter -} - -// initRateLimiters initializes all rate limiters -func initRateLimiters() { - restartPedestalRateLimiter = newTokenBucketRateLimiter(RateLimiterConfig{ +func NewRestartPedestalRateLimiter(gateway *redis.Gateway) *TokenBucketRateLimiter { + return newTokenBucketRateLimiter(gateway, RateLimiterConfig{ TokenBucketKey: consts.RestartPedestalTokenBucket, MaxTokensKey: consts.MaxTokensKeyRestartPedestal, DefaultMaxTokens: consts.MaxConcurrentRestartPedestal, DefaultTimeout: consts.TokenWaitTimeout, ServiceName: consts.RestartPedestalServiceName, }) +} - buildContainerRateLimiter = newTokenBucketRateLimiter(RateLimiterConfig{ +func NewBuildContainerRateLimiter(gateway *redis.Gateway) *TokenBucketRateLimiter { + return newTokenBucketRateLimiter(gateway, RateLimiterConfig{ TokenBucketKey: consts.BuildContainerTokenBucket, MaxTokensKey: consts.MaxTokensKeyBuildContainer, DefaultMaxTokens: consts.MaxConcurrentBuildContainer, DefaultTimeout: consts.TokenWaitTimeout, ServiceName: consts.BuildContainerServiceName, }) +} - algoExecutionRateLimiter = newTokenBucketRateLimiter(RateLimiterConfig{ +func NewAlgoExecutionRateLimiter(gateway *redis.Gateway) *TokenBucketRateLimiter { + return newTokenBucketRateLimiter(gateway, RateLimiterConfig{ TokenBucketKey: consts.AlgoExecutionTokenBucket, MaxTokensKey: consts.MaxTokensKeyAlgoExecution, DefaultMaxTokens: consts.MaxConcurrentAlgoExecution, @@ -231,7 +207,7 @@ func initRateLimiters() { } // newTokenBucketRateLimiter creates a new token bucket rate limiter -func newTokenBucketRateLimiter(cfg RateLimiterConfig) *TokenBucketRateLimiter { +func newTokenBucketRateLimiter(gateway *redis.Gateway, cfg RateLimiterConfig) *TokenBucketRateLimiter { maxTokens := config.GetInt(cfg.MaxTokensKey) if maxTokens <= 0 { maxTokens = cfg.DefaultMaxTokens @@ -243,8 +219,8 @@ func newTokenBucketRateLimiter(cfg RateLimiterConfig) *TokenBucketRateLimiter { } return &TokenBucketRateLimiter{ - redisClient: client.GetRedisClient(), bucketKey: cfg.TokenBucketKey, + store: newTokenBucketStore(gateway, cfg.TokenBucketKey), maxTokens: maxTokens, waitTimeout: time.Duration(waitTimeout) * time.Second, serviceName: cfg.ServiceName, diff --git a/src/service/consumer/rate_limiter_store.go b/src/service/consumer/rate_limiter_store.go new file mode 100644 index 00000000..2ff13dd9 --- /dev/null +++ b/src/service/consumer/rate_limiter_store.go @@ -0,0 +1,62 @@ +package consumer + +import ( + "context" + "fmt" + + redisinfra "aegis/infra/redis" + goredis "github.com/redis/go-redis/v9" +) + +type tokenBucketStore struct { + bucketKey string + client *redisinfra.Gateway +} + +func newTokenBucketStore(client *redisinfra.Gateway, bucketKey string) tokenBucketStore { + return tokenBucketStore{bucketKey: bucketKey, client: client} +} + +func (s tokenBucketStore) acquire(ctx context.Context, maxTokens int, taskID, traceID string) (bool, error) { + script := goredis.NewScript(` + local bucket_key = KEYS[1] + local max_tokens = tonumber(ARGV[1]) + local task_id = ARGV[2] + local trace_id = ARGV[3] + local expire_time = tonumber(ARGV[4]) + + local current_tokens = redis.call('SCARD', bucket_key) + + if current_tokens < max_tokens then + redis.call('SADD', bucket_key, task_id) + redis.call('EXPIRE', bucket_key, expire_time) + return 1 + else + return 0 + end + `) + + const expireTime = 10 * 60 + result, err := s.client.RunScript(ctx, script, []string{s.bucketKey}, + maxTokens, taskID, traceID, expireTime) + if err != nil { + return false, fmt.Errorf("failed to acquire token: %v", err) + } + return result.(int64) == 1, nil +} + +func (s tokenBucketStore) release(ctx context.Context, taskID string) (int64, error) { + result, err := s.client.SetRemove(ctx, s.bucketKey, taskID) + if err != nil { + return 0, fmt.Errorf("failed to release token: %v", err) + } + return result, nil +} + +func (s tokenBucketStore) inUse(ctx context.Context) (int64, error) { + result, err := s.client.SetCard(ctx, s.bucketKey) + if err != nil { + return 0, fmt.Errorf("failed to get token usage: %v", err) + } + return result, nil +} diff --git a/src/service/consumer/redis.go b/src/service/consumer/redis.go new file mode 100644 index 00000000..56f5563b --- /dev/null +++ b/src/service/consumer/redis.go @@ -0,0 +1,50 @@ +package consumer + +import ( + "context" + "fmt" + + "aegis/consts" + "aegis/dto" + redis "aegis/infra/redis" +) + +func consumerDetachedContext() context.Context { + return context.TODO() +} + +type redisStreamEvent interface { + ToRedisStream() map[string]any +} + +func publishRedisStreamEvent(gateway *redis.Gateway, ctx context.Context, stream string, event redisStreamEvent) error { + if gateway == nil { + return fmt.Errorf("redis gateway is nil") + } + if err := gateway.XAdd(ctx, stream, event.ToRedisStream()); err != nil { + return fmt.Errorf("failed to publish redis stream event: %w", err) + } + return nil +} + +func publishTraceStreamEvent(gateway *redis.Gateway, ctx context.Context, stream string, event *dto.TraceStreamEvent) error { + if event == nil { + return nil + } + return publishRedisStreamEvent(gateway, ctx, stream, event) +} + +func loadCachedInjectionAlgorithms(gateway *redis.Gateway, ctx context.Context, groupID string) ([]dto.ContainerVersionItem, bool, error) { + if gateway == nil { + return nil, false, fmt.Errorf("redis gateway is nil") + } + if !gateway.CheckCachedField(ctx, consts.InjectionAlgorithmsKey, groupID) { + return nil, false, nil + } + + var algorithms []dto.ContainerVersionItem + if err := gateway.GetHashField(ctx, consts.InjectionAlgorithmsKey, groupID, &algorithms); err != nil { + return nil, false, err + } + return algorithms, true, nil +} diff --git a/src/service/consumer/restart_pedestal.go b/src/service/consumer/restart_pedestal.go index 7da2c49c..1a34e1e0 100644 --- a/src/service/consumer/restart_pedestal.go +++ b/src/service/consumer/restart_pedestal.go @@ -1,10 +1,11 @@ package consumer import ( - "aegis/client" "aegis/config" "aegis/consts" "aegis/dto" + helm "aegis/infra/helm" + redis "aegis/infra/redis" "aegis/service/common" "aegis/tracing" "aegis/utils" @@ -18,6 +19,7 @@ import ( chaos "github.com/OperationsPAI/chaos-experiment/handler" "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/trace" + "gorm.io/gorm" ) type restartPayload struct { @@ -28,7 +30,7 @@ type restartPayload struct { } // executeRestartPedestal handles the execution of a restart pedestal task -func executeRestartPedestal(ctx context.Context, task *dto.UnifiedTask) error { +func executeRestartPedestal(ctx context.Context, task *dto.UnifiedTask, deps RuntimeDeps) error { return tracing.WithSpan(ctx, func(childCtx context.Context) error { span := trace.SpanFromContext(childCtx) span.AddEvent(fmt.Sprintf("Starting restarting pedestal attempt %d", task.ReStartNum+1)) @@ -36,8 +38,19 @@ func executeRestartPedestal(ctx context.Context, task *dto.UnifiedTask) error { "task_id": task.TaskID, "trace_id": task.TraceID, }) + helmGateway := deps.HelmGateway + if helmGateway == nil { + return handleExecutionError(span, logEntry, "helm gateway not initialized", fmt.Errorf("helm gateway not initialized")) + } + redisGateway := deps.RedisGateway + if redisGateway == nil { + return handleExecutionError(span, logEntry, "redis gateway not initialized", fmt.Errorf("redis gateway not initialized")) + } - rateLimiter := GetRestartPedestalRateLimiter() + rateLimiter := deps.RestartRateLimiter + if rateLimiter == nil { + return handleExecutionError(span, logEntry, "restart pedestal rate limiter not initialized", errors.New("restart pedestal rate limiter not initialized")) + } acquired, err := rateLimiter.AcquireToken(childCtx, task.TaskID, task.TraceID) if err != nil { return handleExecutionError(span, logEntry, "failed to acquire rate limit token", err) @@ -53,7 +66,7 @@ func executeRestartPedestal(ctx context.Context, task *dto.UnifiedTask) error { } if !acquired { - if err := rescheduleRestartPedestalTask(childCtx, task, "rate limited, retrying later"); err != nil { + if err := rescheduleRestartPedestalTask(childCtx, deps.DB, redisGateway, task, "rate limited, retrying later"); err != nil { return err } return nil @@ -75,7 +88,7 @@ func executeRestartPedestal(ctx context.Context, task *dto.UnifiedTask) error { return handleExecutionError(span, logEntry, fmt.Sprintf("no configuration found for system type: %s", system), fmt.Errorf("no configuration found for system type: %s", system)) } - monitor := GetMonitor() + monitor := deps.Monitor if monitor == nil { return handleExecutionError(span, logEntry, "monitor not initialized", errors.New("monitor not initialized")) } @@ -109,7 +122,7 @@ func executeRestartPedestal(ctx context.Context, task *dto.UnifiedTask) error { } acquired = false - if err := rescheduleRestartPedestalTask(childCtx, task, "failed to acquire lock for namespace, retrying"); err != nil { + if err := rescheduleRestartPedestalTask(childCtx, deps.DB, redisGateway, task, "failed to acquire lock for namespace, retrying"); err != nil { return err } @@ -132,12 +145,12 @@ func executeRestartPedestal(ctx context.Context, task *dto.UnifiedTask) error { consts.TaskTypeRestartPedestal, consts.TaskRunning, fmt.Sprintf("Restarting pedestal in namespace %s", namespace), - ).withSimpleEvent(consts.EventRestartPedestalStarted), + ).withSimpleEvent(consts.EventRestartPedestalStarted).withDB(deps.DB).withRedis(redisGateway), ) if payload.pedestal.Extra == nil { toReleased = true - publishEvent(childCtx, fmt.Sprintf(consts.StreamTraceLogKey, task.TraceID), dto.TraceStreamEvent{ + publishEvent(redisGateway, childCtx, fmt.Sprintf(consts.StreamTraceLogKey, task.TraceID), dto.TraceStreamEvent{ TaskID: task.TaskID, TaskType: consts.TaskTypeRestartPedestal, EventName: consts.EventRestartPedestalFailed, @@ -147,9 +160,9 @@ func executeRestartPedestal(ctx context.Context, task *dto.UnifiedTask) error { return handleExecutionError(span, logEntry, "missing extra info in pedestal item", fmt.Errorf("missing extra info in pedestal item")) } - if err := installPedestal(childCtx, namespace, index, payload.pedestal.Extra); err != nil { + if err := installPedestal(childCtx, helmGateway, namespace, index, payload.pedestal.Extra); err != nil { toReleased = true - publishEvent(childCtx, fmt.Sprintf(consts.StreamTraceLogKey, task.TraceID), dto.TraceStreamEvent{ + publishEvent(redisGateway, childCtx, fmt.Sprintf(consts.StreamTraceLogKey, task.TraceID), dto.TraceStreamEvent{ TaskID: task.TaskID, TaskType: consts.TaskTypeRestartPedestal, EventName: consts.EventRestartPedestalFailed, @@ -167,7 +180,7 @@ func executeRestartPedestal(ctx context.Context, task *dto.UnifiedTask) error { consts.TaskTypeRestartPedestal, consts.TaskCompleted, message, - ).withEvent(consts.EventRestartPedestalCompleted, message), + ).withEvent(consts.EventRestartPedestalCompleted, message).withDB(deps.DB).withRedis(redisGateway), ) tracing.SetSpanAttribute(childCtx, consts.TaskStateKey, consts.GetTaskStateName(consts.TaskCompleted)) @@ -176,7 +189,7 @@ func executeRestartPedestal(ctx context.Context, task *dto.UnifiedTask) error { payload.injectPayload[consts.InjectPedestal] = system payload.injectPayload[consts.InjectPedestalID] = payload.pedestal.ID - if err := common.ProduceFaultInjectionTasks(childCtx, task, injectTime, payload.injectPayload); err != nil { + if err := common.ProduceFaultInjectionTasksWithDB(childCtx, deps.DB, deps.RedisGateway, task, injectTime, payload.injectPayload); err != nil { toReleased = true return handleExecutionError(span, logEntry, "failed to submit inject task", err) } @@ -186,7 +199,7 @@ func executeRestartPedestal(ctx context.Context, task *dto.UnifiedTask) error { } // rescheduleRestartPedestalTask reschedules a pedestal restart task with exponential backoff and jitter -func rescheduleRestartPedestalTask(ctx context.Context, task *dto.UnifiedTask, reason string) error { +func rescheduleRestartPedestalTask(ctx context.Context, db *gorm.DB, redisGateway *redis.Gateway, task *dto.UnifiedTask, reason string) error { return tracing.WithSpan(ctx, func(childCtx context.Context) error { span := trace.SpanFromContext(ctx) @@ -211,11 +224,11 @@ func rescheduleRestartPedestalTask(ctx context.Context, task *dto.UnifiedTask, r consts.TaskTypeRestartPedestal, consts.TaskRescheduled, reason, - ).withEvent(consts.EventNoNamespaceAvailable, executeTime.String()), + ).withEvent(consts.EventNoNamespaceAvailable, executeTime.String()).withDB(db).withRedis(redisGateway), ) task.Reschedule(executeTime) - if err := common.SubmitTask(ctx, task); err != nil { + if err := common.SubmitTaskWithDB(ctx, db, redisGateway, task); err != nil { span.RecordError(err) span.AddEvent("failed to submit rescheduled task") return fmt.Errorf("failed to submit rescheduled restart task: %w", err) @@ -261,7 +274,7 @@ func parseRestartPayload(payload map[string]any) (*restartPayload, error) { // installPedestal installs or upgrades the pedestal using Helm // Priority: Remote (if configured) -> Local fallback (if remote fails and LocalPath is set) -func installPedestal(ctx context.Context, releaseName string, namespaceIdx int, item *dto.HelmConfigItem) error { +func installPedestal(ctx context.Context, gateway *helm.Gateway, releaseName string, namespaceIdx int, item *dto.HelmConfigItem) error { return tracing.WithSpan(ctx, func(childCtx context.Context) error { span := trace.SpanFromContext(childCtx) logEntry := logrus.WithFields(logrus.Fields{ @@ -273,11 +286,6 @@ func installPedestal(ctx context.Context, releaseName string, namespaceIdx int, return handleExecutionError(span, logEntry, "missing helm config in container extra info", fmt.Errorf("missing helm config in container extra info")) } - helmClient, err := client.NewHelmClient(releaseName) - if err != nil { - return handleExecutionError(span, logEntry, "failed to create Helm client", err) - } - paramItems := item.DynamicValues for i := range paramItems { if paramItems[i].TemplateString != "" { @@ -296,10 +304,10 @@ func installPedestal(ctx context.Context, releaseName string, namespaceIdx int, if hasRemote { logEntry.Infof("Attempting to install chart from remote repository: %s/%s", item.RepoName, item.ChartName) - if err := helmClient.AddRepo(item.RepoName, item.RepoURL); err != nil { + if err := gateway.AddRepo(releaseName, item.RepoName, item.RepoURL); err != nil { logEntry.Warnf("Failed to add repository: %v", err) installErr = err - } else if err := helmClient.UpdateRepo(item.RepoName); err != nil { + } else if err := gateway.UpdateRepo(releaseName, item.RepoName); err != nil { logEntry.Warnf("Failed to update repository: %v", err) installErr = err } else { @@ -312,7 +320,8 @@ func installPedestal(ctx context.Context, releaseName string, namespaceIdx int, "namespace": releaseName, }).Infof("Installing Helm chart from remote with parameters: %+v", helmValues) - if err := helmClient.Install(ctx, + if err := gateway.Install(ctx, + releaseName, releaseName, fullChart, item.Version, @@ -343,7 +352,8 @@ func installPedestal(ctx context.Context, releaseName string, namespaceIdx int, "namespace": releaseName, }).Infof("Installing Helm chart from local path with parameters: %+v", helmValues) - if err := helmClient.Install(ctx, + if err := gateway.Install(ctx, + releaseName, releaseName, item.LocalPath, item.Version, diff --git a/src/service/consumer/runtime_deps.go b/src/service/consumer/runtime_deps.go new file mode 100644 index 00000000..daa9ebde --- /dev/null +++ b/src/service/consumer/runtime_deps.go @@ -0,0 +1,25 @@ +package consumer + +import ( + buildkit "aegis/infra/buildkit" + helm "aegis/infra/helm" + k8s "aegis/infra/k8s" + redis "aegis/infra/redis" + + "gorm.io/gorm" +) + +type RuntimeDeps struct { + DB *gorm.DB + Monitor NamespaceMonitor + RestartRateLimiter *TokenBucketRateLimiter + BuildRateLimiter *TokenBucketRateLimiter + AlgorithmRateLimiter *TokenBucketRateLimiter + RedisGateway *redis.Gateway + K8sGateway *k8s.Gateway + BuildKitGateway *buildkit.Gateway + HelmGateway *helm.Gateway + FaultBatchManager *FaultBatchManager + ExecutionOwner ExecutionOwner + InjectionOwner InjectionOwner +} diff --git a/src/service/consumer/runtime_snapshot.go b/src/service/consumer/runtime_snapshot.go new file mode 100644 index 00000000..766450eb --- /dev/null +++ b/src/service/consumer/runtime_snapshot.go @@ -0,0 +1,175 @@ +package consumer + +import ( + "context" + "fmt" + "time" + + "aegis/consts" + buildkit "aegis/infra/buildkit" + helm "aegis/infra/helm" + k8s "aegis/infra/k8s" + redis "aegis/infra/redis" + + "gorm.io/gorm" +) + +const ( + RuntimeServiceName = "runtime-worker-service" + healthCheckTimeout = 2 * time.Second + runtimeModeWorker = "runtime-worker" +) + +type DependencyStatus struct { + Available bool + Healthy bool + Error string +} + +type RuntimeStatusSnapshot struct { + ServiceName string + Mode string + AppID string + StartedAt time.Time + UptimeSeconds int64 + DB DependencyStatus + Redis DependencyStatus + K8s DependencyStatus + BuildKit DependencyStatus + Helm DependencyStatus +} + +type RuntimeSnapshotService struct { + db *gorm.DB + redis *redis.Gateway + k8s *k8s.Gateway + buildkit *buildkit.Gateway + helm *helm.Gateway + restart *TokenBucketRateLimiter + build *TokenBucketRateLimiter + algorithm *TokenBucketRateLimiter +} + +func NewRuntimeSnapshotService( + db *gorm.DB, + redis *redis.Gateway, + k8s *k8s.Gateway, + buildkit *buildkit.Gateway, + helm *helm.Gateway, + restart *TokenBucketRateLimiter, + build *TokenBucketRateLimiter, + algorithm *TokenBucketRateLimiter, +) *RuntimeSnapshotService { + return &RuntimeSnapshotService{ + db: db, + redis: redis, + k8s: k8s, + buildkit: buildkit, + helm: helm, + restart: restart, + build: build, + algorithm: algorithm, + } +} + +func (s *RuntimeSnapshotService) RuntimeStatus(ctx context.Context) RuntimeStatusSnapshot { + startedAt := time.Now() + if consts.InitialTime != nil { + startedAt = *consts.InitialTime + } + + return RuntimeStatusSnapshot{ + ServiceName: RuntimeServiceName, + Mode: runtimeModeWorker, + AppID: consts.AppID, + StartedAt: startedAt, + UptimeSeconds: int64(time.Since(startedAt).Seconds()), + DB: s.dbStatus(ctx), + Redis: s.redisStatus(ctx), + K8s: s.k8sStatus(ctx), + BuildKit: s.buildkitStatus(ctx), + Helm: s.helmStatus(), + } +} + +func (s *RuntimeSnapshotService) QueueStatus(ctx context.Context) (redis.TaskQueueStats, error) { + if s.redis == nil { + return redis.TaskQueueStats{}, fmt.Errorf("redis gateway is nil") + } + return s.redis.GetTaskQueueStats(ctx) +} + +func (s *RuntimeSnapshotService) LimiterStatus(ctx context.Context) []RateLimiterSnapshot { + limiters := make([]RateLimiterSnapshot, 0, 3) + for _, limiter := range []*TokenBucketRateLimiter{s.restart, s.build, s.algorithm} { + if limiter == nil { + continue + } + limiters = append(limiters, limiter.Snapshot(ctx)) + } + return limiters +} + +func (s *RuntimeSnapshotService) dbStatus(ctx context.Context) DependencyStatus { + if s.db == nil { + return DependencyStatus{Available: false} + } + + sqlDB, err := s.db.DB() + if err != nil { + return DependencyStatus{Available: true, Healthy: false, Error: err.Error()} + } + + checkCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), healthCheckTimeout) + defer cancel() + if err := sqlDB.PingContext(checkCtx); err != nil { + return DependencyStatus{Available: true, Healthy: false, Error: err.Error()} + } + return DependencyStatus{Available: true, Healthy: true} +} + +func (s *RuntimeSnapshotService) redisStatus(ctx context.Context) DependencyStatus { + if s.redis == nil { + return DependencyStatus{Available: false} + } + + checkCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), healthCheckTimeout) + defer cancel() + if err := s.redis.Ping(checkCtx); err != nil { + return DependencyStatus{Available: true, Healthy: false, Error: err.Error()} + } + return DependencyStatus{Available: true, Healthy: true} +} + +func (s *RuntimeSnapshotService) k8sStatus(ctx context.Context) DependencyStatus { + if s.k8s == nil { + return DependencyStatus{Available: false} + } + + checkCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), healthCheckTimeout) + defer cancel() + if err := s.k8s.CheckHealth(checkCtx); err != nil { + return DependencyStatus{Available: true, Healthy: false, Error: err.Error()} + } + return DependencyStatus{Available: true, Healthy: true} +} + +func (s *RuntimeSnapshotService) buildkitStatus(ctx context.Context) DependencyStatus { + if s.buildkit == nil { + return DependencyStatus{Available: false} + } + + checkCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), healthCheckTimeout) + defer cancel() + if err := s.buildkit.CheckHealth(checkCtx, healthCheckTimeout); err != nil { + return DependencyStatus{Available: true, Healthy: false, Error: err.Error()} + } + return DependencyStatus{Available: true, Healthy: true} +} + +func (s *RuntimeSnapshotService) helmStatus() DependencyStatus { + if s.helm == nil { + return DependencyStatus{Available: false} + } + return DependencyStatus{Available: true, Healthy: true} +} diff --git a/src/service/consumer/state_store.go b/src/service/consumer/state_store.go new file mode 100644 index 00000000..7c672966 --- /dev/null +++ b/src/service/consumer/state_store.go @@ -0,0 +1,55 @@ +package consumer + +import ( + "context" + "fmt" + "time" + + "aegis/consts" + "aegis/dto" + execution "aegis/module/execution" + injection "aegis/module/injection" +) + +type stateStore struct { + execution ExecutionOwner + injection InjectionOwner +} + +func newStateStore(execution ExecutionOwner, injection InjectionOwner) *stateStore { + return &stateStore{ + execution: execution, + injection: injection, + } +} + +func (s *stateStore) updateExecutionState(ctx context.Context, executionID int, newState consts.ExecutionState) error { + if s.execution == nil { + return fmt.Errorf("execution owner service is nil") + } + return s.execution.UpdateExecutionState(ctx, &execution.RuntimeUpdateExecutionStateReq{ + ExecutionID: executionID, + State: newState, + }) +} + +func (s *stateStore) updateInjectionState(ctx context.Context, injectionName string, newState consts.DatapackState) error { + if s.injection == nil { + return fmt.Errorf("injection owner service is nil") + } + return s.injection.UpdateInjectionState(ctx, &injection.RuntimeUpdateInjectionStateReq{ + Name: injectionName, + State: newState, + }) +} + +func (s *stateStore) updateInjectionTimestamp(ctx context.Context, injectionName string, startTime time.Time, endTime time.Time) (*dto.InjectionItem, error) { + if s.injection == nil { + return nil, fmt.Errorf("injection owner service is nil") + } + return s.injection.UpdateInjectionTimestamps(ctx, &injection.RuntimeUpdateInjectionTimestampReq{ + Name: injectionName, + StartTime: startTime, + EndTime: endTime, + }) +} diff --git a/src/service/consumer/task.go b/src/service/consumer/task.go index 400fb1c6..7c9498e2 100644 --- a/src/service/consumer/task.go +++ b/src/service/consumer/task.go @@ -9,23 +9,23 @@ import ( "sync" "time" - "aegis/client" "aegis/consts" - "aegis/database" "aegis/dto" - "aegis/repository" + redisinfra "aegis/infra/redis" + "aegis/model" "aegis/service/common" "aegis/tracing" "aegis/utils" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/redis/go-redis/v9" + goredis "github.com/redis/go-redis/v9" "github.com/sirupsen/logrus" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" + "gorm.io/gorm" ) // ----------------------------------------------------------------------------- @@ -87,12 +87,14 @@ func withCallerLevel(level int) eventPublishOption { // taskStateUpdate encapsulates all information needed to update and notify task state changes type taskStateUpdate struct { - traceID string - taskID string - taskType consts.TaskType - taskState consts.TaskState - message string - event *dto.TraceStreamEvent // Optional: custom event to publish + traceID string + taskID string + taskType consts.TaskType + taskState consts.TaskState + message string + event *dto.TraceStreamEvent // Optional: custom event to publish + db *gorm.DB + redisGateway *redisinfra.Gateway } // newTaskStateUpdate creates a basic TaskStateUpdate with required fields @@ -138,15 +140,25 @@ func (u *taskStateUpdate) withSimpleEvent(eventType consts.EventType) *taskState return u.withEvent(eventType, nil) } +func (u *taskStateUpdate) withDB(db *gorm.DB) *taskStateUpdate { + u.db = db + return u +} + +func (u *taskStateUpdate) withRedis(gateway *redisinfra.Gateway) *taskStateUpdate { + u.redisGateway = gateway + return u +} + // StartScheduler starts the scheduler that moves tasks from delayed to ready queue -func StartScheduler(ctx context.Context) { +func StartScheduler(ctx context.Context, redisGateway *redisinfra.Gateway) { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case <-ticker.C: - processDelayedTasks(ctx) + processDelayedTasks(ctx, redisGateway) case <-ctx.Done(): return } @@ -154,10 +166,10 @@ func StartScheduler(ctx context.Context) { } // processDelayedTasks moves tasks from delayed queue to ready queue when their time arrives -func processDelayedTasks(ctx context.Context) { - result, err := repository.ProcessDelayedTasks(ctx) +func processDelayedTasks(ctx context.Context, redisGateway *redisinfra.Gateway) { + result, err := redisGateway.ProcessDelayedTasks(ctx) - if err != nil && err != redis.Nil { + if err != nil && err != goredis.Nil { logrus.Errorf("scheduler error: %v", err) return } @@ -173,7 +185,7 @@ func processDelayedTasks(ctx context.Context) { nextTime, err := common.CronNextTime(task.CronExpr) if err != nil { logrus.Warnf("invalid cron expr: %v", err) - if err := repository.HandleCronRescheduleFailure(ctx, []byte(taskData)); err != nil { + if err := redisGateway.HandleCronRescheduleFailure(ctx, []byte(taskData)); err != nil { logrus.Errorf("failed to handle cron reschedule failure: %v", err) } continue @@ -186,14 +198,14 @@ func processDelayedTasks(ctx context.Context) { return } - if err := repository.SubmitDelayedTask(ctx, taskData, task.TaskID, task.ExecuteTime); err != nil { + if err := redisGateway.SubmitDelayedTask(ctx, taskData, task.TaskID, task.ExecuteTime); err != nil { logrus.Errorf("failed to reschedule cron task %s: %v", task.TaskID, err) - err := repository.HandleCronRescheduleFailure(ctx, []byte(taskData)) + err := redisGateway.HandleCronRescheduleFailure(ctx, []byte(taskData)) if err != nil { logrus.Errorf("failed to handle cron reschedule failure: %v", err) } } else { - common.EmitTaskScheduled(ctx, &task, task.ExecuteTime, dto.TaskScheduledReasonCronNext) + common.EmitTaskScheduled(ctx, redisGateway, &task, task.ExecuteTime, dto.TaskScheduledReasonCronNext) } } } @@ -204,7 +216,7 @@ func processDelayedTasks(ctx context.Context) { // ----------------------------------------------------------------------------- // ConsumeTasks starts a consumer that processes tasks from the ready queue -func ConsumeTasks(ctx context.Context) { +func ConsumeTasks(ctx context.Context, deps RuntimeDeps) { defer func() { if r := recover(); r != nil { logrus.Errorf("consumer panic: %v", r) @@ -213,15 +225,15 @@ func ConsumeTasks(ctx context.Context) { logrus.Info("Starting consume tasks") for { - if !repository.AcquireConcurrencyLock(ctx) { + if !deps.RedisGateway.AcquireConcurrencyLock(ctx) { time.Sleep(100 * time.Millisecond) continue } - taskData, err := repository.GetTask(ctx, 30*time.Second) + taskData, err := deps.RedisGateway.GetTask(ctx, 30*time.Second) if err != nil { - repository.ReleaseConcurrencyLock(ctx) - if err == redis.Nil { + deps.RedisGateway.ReleaseConcurrencyLock(ctx) + if err == goredis.Nil { continue } logrus.Errorf("BRPop error: %v", err) @@ -229,13 +241,13 @@ func ConsumeTasks(ctx context.Context) { continue } - go processTask(ctx, taskData) + go processTask(ctx, taskData, deps) } } // processTask handles a task from the queue -func processTask(ctx context.Context, taskData string) { - defer repository.ReleaseConcurrencyLock(ctx) +func processTask(ctx context.Context, taskData string, deps RuntimeDeps) { + defer deps.RedisGateway.ReleaseConcurrencyLock(ctx) defer func() { if r := recover(); r != nil { logrus.Errorf("task panic: %v\n%s", r, debug.Stack()) @@ -261,7 +273,7 @@ func processTask(ctx context.Context, taskData string) { tasksProcessed.WithLabelValues(consts.GetTaskTypeName(task.Type), "started").Inc() - executeTaskWithRetry(taskCtx, &task) + executeTaskWithRetry(taskCtx, &task, deps) taskDuration.WithLabelValues(consts.GetTaskTypeName(task.Type)).Observe(time.Since(startTime).Seconds()) } @@ -308,7 +320,7 @@ func extractContext(task *dto.UnifiedTask) (context.Context, context.Context) { } // executeTaskWithRetry attempts to execute a task with retry logic -func executeTaskWithRetry(ctx context.Context, task *dto.UnifiedTask) { +func executeTaskWithRetry(ctx context.Context, task *dto.UnifiedTask, deps RuntimeDeps) { retryCtx, retryCancel := context.WithCancel(ctx) registerCancelFunc(task.TaskID, retryCancel) defer retryCancel() @@ -331,7 +343,7 @@ func executeTaskWithRetry(ctx context.Context, task *dto.UnifiedTask) { ctxWithCancel, cancel := context.WithCancel(ctx) _ = cancel - err := dispatchTask(ctxWithCancel, task) + err := dispatchTask(ctxWithCancel, task, deps) if err == nil { tasksProcessed.WithLabelValues(consts.GetTaskTypeName(task.Type), "success").Inc() span.SetStatus(codes.Ok, fmt.Sprintf("Task %s of type %s completed successfully after %d attempts", @@ -350,7 +362,7 @@ func executeTaskWithRetry(ctx context.Context, task *dto.UnifiedTask) { message := fmt.Sprintf("Attempt %d failed: %v", attempt+1, err) span.AddEvent(message) logrus.WithField("task_id", task.TaskID).Warn(message) - publishEvent(ctx, fmt.Sprintf(consts.StreamTraceLogKey, task.TraceID), dto.TraceStreamEvent{ + publishEvent(deps.RedisGateway, ctx, fmt.Sprintf(consts.StreamTraceLogKey, task.TraceID), dto.TraceStreamEvent{ TaskID: task.TaskID, TaskType: task.Type, EventName: consts.EventTaskRetryStatus, @@ -364,7 +376,7 @@ func executeTaskWithRetry(ctx context.Context, task *dto.UnifiedTask) { tasksProcessed.WithLabelValues(consts.GetTaskTypeName(task.Type), "failed").Inc() message := fmt.Sprintf("Task failed after %d attempts, errors: [%v]", task.RetryPolicy.MaxAttempts, errs) - handleFinalFailure(ctx, task, message) + handleFinalFailure(ctx, deps.RedisGateway, task, message) // Simple usage: no custom event needed updateTaskState(ctx, newTaskStateUpdate( @@ -373,7 +385,7 @@ func executeTaskWithRetry(ctx context.Context, task *dto.UnifiedTask) { task.Type, consts.TaskError, message, - )) + ).withDB(deps.DB).withRedis(deps.RedisGateway)) } // ----------------------------------------------------------------------------- @@ -395,14 +407,14 @@ func unregisterCancelFunc(taskID string) { } // handleFinalFailure moves a failed task to the dead letter queue -func handleFinalFailure(ctx context.Context, task *dto.UnifiedTask, errMsg string) { +func handleFinalFailure(ctx context.Context, redisGateway *redisinfra.Gateway, task *dto.UnifiedTask, errMsg string) { taskData, err := json.Marshal(task) if err != nil { logrus.Errorf("failed to marshal failed task %s: %v", task.TaskID, err) return } - if err := repository.HandleFailedTask(ctx, taskData, task.RetryPolicy.BackoffSec); err != nil { + if err := redisGateway.HandleFailedTask(ctx, taskData, task.RetryPolicy.BackoffSec); err != nil { logrus.Errorf("failed to handle failed task %s: %v", task.TaskID, err) } @@ -415,7 +427,7 @@ func handleFinalFailure(ctx context.Context, task *dto.UnifiedTask, errMsg strin } // CancelTask cancels a task and removes it from the queues -func CancelTask(taskID string) error { +func CancelTask(redisGateway *redisinfra.Gateway, taskID string) error { // Cancel execution context taskCancelFuncsMutex.RLock() cancelFunc, exists := taskCancelFuncs[taskID] @@ -426,29 +438,29 @@ func CancelTask(taskID string) error { } // Remove task from Redis - ctx := context.Background() + ctx := consumerDetachedContext() // Locate queue using index - queueType, err := repository.GetTaskQueue(ctx, taskID) + queueType, err := redisGateway.GetTaskQueue(ctx, taskID) if err == nil { switch queueType { - case repository.ReadyQueueKey: - if _, err := repository.RemoveFromList(ctx, repository.ReadyQueueKey, taskID); err != nil { + case ReadyQueueKey: + if _, err := redisGateway.RemoveFromList(ctx, ReadyQueueKey, taskID); err != nil { logrus.Warnf("failed to remove from list: %v", err) } - case repository.DelayedQueueKey: - if s := repository.RemoveFromZSet(ctx, repository.DelayedQueueKey, taskID); !s { + case DelayedQueueKey: + if s := redisGateway.RemoveFromZSet(ctx, DelayedQueueKey, taskID); !s { logrus.Warnf("failed to remove from delayed queue: %v", err) } - case repository.DeadLetterKey: - if s := repository.RemoveFromZSet(ctx, repository.DeadLetterKey, taskID); !s { + case DeadLetterKey: + if s := redisGateway.RemoveFromZSet(ctx, DeadLetterKey, taskID); !s { logrus.Warnf("failed to remove from dead letter queue: %v", err) } } } // Clean up index - if err := repository.DeleteTaskIndex(ctx, taskID); err != nil { + if err := redisGateway.DeleteTaskIndex(ctx, taskID); err != nil { logrus.Warnf("failed to delete task index: %v", err) } @@ -463,7 +475,7 @@ func CancelTask(taskID string) error { // publishEvent publishes a StreamEvent to the specified Redis stream // This adds caller information and handles error logging -func publishEvent(ctx context.Context, stream string, event dto.TraceStreamEvent, opts ...eventPublishOption) { +func publishEvent(gateway *redisinfra.Gateway, ctx context.Context, stream string, event dto.TraceStreamEvent, opts ...eventPublishOption) { options := &eventPublishOptions{ callerLevel: 2, } @@ -478,8 +490,8 @@ func publishEvent(ctx context.Context, stream string, event dto.TraceStreamEvent event.FnName = fn // Call repository layer for data access - if err := client.RedisXAdd(ctx, stream, event.ToRedisStream()); err != nil { - if err == redis.Nil { + if err := publishTraceStreamEvent(gateway, ctx, stream, &event); err != nil { + if err == goredis.Nil { logrus.Warnf("No new messages to publish to Redis stream %s", stream) return } @@ -490,6 +502,14 @@ func publishEvent(ctx context.Context, stream string, event dto.TraceStreamEvent // updateTaskState updates the task states and publishes the update func updateTaskState(ctx context.Context, update *taskStateUpdate) { err := tracing.WithSpan(ctx, func(childCtx context.Context) error { + db := update.db + if db == nil { + return fmt.Errorf("task state update db is nil") + } + if update.redisGateway == nil { + return fmt.Errorf("task state update redis gateway is nil") + } + span := trace.SpanFromContext(childCtx) logEntry := logrus.WithField("trace_id", update.traceID).WithField("task_id", update.taskID) span.AddEvent(update.message) @@ -507,15 +527,15 @@ func updateTaskState(ctx context.Context, update *taskStateUpdate) { // Publish custom event or default state update event if update.event != nil { - publishEvent(childCtx, stream, *update.event, withCallerLevel(5)) + publishEvent(update.redisGateway, childCtx, stream, *update.event, withCallerLevel(5)) } - if err := repository.UpdateTaskState(database.DB, childCtx, update.taskID, update.taskState); err != nil { + if err := updateTaskStateRecord(db, childCtx, update.taskID, update.taskState); err != nil { logEntry.Errorf("failed to update database: %v", err) return err } - if err := updateTraceState(update.traceID, update.taskID, update.taskState, update.event); err != nil { + if err := updateTraceState(update.redisGateway, db, update.traceID, update.taskID, update.taskState, update.event); err != nil { logEntry.Errorf("failed to update trace state: %v", err) return err } @@ -527,3 +547,9 @@ func updateTaskState(ctx context.Context, update *taskStateUpdate) { logrus.WithField("task_id", update.taskID).Errorf("failed to update task state: %v", err) } } + +func updateTaskStateRecord(db *gorm.DB, ctx context.Context, taskID string, state consts.TaskState) error { + return db.WithContext(ctx).Model(&model.Task{}). + Where("id = ?", taskID). + Update("state", state).Error +} diff --git a/src/service/consumer/trace.go b/src/service/consumer/trace.go index dc288708..7400b3ee 100644 --- a/src/service/consumer/trace.go +++ b/src/service/consumer/trace.go @@ -1,16 +1,17 @@ package consumer import ( - "aegis/client" "aegis/consts" - "aegis/database" "aegis/dto" - "aegis/repository" + redis "aegis/infra/redis" + "aegis/model" + group "aegis/module/group" "context" "fmt" "time" "github.com/sirupsen/logrus" + "gorm.io/gorm" ) // levelStatistics holds statistics for a specific level in the task tree @@ -81,15 +82,14 @@ func getEventTypeByTask(taskType consts.TaskType, taskState consts.TaskState) co // updateTraceState updates trace state based on task state change // This function is called after task state is persisted to ensure real-time sync -func updateTraceState(traceID, taskID string, newState consts.TaskState, event *dto.TraceStreamEvent) error { +func updateTraceState(redisGateway *redis.Gateway, db *gorm.DB, traceID, taskID string, newState consts.TaskState, event *dto.TraceStreamEvent) error { logEntry := logrus.WithField("trace_id", traceID).WithField("task_id", taskID) // Update trace state asynchronously to avoid blocking task processing go func() { - // Use background context since this is async - ctx := context.Background() + ctx := consumerDetachedContext() - if err := performTraceStateUpdate(ctx, traceID, taskID, newState, event); err != nil { + if err := performTraceStateUpdate(redisGateway, ctx, db, traceID, taskID, newState, event); err != nil { logEntry.Errorf("failed to update trace state: %v", err) } }() @@ -98,12 +98,12 @@ func updateTraceState(traceID, taskID string, newState consts.TaskState, event * } // performTraceStateUpdate performs the actual trace state update with retry logic -func performTraceStateUpdate(ctx context.Context, traceID, taskID string, newState consts.TaskState, event *dto.TraceStreamEvent) error { +func performTraceStateUpdate(redisGateway *redis.Gateway, ctx context.Context, db *gorm.DB, traceID, taskID string, newState consts.TaskState, event *dto.TraceStreamEvent) error { const maxRetries = 3 logEntry := logrus.WithField("trace_id", traceID) for attempt := range maxRetries { - err := tryUpdateTraceStateCore(ctx, traceID, taskID, newState, event) + err := tryUpdateTraceStateCore(redisGateway, ctx, db, traceID, taskID, newState, event) if err == nil { return nil } @@ -122,11 +122,15 @@ func performTraceStateUpdate(ctx context.Context, traceID, taskID string, newSta } // tryUpdateTraceStateCore attempts to update trace state once -func tryUpdateTraceStateCore(ctx context.Context, traceID, taskID string, newState consts.TaskState, streamEvent *dto.TraceStreamEvent) error { +func tryUpdateTraceStateCore(redisGateway *redis.Gateway, ctx context.Context, db *gorm.DB, traceID, taskID string, newState consts.TaskState, streamEvent *dto.TraceStreamEvent) error { + if db == nil { + return fmt.Errorf("trace state update db is nil") + } + logEntry := logrus.WithField("trace_id", traceID) // 1. Fetch trace with all tasks (including the just-updated task) - trace, err := repository.GetTraceByID(database.DB, traceID) + trace, err := getTraceByID(db, traceID) if err != nil { return fmt.Errorf("failed to get trace: %w", err) } @@ -135,7 +139,7 @@ func tryUpdateTraceStateCore(ctx context.Context, traceID, taskID string, newSta originalUpdatedAt := trace.UpdatedAt // 2. Find the task that was just updated - var updatedTask *database.Task + var updatedTask *model.Task for i := range trace.Tasks { if trace.Tasks[i].ID == taskID { updatedTask = &trace.Tasks[i] @@ -194,12 +198,12 @@ func tryUpdateTraceStateCore(ctx context.Context, traceID, taskID string, newSta // Publish to group-level stream for real-time group progress SSE if trace.GroupID != "" { - publishGroupStreamEvent(ctx, trace.GroupID, traceID, inferredState, inferredEventType) + publishGroupStreamEvent(redisGateway, ctx, trace.GroupID, traceID, inferredState, inferredEventType) } } // 6. Execute optimistic locking update - result := database.DB.Model(&database.Trace{}). + result := db.Model(&model.Trace{}). Where("id = ? AND updated_at = ?", traceID, originalUpdatedAt). Updates(updates) @@ -220,7 +224,7 @@ func tryUpdateTraceStateCore(ctx context.Context, traceID, taskID string, newSta } // buildLevelStatistics constructs level statistics from task list -func buildLevelStatistics(tasks []database.Task, treeHeight int) map[int]*levelStatistics { +func buildLevelStatistics(tasks []model.Task, treeHeight int) map[int]*levelStatistics { stats := make(map[int]*levelStatistics) // Initialize statistics for each level @@ -255,7 +259,7 @@ func buildLevelStatistics(tasks []database.Task, treeHeight int) map[int]*levelS // hasEarlyTerminationEvent checks if any CollectResult task has completed with early termination events // Now also checks the streamEvent to accurately determine if it's truly an early termination -func hasEarlyTerminationEvent(tasks []database.Task, streamEvent *dto.TraceStreamEvent) bool { +func hasEarlyTerminationEvent(tasks []model.Task, streamEvent *dto.TraceStreamEvent) bool { // Priority 1: Check if streamEvent explicitly indicates early termination if streamEvent != nil && streamEvent.EventName != "" { // These events indicate early termination - no further processing needed @@ -297,7 +301,7 @@ func hasEarlyTerminationEvent(tasks []database.Task, streamEvent *dto.TraceStrea // findEarlyTerminationEvent finds and returns the early termination event from completed CollectResult tasks // Now uses streamEvent to accurately return the correct event type -func findEarlyTerminationEvent(tasks []database.Task, streamEvent *dto.TraceStreamEvent) consts.EventType { +func findEarlyTerminationEvent(tasks []model.Task, streamEvent *dto.TraceStreamEvent) consts.EventType { // Priority 1: If streamEvent is provided with early termination events, use it directly if streamEvent != nil && streamEvent.EventName != "" { if streamEvent.EventName == consts.EventDatapackNoAnomaly || @@ -335,7 +339,7 @@ func findEarlyTerminationEvent(tasks []database.Task, streamEvent *dto.TraceStre } // selectBestLastEvent selects the most appropriate last event from completed leaf tasks -func selectBestLastEvent(tasks []database.Task, leafLevel int, streamEvent *dto.TraceStreamEvent) consts.EventType { +func selectBestLastEvent(tasks []model.Task, leafLevel int, streamEvent *dto.TraceStreamEvent) consts.EventType { // Event priority map: higher value = higher priority eventPriority := map[consts.EventType]int{ consts.EventFaultInjectionCompleted: 80, @@ -387,7 +391,7 @@ func selectBestLastEvent(tasks []database.Task, leafLevel int, streamEvent *dto. // inferTraceState infers trace state and last event from all tasks // streamEvent parameter helps distinguish between early termination vs continuation scenarios -func inferTraceState(trace *database.Trace, tasks []database.Task, streamEvent *dto.TraceStreamEvent) (consts.TraceState, consts.EventType) { +func inferTraceState(trace *model.Trace, tasks []model.Task, streamEvent *dto.TraceStreamEvent) (consts.TraceState, consts.EventType) { treeHeight := traceTypeHeightMap[trace.Type] stats := buildLevelStatistics(tasks, treeHeight) @@ -493,6 +497,20 @@ func inferTraceState(trace *database.Trace, tasks []database.Task, streamEvent * return consts.TracePending, consts.EventTaskStateUpdate } +func getTraceByID(db *gorm.DB, traceID string) (*model.Trace, error) { + var trace model.Trace + if err := db.Model(&model.Trace{}). + Preload("Project"). + Preload("Tasks", func(db *gorm.DB) *gorm.DB { + return db.Order("level ASC, sequence ASC") + }). + Where("id = ? AND status != ?", traceID, consts.CommonDeleted). + First(&trace).Error; err != nil { + return nil, err + } + return &trace, nil +} + // isOptimisticLockError checks if an error is due to optimistic lock failure func isOptimisticLockError(err error) bool { return err != nil && err.Error() == "optimistic lock conflict: trace was modified by another job" @@ -501,17 +519,17 @@ func isOptimisticLockError(err error) bool { // publishGroupStreamEvent publishes a lightweight event to the group-level Redis stream // when a trace reaches a terminal state (Completed/Failed). // This enables real-time SSE updates for group progress tracking on the frontend. -func publishGroupStreamEvent(ctx context.Context, groupID, traceID string, state consts.TraceState, lastEvent consts.EventType) { +func publishGroupStreamEvent(redisGateway *redis.Gateway, ctx context.Context, groupID, traceID string, state consts.TraceState, lastEvent consts.EventType) { streamKey := fmt.Sprintf(consts.StreamGroupLogKey, groupID) logEntry := logrus.WithField("group_id", groupID).WithField("trace_id", traceID) - event := &dto.GroupStreamEvent{ + event := &group.GroupStreamEvent{ TraceID: traceID, State: state, LastEvent: lastEvent, } - if err := client.RedisXAdd(ctx, streamKey, event.ToRedisStream()); err != nil { + if err := publishRedisStreamEvent(redisGateway, ctx, streamKey, event); err != nil { logEntry.Errorf("failed to publish group stream event: %v", err) return } diff --git a/src/service/initialization/bootstrap_store.go b/src/service/initialization/bootstrap_store.go new file mode 100644 index 00000000..eb744297 --- /dev/null +++ b/src/service/initialization/bootstrap_store.go @@ -0,0 +1,207 @@ +package initialization + +import ( + "errors" + "fmt" + + "aegis/consts" + "aegis/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + resourceOmitFields = "Parent" + roleOmitFields = "ActiveName" + permissionOmitFields = "ActiveName,Resource" + userOmitFields = "active_username" + teamOmitFields = "ActiveName" + projectOmitFields = "ActiveName" + userTeamOmitFields = "active_user_team" +) + +type bootstrapStore struct { + db *gorm.DB +} + +func newBootstrapStore(db *gorm.DB) *bootstrapStore { + return &bootstrapStore{db: db} +} + +func (s *bootstrapStore) listExistingConfigs() ([]model.DynamicConfig, error) { + var configs []model.DynamicConfig + if err := s.db.Order("config_key ASC").Find(&configs).Error; err != nil { + return nil, fmt.Errorf("failed to list all existing configs: %w", err) + } + return configs, nil +} + +func (s *bootstrapStore) upsertResources(resources []model.Resource) error { + if len(resources) == 0 { + return fmt.Errorf("no resources to upsert") + } + if err := s.db.Omit(resourceOmitFields).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "name"}}, + DoUpdates: clause.AssignmentColumns([]string{}), + }).Create(&resources).Error; err != nil { + return fmt.Errorf("failed to batch upsert resources: %w", err) + } + return nil +} + +func (s *bootstrapStore) listResourcesByNames(names []consts.ResourceName) ([]model.Resource, error) { + if len(names) == 0 { + return nil, fmt.Errorf("no resource names provided") + } + var resources []model.Resource + if err := s.db.Where("name IN ?", names).Find(&resources).Error; err != nil { + return nil, fmt.Errorf("failed to list resources by names: %w", err) + } + return resources, nil +} + +func (s *bootstrapStore) upsertPermissions(permissions []model.Permission) error { + if len(permissions) == 0 { + return fmt.Errorf("no permissions to upsert") + } + if err := s.db.Omit(permissionOmitFields).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "name"}}, + DoUpdates: clause.AssignmentColumns([]string{}), + }).Create(&permissions).Error; err != nil { + return fmt.Errorf("failed to batch upsert permissions: %w", err) + } + return nil +} + +func (s *bootstrapStore) upsertRoles(roles []model.Role) error { + if len(roles) == 0 { + return fmt.Errorf("no roles to upsert") + } + if err := s.db.Omit(roleOmitFields).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "name"}}, + DoUpdates: clause.AssignmentColumns([]string{}), + }).Create(&roles).Error; err != nil { + return fmt.Errorf("failed to batch upsert roles: %w", err) + } + return nil +} + +func (s *bootstrapStore) getRoleByName(name string) (*model.Role, error) { + var role model.Role + if err := s.db.Where("name = ? AND status != ?", name, consts.CommonDeleted).First(&role).Error; err != nil { + return nil, fmt.Errorf("failed to find role with name %s: %w", name, err) + } + return &role, nil +} + +func (s *bootstrapStore) listSystemPermissions() ([]model.Permission, error) { + var permissions []model.Permission + if err := s.db.Where("is_system = ? AND status = ?", true, consts.CommonEnabled).Find(&permissions).Error; err != nil { + return nil, fmt.Errorf("failed to get system permissions: %w", err) + } + return permissions, nil +} + +func (s *bootstrapStore) listPermissionsByNames(names []string) ([]model.Permission, error) { + if len(names) == 0 { + return []model.Permission{}, nil + } + var permissions []model.Permission + if err := s.db.Where("name IN ? AND status = ?", names, consts.CommonEnabled).Find(&permissions).Error; err != nil { + return nil, fmt.Errorf("failed to query permissions: %w", err) + } + return permissions, nil +} + +func (s *bootstrapStore) createRolePermissions(rolePermissions []model.RolePermission) error { + if len(rolePermissions) == 0 { + return nil + } + if err := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rolePermissions).Error; err != nil { + return fmt.Errorf("failed to batch create role permissions: %w", err) + } + return nil +} + +func (s *bootstrapStore) createUser(user *model.User) error { + if err := s.db.Omit(userOmitFields).Create(user).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: user %s already exists", consts.ErrAlreadyExists, user.Username) + } + return fmt.Errorf("failed to create user: %w", err) + } + return nil +} + +func (s *bootstrapStore) createUserRole(userRole *model.UserRole) error { + if err := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(userRole).Error; err != nil { + return fmt.Errorf("failed to create user-role association: %w", err) + } + return nil +} + +func (s *bootstrapStore) createTeam(team *model.Team) error { + if err := s.db.Omit(teamOmitFields).Create(team).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: team %s already exists", consts.ErrAlreadyExists, team.Name) + } + return fmt.Errorf("failed to create team: %w", err) + } + return nil +} + +func (s *bootstrapStore) createProject(project *model.Project) error { + if err := s.db.Omit(projectOmitFields).Create(project).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("%w: project %s already exists", consts.ErrAlreadyExists, project.Name) + } + return fmt.Errorf("failed to create project: %w", err) + } + return nil +} + +func (s *bootstrapStore) getTeamByName(name string) (*model.Team, error) { + var team model.Team + if err := s.db.Where("name = ? AND status != ?", name, consts.CommonDeleted).First(&team).Error; err != nil { + return nil, fmt.Errorf("failed to find team with name %s: %w", name, err) + } + return &team, nil +} + +func (s *bootstrapStore) getProjectByName(name string) (*model.Project, error) { + var project model.Project + if err := s.db.Where("name = ? AND status != ?", name, consts.CommonDeleted).First(&project).Error; err != nil { + return nil, fmt.Errorf("failed to find project with name %s: %w", name, err) + } + return &project, nil +} + +func (s *bootstrapStore) saveProject(project *model.Project) error { + if err := s.db.Omit(projectOmitFields).Save(project).Error; err != nil { + return fmt.Errorf("failed to update project: %w", err) + } + return nil +} + +func (s *bootstrapStore) createUserTeam(userTeam *model.UserTeam) error { + if err := s.db.Omit(userTeamOmitFields).Clauses(clause.OnConflict{DoNothing: true}).Create(userTeam).Error; err != nil { + return fmt.Errorf("failed to create user-team association: %w", err) + } + return nil +} + +func (s *bootstrapStore) createUserProject(userProject *model.UserProject) error { + if err := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(userProject).Error; err != nil { + return fmt.Errorf("failed to create user-project association: %w", err) + } + return nil +} + +func (s *bootstrapStore) listEnabledSystems() ([]model.System, error) { + var systems []model.System + if err := s.db.Where("status = ?", consts.CommonEnabled).Find(&systems).Error; err != nil { + return nil, fmt.Errorf("failed to list enabled systems: %w", err) + } + return systems, nil +} diff --git a/src/service/initialization/common.go b/src/service/initialization/common.go index c007d702..4c26451f 100644 --- a/src/service/initialization/common.go +++ b/src/service/initialization/common.go @@ -4,35 +4,26 @@ import ( "aegis/config" "aegis/consts" "aegis/service/common" - "context" + "fmt" "github.com/sirupsen/logrus" ) -func registerHandlers(ctx context.Context, scope consts.ConfigScope, handlerFunc func()) { - // Register global-scope handlers (idempotent via sync.Once) - common.RegisterGlobalHandlers() - if handlerFunc != nil { - handlerFunc() - } - - // Ensure etcd listener covers required scopes (each call is idempotent) - listener := common.GetConfigUpdateListener(ctx) - +func activateConfigScope(scope consts.ConfigScope, listener *common.ConfigUpdateListener) error { if err := listener.EnsureScope(consts.ConfigScopeGlobal); err != nil { - logrus.Fatalf("Failed to activate global config listener: %v", err) + return fmt.Errorf("failed to activate global config listener: %w", err) } if scope == consts.ConfigScopeConsumer { if err := listener.EnsureScope(consts.ConfigScopeConsumer); err != nil { - logrus.Fatalf("Failed to activate consumer config listener: %v", err) + return fmt.Errorf("failed to activate consumer config listener: %w", err) } } logrus.Infof("Config handlers registered for scope %s, %d total handler(s)", consts.GetConfigScopeName(scope), len(common.ListRegisteredConfigKeys(nil))) - // Sync atomic vars from viper (listener has loaded configs from etcd) config.SetDetectorName(config.GetString(consts.DetectorKey)) logrus.Infof("Global detector name initialized: %s", config.GetDetectorName()) + return nil } diff --git a/src/service/initialization/consumer.go b/src/service/initialization/consumer.go index 47d6fa1f..d431e68e 100644 --- a/src/service/initialization/consumer.go +++ b/src/service/initialization/consumer.go @@ -5,11 +5,12 @@ import ( "fmt" "path/filepath" - "aegis/client/k8s" "aegis/config" "aegis/consts" - "aegis/database" - "aegis/repository" + k8s "aegis/infra/k8s" + redis "aegis/infra/redis" + "aegis/model" + ratelimiter "aegis/module/ratelimiter" "aegis/service/common" "aegis/service/consumer" @@ -17,35 +18,60 @@ import ( "gorm.io/gorm" ) -var consumerData *configData - -func InitConcurrencyLock(ctx context.Context) { - if err := repository.InitConcurrencyLock(ctx); err != nil { - logrus.Fatalf("error setting concurrency lock to 0: %v", err) +func InitializeConsumer( + ctx context.Context, + db *gorm.DB, + controller *k8s.Controller, + monitor consumer.NamespaceMonitor, + publisher *redis.Gateway, + listener *common.ConfigUpdateListener, + restartLimiter *consumer.TokenBucketRateLimiter, + buildLimiter *consumer.TokenBucketRateLimiter, + algoLimiter *consumer.TokenBucketRateLimiter, +) error { + consumerData, err := newConfigDataWithDB(db, consts.ConfigScopeConsumer) + if err != nil { + return fmt.Errorf("failed to load consumer config metadata: %w", err) } -} - -func InitializeConsumer(ctx context.Context) { - consumerData = newConfigData(consts.ConfigScopeConsumer) if len(consumerData.configs) == 0 { logrus.Info("Seeding initial system data for consumer...") - if err := initializeConsumer(); err != nil { - logrus.Fatalf("Failed to initialize system data for consumer: %v", err) + if err := initializeConsumer(db); err != nil { + return fmt.Errorf("failed to initialize system data for consumer: %w", err) } logrus.Info("Successfully seeded initial system data for consumer") } else { logrus.Info("Initial system data for consumer already seeded, skipping initialization") } - registerHandlers(ctx, consumerData.scope, consumer.RegisterConsumerHandlers) + common.RegisterGlobalHandlers(publisher) + consumer.RegisterConsumerHandlers(controller, monitor, publisher, restartLimiter, buildLimiter, algoLimiter) + if err := activateConfigScope(consumerData.scope, listener); err != nil { + return err + } - // Initialize namespaces on startup - critical after restart to re-initialize CRD informers - logrus.Info("Initializing namespaces on startup...") - monitor := consumer.GetMonitor() + // Auto-GC leaked rate-limiter tokens on startup (OperationsPAI/aegis#21). + rlSvc := ratelimiter.NewService(publisher, db) + if released, buckets, err := rlSvc.GC(ctx); err != nil { + logrus.WithError(err).Warn("rate-limiter startup GC failed") + } else if released > 0 { + logrus.WithFields(logrus.Fields{"released": released, "buckets": buckets}). + Info("rate-limiter startup GC completed") + } + + // Namespace/bootstrap informer initialization can take noticeably longer than + // the Fx startup deadline when the local cluster is cold or slow. Run it in + // the background so consumer/both startup does not fail with + // "context deadline exceeded" during local debugging. if monitor == nil { logrus.Warn("Monitor not initialized, skipping namespace initialization") - } else { + return nil + } + + monitor.SetContext(ctx) + go func() { + logrus.Info("Initializing namespaces on startup...") + initialized, err := monitor.InitializeNamespaces() if err != nil { logrus.Errorf("Failed to initialize namespaces: %v", err) @@ -58,14 +84,15 @@ func InitializeConsumer(ctx context.Context) { } logrus.Infof("Initialized namespaces on startup: %v", initialized) - if err := consumer.UpdateK8sController(k8s.GetK8sController(), initialized, []string{}); err != nil { + if err := consumer.UpdateK8sController(controller, initialized, []string{}); err != nil { logrus.Errorf("Failed to update k8s controller: %v", err) - return } - } + }() + + return nil } -func initializeConsumer() error { +func initializeConsumer(db *gorm.DB) error { dataPath := config.GetString("initialization.data_path") filePath := filepath.Join(dataPath, consts.InitialFilename) initialData, err := loadInitialDataFromFile(filePath) @@ -73,9 +100,9 @@ func initializeConsumer() error { return fmt.Errorf("failed to load initial data from file: %w", err) } - return withOptimizedDBSettings(func() error { - err := database.DB.Transaction(func(tx *gorm.DB) error { - if err := initializeDynamicConfigs(tx, initialData); err != nil { + return withOptimizedDBSettings(db, func() error { + err := db.Transaction(func(tx *gorm.DB) error { + if _, err := initializeDynamicConfigs(tx, initialData); err != nil { return fmt.Errorf("failed to initialize dynamic configs for consumer: %w", err) } return nil @@ -88,21 +115,20 @@ func initializeConsumer() error { }) } -func initializeDynamicConfigs(tx *gorm.DB, data *InitialData) error { - var configs []database.DynamicConfig +func initializeDynamicConfigs(tx *gorm.DB, data *InitialData) ([]model.DynamicConfig, error) { + var configs []model.DynamicConfig for _, configData := range data.DynamicConfigs { cfg := configData.ConvertToDBDynamicConfig() if err := common.ValidateConfigMetadataConstraints(cfg); err != nil { - return fmt.Errorf("invalid config value for key %s: %w", configData.Key, err) + return nil, fmt.Errorf("invalid config value for key %s: %w", configData.Key, err) } if err := common.CreateConfig(tx, cfg); err != nil { - return fmt.Errorf("failed to create dynamic config %s: %w", configData.Key, err) + return nil, fmt.Errorf("failed to create dynamic config %s: %w", configData.Key, err) } configs = append(configs, *cfg) } - consumerData.configs = configs - return nil + return configs, nil } diff --git a/src/service/initialization/producer.go b/src/service/initialization/producer.go index 06eccc4d..c51a32f4 100644 --- a/src/service/initialization/producer.go +++ b/src/service/initialization/producer.go @@ -1,16 +1,18 @@ package initialization import ( - "context" "errors" "fmt" "path/filepath" "aegis/config" "aegis/consts" - "aegis/database" - "aegis/repository" - producer "aegis/service/producer" + redis "aegis/infra/redis" + "aegis/model" + container "aegis/module/container" + dataset "aegis/module/dataset" + label "aegis/module/label" + "aegis/service/common" "aegis/utils" "github.com/sirupsen/logrus" @@ -30,17 +32,16 @@ func (r permMeta) String() string { return fmt.Sprintf("%v %v %v", r.action, r.resourceScope, r.resourceName) } -var producerData *configData - -var resourceIDMap map[consts.ResourceName]int - -func InitializeProducer(ctx context.Context) { - producerData = newConfigData(consts.ConfigScopeProducer) +func InitializeProducer(db *gorm.DB, publisher *redis.Gateway, listener *common.ConfigUpdateListener) error { + producerData, err := newConfigDataWithDB(db, consts.ConfigScopeProducer) + if err != nil { + return fmt.Errorf("failed to load producer config metadata: %w", err) + } if len(producerData.configs) == 0 { logrus.Info("Seeding initial system data for producer...") - if err := initializeProducer(); err != nil { - logrus.Fatalf("Failed to initialize system data for producer: %v", err) + if err := initializeProducer(db); err != nil { + return fmt.Errorf("failed to initialize system data for producer: %w", err) } logrus.Info("Successfully seeded initial system data for producer") } else { @@ -48,12 +49,18 @@ func InitializeProducer(ctx context.Context) { } // Initialize systems (seed builtins, register with chaos-experiment, set MetadataStore) - InitializeSystems() + if err := InitializeSystems(db); err != nil { + return fmt.Errorf("failed to initialize systems: %w", err) + } + common.RegisterGlobalHandlers(publisher) + if err := activateConfigScope(producerData.scope, listener); err != nil { + return err + } - registerHandlers(ctx, producerData.scope, nil) + return nil } -func initializeProducer() error { +func initializeProducer(db *gorm.DB) error { dataPath := config.GetString("initialization.data_path") filePath := filepath.Join(dataPath, consts.InitialFilename) initialData, err := loadInitialDataFromFile(filePath) @@ -62,7 +69,7 @@ func initializeProducer() error { } // System resources (following the order in system.go) - resources := []database.Resource{ + resources := []model.Resource{ {Name: consts.ResourceSystem, Type: consts.ResourceTypeSystem, Category: consts.ResourceCategorySystem}, {Name: consts.ResourceAudit, Type: consts.ResourceTypeTable, Category: consts.ResourceCategorySystem}, {Name: consts.ResourceConfiguration, Type: consts.ResourceTypeTable, Category: consts.ResourceCategorySystem}, @@ -86,9 +93,9 @@ func initializeProducer() error { resources[i].DisplayName = consts.GetResourceDisplayName(resources[i].Name) } - systemRoles := make([]database.Role, 0) + systemRoles := make([]model.Role, 0) for role, displayName := range consts.SystemRoleDisplayNames { - systemRoles = append(systemRoles, database.Role{ + systemRoles = append(systemRoles, model.Role{ Name: role.String(), DisplayName: displayName, IsSystem: true, @@ -96,9 +103,11 @@ func initializeProducer() error { }) } - return withOptimizedDBSettings(func() error { - return database.DB.Transaction(func(tx *gorm.DB) error { - if err := repository.BatchUpsertResources(tx, resources); err != nil { + return withOptimizedDBSettings(db, func() error { + return db.Transaction(func(tx *gorm.DB) error { + txStore := newBootstrapStore(tx) + + if err := txStore.upsertResources(resources); err != nil { return fmt.Errorf("failed to create system resources: %w", err) } @@ -107,7 +116,7 @@ func initializeProducer() error { resourceNames = append(resourceNames, res.Name) } - allResourcesInDB, err := repository.ListResourcesByNames(tx, resourceNames) + allResourcesInDB, err := txStore.listResourcesByNames(resourceNames) if err != nil { return fmt.Errorf("failed to get system resources from database: %w", err) } @@ -116,8 +125,8 @@ func initializeProducer() error { return fmt.Errorf("mismatch in number of resources created and fetched") } - resourceMap := make(map[consts.ResourceName]*database.Resource, len(allResourcesInDB)) - resourceIDMap = make(map[consts.ResourceName]int, len(allResourcesInDB)) + resourceMap := make(map[consts.ResourceName]*model.Resource, len(allResourcesInDB)) + resourceIDMap := make(map[consts.ResourceName]int, len(allResourcesInDB)) for _, res := range allResourcesInDB { resourceIDMap[res.Name] = res.ID resourceMap[res.Name] = &res @@ -126,12 +135,12 @@ func initializeProducer() error { resourceMap[consts.ResourceContainerVersion].ParentID = utils.IntPtr(resourceIDMap[consts.ResourceContainer]) resourceMap[consts.ResourceDatasetVersion].ParentID = utils.IntPtr(resourceIDMap[consts.ResourceDataset]) - toUpdatedResources := []database.Resource{ + toUpdatedResources := []model.Resource{ *resourceMap[consts.ResourceContainerVersion], *resourceMap[consts.ResourceDatasetVersion], } - if err := repository.BatchUpsertResources(tx, toUpdatedResources); err != nil { + if err := txStore.upsertResources(toUpdatedResources); err != nil { return fmt.Errorf("failed to update resource parent IDs: %w", err) } @@ -157,7 +166,7 @@ func initializeProducer() error { } } - var permissionsToCreate []database.Permission + var permissionsToCreate []model.Permission for permName, permData := range uniquePermissions { resource, ok := resourceMap[permData.resourceName] if !ok { @@ -172,7 +181,7 @@ func initializeProducer() error { } } - permission := database.Permission{ + permission := model.Permission{ Name: permName, DisplayName: permData.String(), Action: permData.action, @@ -184,28 +193,28 @@ func initializeProducer() error { permissionsToCreate = append(permissionsToCreate, permission) } - if err := repository.BatchUpsertPermissions(tx, permissionsToCreate); err != nil { + if err := txStore.upsertPermissions(permissionsToCreate); err != nil { return fmt.Errorf("failed to create system permissions: %w", err) } - if err := repository.BatchUpsertRoles(tx, systemRoles); err != nil { + if err := txStore.upsertRoles(systemRoles); err != nil { return fmt.Errorf("failed to create system roles: %w", err) } - if err := assignSystemRolePermissions(tx); err != nil { + if err := assignSystemRolePermissions(txStore); err != nil { return fmt.Errorf("failed to assign system role permissions: %w", err) } - adminUser, err := initializeAdminUser(tx, initialData) + adminUser, err := initializeAdminUser(txStore, initialData) if err != nil { return fmt.Errorf("failed to initialize admin user: %w", err) } - if err := initializeProjectsAndTeams(tx, initialData); err != nil { + if err := initializeProjectsAndTeams(txStore, initialData); err != nil { return fmt.Errorf("failed to initialize admin user, projects and teams: %w", err) } - if err := initializeUsers(tx, initialData); err != nil { + if err := initializeUsers(txStore, initialData); err != nil { return fmt.Errorf("failed to initialize users: %w", err) } @@ -226,9 +235,9 @@ func initializeProducer() error { }) } -func assignSystemRolePermissions(tx *gorm.DB) error { +func assignSystemRolePermissions(store *bootstrapStore) error { for roleName, permissionRules := range consts.SystemRolePermissions { - role, err := repository.GetRoleByName(tx, roleName.String()) + role, err := store.getRoleByName(roleName.String()) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("role %s not found", roleName) @@ -237,20 +246,20 @@ func assignSystemRolePermissions(tx *gorm.DB) error { } if roleName == consts.RoleSuperAdmin { - permissions, err := repository.ListSystemPermissions(tx) + permissions, err := store.listSystemPermissions() if err != nil { return fmt.Errorf("failed to list system permissions: %w", err) } - var rolePermissions []database.RolePermission + var rolePermissions []model.RolePermission for _, perm := range permissions { - rolePermissions = append(rolePermissions, database.RolePermission{ + rolePermissions = append(rolePermissions, model.RolePermission{ RoleID: role.ID, PermissionID: perm.ID, }) } - if err := repository.BatchCreateRolePermissions(tx, rolePermissions); err != nil { + if err := store.createRolePermissions(rolePermissions); err != nil { return fmt.Errorf("failed to assign all permissions to super admin role: %w", err) } } else { @@ -259,20 +268,20 @@ func assignSystemRolePermissions(tx *gorm.DB) error { permissionStrs = append(permissionStrs, rule.String()) } - permissions, err := repository.ListPermissionsByNames(tx, permissionStrs) + permissions, err := store.listPermissionsByNames(permissionStrs) if err != nil { return fmt.Errorf("failed to list permissions for role %s: %w", roleName, err) } - var rolePermissions []database.RolePermission + var rolePermissions []model.RolePermission for _, perm := range permissions { - rolePermissions = append(rolePermissions, database.RolePermission{ + rolePermissions = append(rolePermissions, model.RolePermission{ RoleID: role.ID, PermissionID: perm.ID, }) } - if err := repository.BatchCreateRolePermissions(tx, rolePermissions); err != nil { + if err := store.createRolePermissions(rolePermissions); err != nil { return fmt.Errorf("failed to assign permissions to role %s: %w", roleName, err) } } @@ -281,16 +290,16 @@ func assignSystemRolePermissions(tx *gorm.DB) error { return nil } -func initializeAdminUser(tx *gorm.DB, data *InitialData) (*database.User, error) { +func initializeAdminUser(store *bootstrapStore, data *InitialData) (*model.User, error) { adminUser := data.AdminUser.ConvertToDBUser() - if err := repository.CreateUser(tx, adminUser); err != nil { + if err := store.createUser(adminUser); err != nil { if errors.Is(err, consts.ErrAlreadyExists) { return nil, fmt.Errorf("admin user already exists") } return nil, fmt.Errorf("failed to create admin user: %w", err) } - superAdminRole, err := repository.GetRoleByName(tx, "super_admin") + superAdminRole, err := store.getRoleByName("super_admin") if err != nil { if errors.Is(err, consts.ErrNotFound) { return nil, fmt.Errorf("super_admin role not found, ensure system roles are initialized first") @@ -298,11 +307,11 @@ func initializeAdminUser(tx *gorm.DB, data *InitialData) (*database.User, error) return nil, fmt.Errorf("failed to get super_admin role: %w", err) } - userRole := database.UserRole{ + userRole := model.UserRole{ UserID: adminUser.ID, RoleID: superAdminRole.ID, } - if err := repository.CreateUserRole(tx, &userRole); err != nil { + if err := store.createUserRole(&userRole); err != nil { if errors.Is(err, consts.ErrAlreadyExists) { return nil, fmt.Errorf("admin user already has super_admin role") } @@ -312,10 +321,10 @@ func initializeAdminUser(tx *gorm.DB, data *InitialData) (*database.User, error) return adminUser, nil } -func initializeProjectsAndTeams(tx *gorm.DB, data *InitialData) error { +func initializeProjectsAndTeams(store *bootstrapStore, data *InitialData) error { for _, teamData := range data.Teams { team := teamData.ConvertToDBTeam() - if err := repository.CreateTeam(tx, team); err != nil { + if err := store.createTeam(team); err != nil { if errors.Is(err, consts.ErrAlreadyExists) { return fmt.Errorf("team %s already exists", team.Name) } @@ -325,7 +334,7 @@ func initializeProjectsAndTeams(tx *gorm.DB, data *InitialData) error { for _, projectData := range data.Projects { project := projectData.ConvertToDBProject() - if err := repository.CreateProject(tx, project); err != nil { + if err := store.createProject(project); err != nil { if errors.Is(err, consts.ErrAlreadyExists) { return fmt.Errorf("project %s already exists", project.Name) } @@ -340,20 +349,20 @@ func initializeContainers(tx *gorm.DB, data *InitialData, userID int) error { dataPath := config.GetString("initialization.data_path") for _, containerData := range data.Containers { - container := containerData.ConvertToDBContainer() - if container.Type == consts.ContainerTypePedestal { - system := chaos.SystemType(container.Name) + containerModel := containerData.ConvertToDBContainer() + if containerModel.Type == consts.ContainerTypePedestal { + system := chaos.SystemType(containerModel.Name) if !system.IsValid() { - return fmt.Errorf("invalid pedestal name: %s", container.Name) + return fmt.Errorf("invalid pedestal name: %s", containerModel.Name) } } - versions := make([]database.ContainerVersion, 0, len(containerData.Versions)) + versions := make([]model.ContainerVersion, 0, len(containerData.Versions)) for _, versionData := range containerData.Versions { version := versionData.ConvertToDBContainerVersion() if len(versionData.EnvVars) > 0 { - params := make([]database.ParameterConfig, 0, len(versionData.EnvVars)) + params := make([]model.ParameterConfig, 0, len(versionData.EnvVars)) for _, paramData := range versionData.EnvVars { param := paramData.ConvertToDBParameterConfig() params = append(params, *param) @@ -364,7 +373,7 @@ func initializeContainers(tx *gorm.DB, data *InitialData, userID int) error { if versionData.HelmConfig != nil { helmConfig := versionData.HelmConfig.ConvertToDBHelmConfig() if len(versionData.HelmConfig.Values) > 0 { - params := make([]database.ParameterConfig, 0, len(versionData.HelmConfig.Values)) + params := make([]model.ParameterConfig, 0, len(versionData.HelmConfig.Values)) for _, paramData := range versionData.HelmConfig.Values { param := paramData.ConvertToDBParameterConfig() params = append(params, *param) @@ -378,19 +387,17 @@ func initializeContainers(tx *gorm.DB, data *InitialData, userID int) error { versions = append(versions, *version) } - container.Versions = versions + containerModel.Versions = versions - createdContainer, err := producer.CreateContainerCore(tx, container, userID) + createdContainer, err := container.NewRepository(tx).CreateContainerCore(containerModel, userID) if err != nil { return fmt.Errorf("failed to create container %s: %w", containerData.Name, err) } if createdContainer.Type == consts.ContainerTypePedestal { - if err := producer.UploadHemlValueFileCore( - tx, + if err := container.NewRepository(tx).UploadHelmValueFileFromPath( containerData.Name, - container.Versions[0].HelmConfig, - nil, + containerModel.Versions[0].HelmConfig, filepath.Join(dataPath, fmt.Sprintf("%s.yaml", createdContainer.Name)), ); err != nil { return fmt.Errorf("failed to upload helm value file for container %s: %w", containerData.Name, err) @@ -403,15 +410,15 @@ func initializeContainers(tx *gorm.DB, data *InitialData, userID int) error { func initializeDatasets(tx *gorm.DB, data *InitialData, userID int) error { for _, datasetData := range data.Datasets { - dataset := datasetData.ConvertToDBDataset() + datasetModel := datasetData.ConvertToDBDataset() - versions := make([]database.DatasetVersion, 0, len(datasetData.Versions)) + versions := make([]model.DatasetVersion, 0, len(datasetData.Versions)) for _, versionData := range datasetData.Versions { version := versionData.ConvertToDBDatasetVersion() versions = append(versions, *version) } - _, err := producer.CreateDatasetCore(tx, dataset, versions, userID) + _, err := dataset.NewRepository(tx).CreateDatasetCore(datasetModel, versions, userID) if err != nil { return fmt.Errorf("failed to create dataset %s: %w", datasetData.Name, err) } @@ -430,7 +437,7 @@ func initializeExecutionLabels(tx *gorm.DB) error { } for _, labelInfo := range sourceLabels { - _, err := producer.CreateLabelCore(tx, &database.Label{ + _, err := label.NewRepository(tx).CreateLabelCore(tx, &model.Label{ Key: consts.ExecutionLabelSource, Value: labelInfo.value, Category: consts.ExecutionCategory, @@ -445,12 +452,12 @@ func initializeExecutionLabels(tx *gorm.DB) error { return nil } -func initializeUsers(tx *gorm.DB, data *InitialData) error { +func initializeUsers(store *bootstrapStore, data *InitialData) error { if len(data.Users) == 0 { return nil } - role, err := repository.GetRoleByName(tx, consts.RoleUser.String()) + role, err := store.getRoleByName(consts.RoleUser.String()) if err != nil { if errors.Is(err, consts.ErrNotFound) { return fmt.Errorf("user role not found, ensure system roles are initialized first") @@ -461,7 +468,7 @@ func initializeUsers(tx *gorm.DB, data *InitialData) error { for _, userData := range data.Users { user := userData.ConvertToDBUser() - if err := repository.CreateUser(tx, user); err != nil { + if err := store.createUser(user); err != nil { if errors.Is(err, consts.ErrAlreadyExists) { logrus.Warnf("User %s already exists, skipping", user.Username) continue @@ -469,7 +476,7 @@ func initializeUsers(tx *gorm.DB, data *InitialData) error { return fmt.Errorf("failed to create user %s: %w", user.Username, err) } - if err := repository.CreateUserRole(tx, &database.UserRole{ + if err := store.createUserRole(&model.UserRole{ UserID: user.ID, RoleID: role.ID, }); err != nil { @@ -480,7 +487,7 @@ func initializeUsers(tx *gorm.DB, data *InitialData) error { if len(userData.Teams) > 0 { for _, teamBinding := range userData.Teams { // Get team by name - team, err := repository.GetTeamByName(tx, teamBinding.Name) + team, err := store.getTeamByName(teamBinding.Name) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("team %s not found for user %s", teamBinding.Name, user.Username) @@ -489,7 +496,7 @@ func initializeUsers(tx *gorm.DB, data *InitialData) error { } // Get role by name for user-team binding - teamRole, err := repository.GetRoleByName(tx, teamBinding.Role) + teamRole, err := store.getRoleByName(teamBinding.Role) if err != nil { if errors.Is(err, consts.ErrNotFound) { return fmt.Errorf("role %s not found for user %s in team %s", teamBinding.Role, user.Username, teamBinding.Name) @@ -498,21 +505,19 @@ func initializeUsers(tx *gorm.DB, data *InitialData) error { } // Bind user to team with role - if err := repository.CreateUserTeam(tx, &database.UserTeam{ + if err := store.createUserTeam(&model.UserTeam{ UserID: user.ID, TeamID: team.ID, RoleID: teamRole.ID, Status: consts.CommonEnabled, }); err != nil { - if !errors.Is(err, consts.ErrAlreadyExists) { - return fmt.Errorf("failed to bind user %s to team %s with role %s: %w", user.Username, teamBinding.Name, teamBinding.Role, err) - } + return fmt.Errorf("failed to bind user %s to team %s with role %s: %w", user.Username, teamBinding.Name, teamBinding.Role, err) } // Bind projects to this team and user if specified if len(teamBinding.Projects) > 0 { for _, projectBinding := range teamBinding.Projects { - project, err := repository.GetProjectByName(tx, projectBinding.Name) + project, err := store.getProjectByName(projectBinding.Name) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("project %s not found for team %s", projectBinding.Name, teamBinding.Name) @@ -522,14 +527,14 @@ func initializeUsers(tx *gorm.DB, data *InitialData) error { // Update project's team_id to bind project to team project.TeamID = &team.ID - if err := repository.UpdateProject(tx, project); err != nil { + if err := store.saveProject(project); err != nil { return fmt.Errorf("failed to bind project %s to team %s: %w", projectBinding.Name, teamBinding.Name, err) } logrus.Infof("Bound project %s to team %s", projectBinding.Name, teamBinding.Name) // Get role for user-project binding - projectRole, err := repository.GetRoleByName(tx, projectBinding.Role) + projectRole, err := store.getRoleByName(projectBinding.Role) if err != nil { if errors.Is(err, consts.ErrNotFound) { return fmt.Errorf("role %s not found for user %s in project %s", projectBinding.Role, user.Username, projectBinding.Name) @@ -538,15 +543,13 @@ func initializeUsers(tx *gorm.DB, data *InitialData) error { } // Bind user to project with role - if err := repository.CreateUserProject(tx, &database.UserProject{ + if err := store.createUserProject(&model.UserProject{ UserID: user.ID, ProjectID: project.ID, RoleID: projectRole.ID, Status: consts.CommonEnabled, }); err != nil { - if !errors.Is(err, consts.ErrAlreadyExists) { - return fmt.Errorf("failed to bind user %s to project %s with role %s: %w", user.Username, projectBinding.Name, projectBinding.Role, err) - } + return fmt.Errorf("failed to bind user %s to project %s with role %s: %w", user.Username, projectBinding.Name, projectBinding.Role, err) } } } @@ -559,7 +562,7 @@ func initializeUsers(tx *gorm.DB, data *InitialData) error { if len(userData.Projects) > 0 { for _, projectBinding := range userData.Projects { // Get project by name - project, err := repository.GetProjectByName(tx, projectBinding.Name) + project, err := store.getProjectByName(projectBinding.Name) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("project %s not found for user %s", projectBinding.Name, user.Username) @@ -568,7 +571,7 @@ func initializeUsers(tx *gorm.DB, data *InitialData) error { } // Get role by name - projectRole, err := repository.GetRoleByName(tx, projectBinding.Role) + projectRole, err := store.getRoleByName(projectBinding.Role) if err != nil { if errors.Is(err, consts.ErrNotFound) { return fmt.Errorf("role %s not found for user %s in project %s", projectBinding.Role, user.Username, projectBinding.Name) @@ -577,15 +580,13 @@ func initializeUsers(tx *gorm.DB, data *InitialData) error { } // Bind user to project with role - if err := repository.CreateUserProject(tx, &database.UserProject{ + if err := store.createUserProject(&model.UserProject{ UserID: user.ID, ProjectID: project.ID, RoleID: projectRole.ID, Status: consts.CommonEnabled, }); err != nil { - if !errors.Is(err, consts.ErrAlreadyExists) { - return fmt.Errorf("failed to bind user %s to project %s with role %s: %w", user.Username, projectBinding.Name, projectBinding.Role, err) - } + return fmt.Errorf("failed to bind user %s to project %s with role %s: %w", user.Username, projectBinding.Name, projectBinding.Role, err) } } } diff --git a/src/service/initialization/systems.go b/src/service/initialization/systems.go index b4f03eec..30770f69 100644 --- a/src/service/initialization/systems.go +++ b/src/service/initialization/systems.go @@ -3,9 +3,9 @@ package initialization import ( "aegis/config" "aegis/consts" - "aegis/database" - "aegis/repository" + "aegis/model" "aegis/service/common" + "fmt" chaos "github.com/OperationsPAI/chaos-experiment/handler" "github.com/sirupsen/logrus" @@ -13,7 +13,7 @@ import ( ) // builtinSystems defines the 6 built-in systems that are seeded on startup. -var builtinSystems = []database.System{ +var builtinSystems = []model.System{ {Name: "train-ticket", DisplayName: "Train Ticket", NsPattern: `^ts\d+$`, ExtractPattern: `^(ts)(\d+)$`, Count: 1, IsBuiltin: true, Status: consts.CommonEnabled}, {Name: "sock-shop", DisplayName: "Sock Shop", NsPattern: `^ss\d+$`, ExtractPattern: `^(ss)(\d+)$`, Count: 1, IsBuiltin: true, Status: consts.CommonEnabled}, {Name: "social-network", DisplayName: "Social Network", NsPattern: `^sn\d+$`, ExtractPattern: `^(sn)(\d+)$`, Count: 1, IsBuiltin: true, Status: consts.CommonEnabled}, @@ -24,16 +24,16 @@ var builtinSystems = []database.System{ // InitializeSystems seeds built-in systems, registers all enabled systems with // chaos-experiment, and sets the global MetadataStore. -func InitializeSystems() { +func InitializeSystems(db *gorm.DB) error { // Set DB reference for ChaosSystemConfig to query System table - config.SetChaosConfigDB(database.DB) + config.SetChaosConfigDB(db) // Seed built-in systems using FirstOrCreate for _, sys := range builtinSystems { - var existing database.System - result := database.DB.Where("name = ?", sys.Name).First(&existing) + var existing model.System + result := db.Where("name = ?", sys.Name).First(&existing) if result.Error == gorm.ErrRecordNotFound { - if err := database.DB.Create(&sys).Error; err != nil { + if err := db.Create(&sys).Error; err != nil { logrus.Warnf("Failed to seed builtin system %s: %v", sys.Name, err) } else { logrus.Infof("Seeded builtin system: %s", sys.Name) @@ -42,10 +42,9 @@ func InitializeSystems() { } // Load all enabled systems from DB and register with chaos-experiment - systems, err := repository.ListEnabledSystems(database.DB) + systems, err := newBootstrapStore(db).listEnabledSystems() if err != nil { - logrus.Errorf("Failed to load enabled systems: %v", err) - return + return fmt.Errorf("failed to load enabled systems: %w", err) } for _, sys := range systems { @@ -61,7 +60,19 @@ func InitializeSystems() { } // Create and set the global MetadataStore - store := common.NewDBMetadataStore() + store := common.NewDBMetadataStore(db) chaos.SetMetadataStore(store) logrus.Info("Set global DBMetadataStore for chaos-experiment") + + // Force ChaosSystemConfigManager to (re)load from the System table now that + // the DB reference is wired and builtins are seeded. Without this, the + // singleton may have been initialized earlier with an empty config (when + // chaosConfigDB was still nil), leaving Get() permanently empty + // until a config-update event fires Reload. + if err := config.GetChaosSystemConfigManager().Reload(func() error { return nil }); err != nil { + logrus.Warnf("Failed to reload chaos system config: %v", err) + } else { + logrus.Infof("Chaos system config manager loaded %d systems", len(config.GetChaosSystemConfigManager().GetAll())) + } + return nil } diff --git a/src/service/initialization/types.go b/src/service/initialization/types.go index 27d13c89..4bbc7458 100644 --- a/src/service/initialization/types.go +++ b/src/service/initialization/types.go @@ -2,10 +2,8 @@ package initialization import ( "aegis/consts" - "aegis/database" - "aegis/repository" - - "github.com/sirupsen/logrus" + "aegis/model" + "gorm.io/gorm" ) const AdminUsername = "admin" @@ -24,8 +22,8 @@ type InitialDynamicConfig struct { Options string `yaml:"options"` } -func (c *InitialDynamicConfig) ConvertToDBDynamicConfig() *database.DynamicConfig { - return &database.DynamicConfig{ +func (c *InitialDynamicConfig) ConvertToDBDynamicConfig() *model.DynamicConfig { + return &model.DynamicConfig{ Key: c.Key, DefaultValue: c.DefaultValue, ValueType: c.ValueType, @@ -48,8 +46,8 @@ type InitialDataContainer struct { Versions []InitialContainerVersion `yaml:"versions"` } -func (c *InitialDataContainer) ConvertToDBContainer() *database.Container { - return &database.Container{ +func (c *InitialDataContainer) ConvertToDBContainer() *model.Container { + return &model.Container{ Type: c.Type, Name: c.Name, IsPublic: c.IsPublic, @@ -67,8 +65,8 @@ type InitialContainerVersion struct { HelmConfig *InitialHelmConfig `yaml:"helm_config"` } -func (cv *InitialContainerVersion) ConvertToDBContainerVersion() *database.ContainerVersion { - return &database.ContainerVersion{ +func (cv *InitialContainerVersion) ConvertToDBContainerVersion() *model.ContainerVersion { + return &model.ContainerVersion{ Name: cv.Name, GithubLink: cv.GithubLink, ImageRef: cv.ImageRef, @@ -85,8 +83,8 @@ type InitialHelmConfig struct { Values []InitialParameterConfig `yaml:"values"` } -func (hc *InitialHelmConfig) ConvertToDBHelmConfig() *database.HelmConfig { - return &database.HelmConfig{ +func (hc *InitialHelmConfig) ConvertToDBHelmConfig() *model.HelmConfig { + return &model.HelmConfig{ Version: hc.Version, ChartName: hc.ChartName, RepoName: hc.RepoName, @@ -105,8 +103,8 @@ type InitialParameterConfig struct { Overridable *bool `yaml:"overridable"` } -func (pc *InitialParameterConfig) ConvertToDBParameterConfig() *database.ParameterConfig { - config := &database.ParameterConfig{ +func (pc *InitialParameterConfig) ConvertToDBParameterConfig() *model.ParameterConfig { + config := &model.ParameterConfig{ Key: pc.Key, Type: pc.Type, Category: pc.Category, @@ -133,8 +131,8 @@ type InitialDatasaet struct { Versions []InitialDatasetVersion `yaml:"versions"` } -func (d *InitialDatasaet) ConvertToDBDataset() *database.Dataset { - return &database.Dataset{ +func (d *InitialDatasaet) ConvertToDBDataset() *model.Dataset { + return &model.Dataset{ Name: d.Name, Type: d.Type, Description: d.Description, @@ -148,8 +146,8 @@ type InitialDatasetVersion struct { Status consts.StatusType `yaml:"status"` } -func (dv *InitialDatasetVersion) ConvertToDBDatasetVersion() *database.DatasetVersion { - return &database.DatasetVersion{ +func (dv *InitialDatasetVersion) ConvertToDBDatasetVersion() *model.DatasetVersion { + return &model.DatasetVersion{ Name: dv.Name, Status: dv.Status, } @@ -161,8 +159,8 @@ type InitialDataProject struct { Status consts.StatusType `yaml:"status"` } -func (p *InitialDataProject) ConvertToDBProject() *database.Project { - return &database.Project{ +func (p *InitialDataProject) ConvertToDBProject() *model.Project { + return &model.Project{ Name: p.Name, Description: p.Description, Status: p.Status, @@ -176,8 +174,8 @@ type InitialDataTeam struct { Status consts.StatusType `yaml:"status"` } -func (t *InitialDataTeam) ConvertToDBTeam() *database.Team { - return &database.Team{ +func (t *InitialDataTeam) ConvertToDBTeam() *model.Team { + return &model.Team{ Name: t.Name, Description: t.Description, IsPublic: t.IsPublic, @@ -207,8 +205,8 @@ type InitialDataUser struct { Teams []InitialUserTeam `yaml:"teams"` } -func (u *InitialDataUser) ConvertToDBUser() *database.User { - return &database.User{ +func (u *InitialDataUser) ConvertToDBUser() *model.User { + return &model.User{ Username: u.Username, Email: u.Email, Password: u.Password, @@ -230,17 +228,17 @@ type InitialData struct { type configData struct { scope consts.ConfigScope - configs []database.DynamicConfig + configs []model.DynamicConfig } -func newConfigData(scope consts.ConfigScope) *configData { - configs, err := repository.ListExistingConfigs(database.DB) +func newConfigDataWithDB(db *gorm.DB, scope consts.ConfigScope) (*configData, error) { + configs, err := newBootstrapStore(db).listExistingConfigs() if err != nil { - logrus.Fatalf("Failed to check existing dynamic configs: %v", err) + return nil, err } return &configData{ scope: scope, configs: configs, - } + }, nil } diff --git a/src/service/initialization/utils.go b/src/service/initialization/utils.go index 2a1693d3..6e07309d 100644 --- a/src/service/initialization/utils.go +++ b/src/service/initialization/utils.go @@ -6,10 +6,9 @@ import ( "os" "path/filepath" - "aegis/database" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert/yaml" + "gorm.io/gorm" ) func loadInitialDataFromFile(filePath string) (*InitialData, error) { @@ -35,19 +34,19 @@ func loadInitialDataFromFile(filePath string) (*InitialData, error) { return &initialData, nil } -func withOptimizedDBSettings(fn func() error) error { - if err := database.DB.Exec("SET FOREIGN_KEY_CHECKS=0").Error; err != nil { +func withOptimizedDBSettings(db *gorm.DB, fn func() error) error { + if err := db.Exec("SET FOREIGN_KEY_CHECKS=0").Error; err != nil { logrus.Warnf("Failed to disable foreign key checks: %v", err) } - if err := database.DB.Exec("SET UNIQUE_CHECKS=0").Error; err != nil { + if err := db.Exec("SET UNIQUE_CHECKS=0").Error; err != nil { logrus.Warnf("Failed to disable unique checks: %v", err) } defer func() { - if err := database.DB.Exec("SET FOREIGN_KEY_CHECKS=1").Error; err != nil { + if err := db.Exec("SET FOREIGN_KEY_CHECKS=1").Error; err != nil { logrus.Errorf("Failed to re-enable foreign key checks: %v", err) } - if err := database.DB.Exec("SET UNIQUE_CHECKS=1").Error; err != nil { + if err := db.Exec("SET UNIQUE_CHECKS=1").Error; err != nil { logrus.Errorf("Failed to re-enable unique checks: %v", err) } }() diff --git a/src/service/logreceiver/receiver.go b/src/service/logreceiver/receiver.go index 732570f6..032299d1 100644 --- a/src/service/logreceiver/receiver.go +++ b/src/service/logreceiver/receiver.go @@ -11,7 +11,6 @@ import ( "sync/atomic" "time" - "aegis/client" "aegis/dto" "github.com/sirupsen/logrus" @@ -40,6 +39,7 @@ type OTLPLogReceiver struct { port int maxRequestSize int64 shutdownCh chan struct{} + publisher logPublisher // Metrics receivedTotal atomic.Int64 @@ -47,8 +47,12 @@ type OTLPLogReceiver struct { errorsTotal atomic.Int64 } +type logPublisher interface { + Publish(ctx context.Context, channel string, message any) error +} + // NewOTLPLogReceiver creates a new OTLP log receiver -func NewOTLPLogReceiver(port int, maxRequestSize int64) *OTLPLogReceiver { +func NewOTLPLogReceiver(port int, maxRequestSize int64, publisher logPublisher) *OTLPLogReceiver { if port == 0 { port = DefaultPort } @@ -60,6 +64,7 @@ func NewOTLPLogReceiver(port int, maxRequestSize int64) *OTLPLogReceiver { port: port, maxRequestSize: maxRequestSize, shutdownCh: make(chan struct{}), + publisher: publisher, } } @@ -84,7 +89,7 @@ func (r *OTLPLogReceiver) Start(ctx context.Context) error { case <-ctx.Done(): case <-r.shutdownCh: } - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) defer cancel() if err := r.server.Shutdown(shutdownCtx); err != nil { logrus.Errorf("OTLP log receiver shutdown error: %v", err) @@ -245,7 +250,10 @@ func (r *OTLPLogReceiver) parseJSONRequest(body []byte, exportReq *collogspb.Exp // publishLogEntry publishes a log entry to Redis Pub/Sub channel keyed by task_id func (r *OTLPLogReceiver) publishLogEntry(ctx context.Context, entry dto.LogEntry) error { channel := fmt.Sprintf("%s:%s", PubSubChannelPrefix, entry.TaskID) - return client.RedisPublish(ctx, channel, entry) + if r.publisher == nil { + return fmt.Errorf("log publisher not initialized") + } + return r.publisher.Publish(ctx, channel, entry) } // parseResourceLog parses a single ResourceLog from JSON diff --git a/src/service/producer/audit.go b/src/service/producer/audit.go deleted file mode 100644 index 74c1b764..00000000 --- a/src/service/producer/audit.go +++ /dev/null @@ -1,150 +0,0 @@ -package producer - -import ( - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "errors" - "fmt" - - "gorm.io/gorm" -) - -// GetAuditLogDetail retrieves detailed information about a specific audit log by ID -func GetAuditLogDetail(id int) (*dto.AuditLogDetailResp, error) { - log, err := repository.GetAuditLogByID(database.DB, id) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("%w: audit log with ID %d not found", consts.ErrNotFound, id) - } - return nil, fmt.Errorf("failed to get audit log: %w", err) - } - - return dto.NewAuditLogDetailResp(log), nil -} - -// ListAuditLogs retrieves audit logs with pagination and filtering -func ListAuditLogs(req *dto.ListAuditLogReq) (*dto.ListResp[dto.AuditLogResp], error) { - limit, offset := req.ToGormParams() - filterOptions := req.ToFilterOptions() - - logs, total, err := repository.ListAuditLogs(database.DB, limit, offset, filterOptions) - if err != nil { - return nil, fmt.Errorf("failed to list audit logs: %w", err) - } - - logResps := make([]dto.AuditLogResp, 0, len(logs)) - for i := range logs { - logResps = append(logResps, *dto.NewAuditLogResp(&logs[i])) - } - - resp := dto.ListResp[dto.AuditLogResp]{ - Items: logResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// LogFailedAction logs a failed action with error message -func LogFailedAction(ipAddress, userAgent, action, errorMsg string, duration, userID int, resourceName consts.ResourceName) error { - if resourceName == "" { - return fmt.Errorf("resource name cannot be empty") - } - - log := &database.AuditLog{ - IPAddress: ipAddress, - UserAgent: userAgent, - Duration: duration, - Action: action, - ErrorMsg: errorMsg, - UserID: userID, - State: consts.AuditLogStateFailed, - Status: consts.CommonEnabled, - } - - return database.DB.Transaction(func(tx *gorm.DB) error { - resource, err := repository.GetResourceByName(tx, resourceName) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("%w: resource %s not found", consts.ErrNotFound, resourceName) - } - return fmt.Errorf("failed to get resource: %w", err) - } - - log.ResourceID = resource.ID - - if err := repository.CreateAuditLog(tx, log); err != nil { - return fmt.Errorf("failed to log failed action: %w", err) - } - return nil - }) -} - -// LogSystemAction logs a system action (no user involved) -func LogSystemAction(action, details string, resourceName consts.ResourceName) error { - if resourceName == "" { - return fmt.Errorf("resource name cannot be empty") - } - - log := &database.AuditLog{ - IPAddress: "127.0.0.1", - UserAgent: "SYSTEM", - Action: action, - Details: details, - State: consts.AuditLogStateSuccess, - Status: consts.CommonEnabled, - } - - return database.DB.Transaction(func(tx *gorm.DB) error { - resource, err := repository.GetResourceByName(tx, resourceName) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("%w: resource %s not found", consts.ErrNotFound, resourceName) - } - return fmt.Errorf("failed to get resource: %w", err) - } - - log.ResourceID = resource.ID - - if err := repository.CreateAuditLog(tx, log); err != nil { - return fmt.Errorf("failed to log system action: %w", err) - } - return nil - }) -} - -// LogUserAction logs an action performed by a user -func LogUserAction(ipAddress, userAgent, action, details string, duration, userID int, resourceName consts.ResourceName) error { - if resourceName == "" { - return fmt.Errorf("resource name cannot be empty") - } - - log := &database.AuditLog{ - IPAddress: ipAddress, - UserAgent: userAgent, - Duration: duration, - Action: action, - Details: details, - UserID: userID, - State: consts.AuditLogStateSuccess, - Status: consts.CommonEnabled, - } - - return database.DB.Transaction(func(tx *gorm.DB) error { - resource, err := repository.GetResourceByName(tx, resourceName) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("%w: resource %s not found", consts.ErrNotFound, resourceName) - } - return fmt.Errorf("failed to get resource: %w", err) - } - - log.ResourceID = resource.ID - - if err := repository.CreateAuditLog(tx, log); err != nil { - return fmt.Errorf("failed to log user action: %w", err) - } - return nil - }) -} diff --git a/src/service/producer/auth.go b/src/service/producer/auth.go deleted file mode 100644 index 7aa2c681..00000000 --- a/src/service/producer/auth.go +++ /dev/null @@ -1,286 +0,0 @@ -package producer - -import ( - "context" - "errors" - "fmt" - "time" - - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "aegis/utils" - - "github.com/sirupsen/logrus" - "gorm.io/gorm" -) - -// Register handles user registration business logic -func Register(req *dto.RegisterReq) (*dto.UserInfo, error) { - if req == nil { - return nil, fmt.Errorf("register request is nil") - } - - var createdUser *database.User - - err := database.DB.Transaction(func(tx *gorm.DB) error { - // Check if user already exists - if _, err := repository.GetUserByUsername(tx, req.Username); err == nil { - return fmt.Errorf("%w: username is already taken", consts.ErrAlreadyExists) - } - - if _, err := repository.GetUserByEmail(tx, req.Email); err == nil { - return fmt.Errorf("%w: email is already registered", consts.ErrAlreadyExists) - } - - // Hash password - hashedPassword, err := utils.HashPassword(req.Password) - if err != nil { - return fmt.Errorf("password hashing failed: %w", err) - } - - user := &database.User{ - Username: req.Username, - Email: req.Email, - Password: hashedPassword, - IsActive: true, - Status: consts.CommonEnabled, - } - - if err := repository.CreateUser(tx, user); err != nil { - return fmt.Errorf("failed to create user: %w", err) - } - - createdUser = user - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewUserInfo(createdUser), nil -} - -// Login handles user authentication business logic -func Login(req *dto.LoginReq) (*dto.LoginResp, error) { - if req == nil { - return nil, fmt.Errorf("login request is nil") - } - - var loginedUser *database.User - var token string - var expiresAt time.Time - - err := database.DB.Transaction(func(tx *gorm.DB) error { - user, err := repository.GetUserByUsername(tx, req.Username) - if err != nil { - return fmt.Errorf("%w: invalid username or password", consts.ErrAuthenticationFailed) - } - - if !utils.VerifyPassword(req.Password, user.Password) { - return fmt.Errorf("%w: invalid username or password", consts.ErrAuthenticationFailed) - } - - // Generate token with user roles - token, expiresAt, err = generateTokenWithRoles(tx, user) - if err != nil { - return err - } - - if err := repository.UpdateUserLoginTime(tx, user.ID); err != nil { - logrus.Errorf("failed to update last login time for user %d: %v", user.ID, err) - } - - loginedUser = user - return nil - }) - if err != nil { - return nil, err - } - - roles, err := repository.ListRolesByUserID(database.DB, loginedUser.ID) - if err != nil { - return nil, fmt.Errorf("failed to get user role: %w", err) - } - - if len(roles) == 0 { - return nil, fmt.Errorf("%w: user has no assigned role", consts.ErrPermissionDenied) - } - - info := dto.NewUserInfo(loginedUser) - info.Role = roles[0].Name - - resp := &dto.LoginResp{ - Token: token, - ExpiresAt: expiresAt, - User: *info, - } - return resp, nil -} - -// Logout handles user logout business logic -func Logout(ctx context.Context, claims *utils.Claims) error { - metaData := map[string]any{ - "user_id": claims.UserID, - "reason": "User logout", - } - if err := repository.AddTokenToBlacklist(ctx, claims.ID, claims.ExpiresAt.Time, metaData); err != nil { - logrus.Errorf("failed to add token to blacklist: %v", err) - return fmt.Errorf("failed to blacklist token: %w", err) - } - return nil -} - -// RefreshToken handles JWT token refresh business logic -func RefreshToken(req *dto.TokenRefreshReq) (*dto.TokenRefreshResp, error) { - if req == nil { - return nil, fmt.Errorf("token refresh request is nil") - } - - // Validate refresh token and get user info - refreshClaims, err := utils.ValidateToken(req.Token) - if err != nil { - return nil, fmt.Errorf("token refresh failed: %w", err) - } - - // Fetch fresh user data from database - user, err := repository.GetUserByID(database.DB, refreshClaims.UserID) - if err != nil { - return nil, fmt.Errorf("user not found: %w", err) - } - - // Generate new access token with fresh user data - newToken, expiresAt, err := generateTokenWithRoles(database.DB, user) - if err != nil { - return nil, err - } - - response := &dto.TokenRefreshResp{ - Token: newToken, - ExpiresAt: expiresAt, - } - - return response, nil -} - -// ChangePassword handles password change business logic -func ChangePassword(req *dto.ChangePasswordReq, userID int) error { - if req == nil { - return fmt.Errorf("change password request is nil") - } - - err := database.DB.Transaction(func(tx *gorm.DB) error { - user, err := repository.GetUserByID(tx, userID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("%w: user not found", consts.ErrNotFound) - } - return fmt.Errorf("failed to get user: %w", err) - } - - if !utils.VerifyPassword(req.OldPassword, user.Password) { - return fmt.Errorf("invalid old password") - } - - hashedPassword, err := utils.HashPassword(req.NewPassword) - if err != nil { - return fmt.Errorf("password hashing failed: %w", err) - } - user.Password = hashedPassword - - if err := repository.UpdateUser(tx, user); err != nil { - return fmt.Errorf("failed to update password: %w", err) - } - - return nil - }) - - return err -} - -// GetProfile handles getting current user profile business logic -func GetProfile(userID int) (*dto.UserProfileResp, error) { - user, err := repository.GetUserByID(database.DB, userID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("%w: user not found", consts.ErrNotFound) - } - return nil, fmt.Errorf("failed to get user: %w", err) - } - - resp := dto.NewUserProfileResp(user) - userContainers, userDatasets, userProjects, err := getAllUserResourceRoles(userID) - if err != nil { - return nil, fmt.Errorf("failed to get user resource roles: %w", err) - } - - resp.ContainerRoles = userContainers - resp.DatasetRoles = userDatasets - resp.ProjectRoles = userProjects - - return resp, nil -} - -// getAllUserResourceRoles fetches all container, dataset, project roles assigned to the user -func getAllUserResourceRoles(userID int) ([]dto.UserContainerInfo, []dto.UserDatasetInfo, []dto.UserProjectInfo, error) { - userContainers, err := repository.ListUserContainersByUserID(database.DB, userID) - if err != nil { - return nil, nil, nil, fmt.Errorf("failed to list user-container roles: %w", err) - } - var containerRoles []dto.UserContainerInfo - for _, uc := range userContainers { - containerRoles = append(containerRoles, *dto.NewUserContainerInfo(&uc)) - } - - userDatasets, err := repository.ListUserDatasetsByUserID(database.DB, userID) - if err != nil { - return nil, nil, nil, fmt.Errorf("failed to list user-dataset roles: %w", err) - } - var datasetRoles []dto.UserDatasetInfo - for _, ud := range userDatasets { - datasetRoles = append(datasetRoles, *dto.NewUserDatasetInfo(&ud)) - } - - userProjects, err := repository.ListUserProjectsByUserID(database.DB, userID) - if err != nil { - return nil, nil, nil, fmt.Errorf("failed to list user-project roles: %w", err) - } - var projectRoles []dto.UserProjectInfo - for _, up := range userProjects { - projectRoles = append(projectRoles, *dto.NewUserProjectInfo(&up)) - } - - return containerRoles, datasetRoles, projectRoles, nil -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -// generateTokenWithRoles fetches user roles and generates a JWT token with role information -func generateTokenWithRoles(db *gorm.DB, user *database.User) (string, time.Time, error) { - // Get user's global roles - roles, err := repository.ListRolesByUserID(db, user.ID) - if err != nil { - return "", time.Time{}, fmt.Errorf("failed to get user roles: %w", err) - } - - // Check if user is system admin and build role names list - isAdmin := false - roleNames := make([]string, 0, len(roles)) - for _, role := range roles { - roleNames = append(roleNames, role.Name) - if role.Name == string(consts.RoleSuperAdmin) || role.Name == string(consts.RoleAdmin) { - isAdmin = true - } - } - - // Generate token with role information - token, expiresAt, err := utils.GenerateToken(user.ID, user.Username, user.Email, user.IsActive, isAdmin, roleNames) - if err != nil { - return "", time.Time{}, fmt.Errorf("failed to generate token: %w", err) - } - - return token, expiresAt, nil -} diff --git a/src/service/producer/container.go b/src/service/producer/container.go deleted file mode 100644 index 9eab90e4..00000000 --- a/src/service/producer/container.go +++ /dev/null @@ -1,899 +0,0 @@ -package producer - -import ( - "aegis/config" - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "aegis/service/common" - "aegis/utils" - "context" - "errors" - "fmt" - "mime/multipart" - "os" - "os/exec" - "path/filepath" - "time" - - "github.com/sirupsen/logrus" - "gorm.io/gorm" -) - -// ===================================================================== -// Container Service Layer -// ===================================================================== - -// CreateContainer handles the atomic creation of a new container resource, -// including its initial versions and assigning the creating user as container administrator -func CreateContainer(req *dto.CreateContainerReq, userID int) (*dto.ContainerResp, error) { - if req == nil { - return nil, fmt.Errorf("request cannot be nil") - } - - container := req.ConvertToContainer() - - var createdContainer *database.Container - err := database.DB.Transaction(func(tx *gorm.DB) error { - container, err := CreateContainerCore(tx, container, userID) - - if err != nil { - return fmt.Errorf("failed to create container: %w", err) - } - - createdContainer = container - return nil - }) - if err != nil { - return nil, fmt.Errorf("failed to create container: %w", err) - } - - return dto.NewContainerResp(createdContainer), nil -} - -// CreateContainerCore performs the core logic of creating a container within a transaction -func CreateContainerCore(tx *gorm.DB, container *database.Container, userID int) (*database.Container, error) { - role, err := repository.GetRoleByName(tx, consts.RoleContainerAdmin.String()) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: role %v not found", err, consts.RoleContainerAdmin) - } - return nil, fmt.Errorf("failed to get project owner role: %w", err) - } - - if err := repository.CreateContainer(tx, container); err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return nil, consts.ErrAlreadyExists - } - return nil, err - } - - if err := repository.CreateUserContainer(tx, &database.UserContainer{ - UserID: userID, - ContainerID: container.ID, - RoleID: role.ID, - Status: consts.CommonEnabled, - }); err != nil { - return nil, fmt.Errorf("failed to associate container with user: %w", err) - } - - if len(container.Versions) > 0 { - for i := range container.Versions { - container.Versions[i].ContainerID = container.ID - container.Versions[i].UserID = userID - } - - _, err = createContainerVersionsCore(tx, container.Versions) - if err != nil { - return nil, fmt.Errorf("failed to create container versions: %w", err) - } - } - - return container, nil -} - -// DeleteContainer deletes an existing container (Service Layer) -func DeleteContainer(containerID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - if _, err := repository.BatchDeleteContainerVersions(tx, containerID); err != nil { - return fmt.Errorf("failed to delete container versions: %w", err) - } - - if _, err := repository.RemoveUsersFromContainer(tx, containerID); err != nil { - return fmt.Errorf("failed to remove all users from container: %w", err) - } - - if err := repository.ClearContainerLabels(tx, []int{containerID}, nil); err != nil { - return fmt.Errorf("failed to clear container labels: %w", err) - } - - rows, err := repository.DeleteContainer(tx, containerID) - if err != nil { - return fmt.Errorf("failed to delete container: %w", err) - } - if rows == 0 { - return fmt.Errorf("%w: container id %d not found", consts.ErrNotFound, containerID) - } - - return nil - }) -} - -// GetContainerDetail retrieves detailed information about a specific container, -// including its versions and associated Helm configurations -func GetContainerDetail(containerID int) (*dto.ContainerDetailResp, error) { - container, err := repository.GetContainerByID(database.DB, containerID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: container id: %d", consts.ErrNotFound, containerID) - } - return nil, fmt.Errorf("failed to get container: %w", err) - } - - versions, err := repository.ListContainerVersionsByContainerID(database.DB, container.ID) - if err != nil { - return nil, fmt.Errorf("failed to get container versions: %w", err) - } - - resp := dto.NewContainerDetailResp(container) - for _, version := range versions { - resp.Versions = append(resp.Versions, *dto.NewContainerVersionResp(&version)) - } - - return resp, nil -} - -// ListContainers lists containers based on the provided filters -func ListContainers(req *dto.ListContainerReq) (*dto.ListResp[dto.ContainerResp], error) { - limit, offset := req.ToGormParams() - - containers, total, err := repository.ListContainers(database.DB, limit, offset, req.Type, req.IsPublic, req.Status) - if err != nil { - return nil, fmt.Errorf("failed to list containers: %w", err) - } - - containerIDs := make([]int, 0, len(containers)) - for _, c := range containers { - containerIDs = append(containerIDs, c.ID) - } - - labelsMap, err := repository.ListContainerLabels(database.DB, containerIDs) - if err != nil { - return nil, fmt.Errorf("failed to list container labels: %w", err) - } - - containerResps := make([]dto.ContainerResp, 0, len(containers)) - for _, container := range containers { - if labels, exists := labelsMap[container.ID]; exists { - container.Labels = labels - } - containerResps = append(containerResps, *dto.NewContainerResp(&container)) - } - - resp := dto.ListResp[dto.ContainerResp]{ - Items: containerResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// UpdateContainer updates an existing container's details -func UpdateContainer(req *dto.UpdateContainerReq, containerID int) (*dto.ContainerResp, error) { - var updatedContainer *database.Container - - err := database.DB.Transaction(func(tx *gorm.DB) error { - existingContainer, err := repository.GetContainerByID(tx, containerID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("%w: container with id %d not found", consts.ErrNotFound, containerID) - } - } - - req.PatchContainerModel(existingContainer) - - if err := repository.UpdateContainer(tx, existingContainer); err != nil { - return fmt.Errorf("failed to update container: %w", err) - } - - updatedContainer = existingContainer - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewContainerResp(updatedContainer), nil -} - -// ===================== ContainerLabel ===================== - -// ManageContainerLabels handles adding and removing labels for a container -func ManageContainerLabels(req *dto.ManageContainerLabelReq, containerID int) (*dto.ContainerResp, error) { - if req == nil { - return nil, fmt.Errorf("request cannot be nil") - } - - var managedContainer *database.Container - err := database.DB.Transaction(func(tx *gorm.DB) error { - container, err := repository.GetContainerByID(tx, containerID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: container not found", consts.ErrNotFound) - } - return err - } - - // Add labels - if len(req.AddLabels) > 0 { - labels, err := common.CreateOrUpdateLabelsFromItems(tx, req.AddLabels, consts.ContainerCategory) - if err != nil { - return fmt.Errorf("failed to create or update labels: %w", err) - } - - containerLabels := make([]database.ContainerLabel, 0, len(labels)) - for _, label := range labels { - containerLabels = append(containerLabels, database.ContainerLabel{ - ContainerID: containerID, - LabelID: label.ID, - }) - } - - if err := repository.AddContainerLabels(tx, containerLabels); err != nil { - return fmt.Errorf("failed to add container labels: %w", err) - } - } - - // Remove labels - if len(req.RemoveLabels) > 0 { - labelIDs, err := repository.ListLabelIDsByKeyAndContainerID(tx, containerID, req.RemoveLabels) - if err != nil { - return fmt.Errorf("failed to find label IDs: %w", err) - } - - if len(labelIDs) == 0 { - return nil - } - - if err := repository.ClearContainerLabels(tx, []int{containerID}, labelIDs); err != nil { - return fmt.Errorf("failed to delete container-label associations: %w", err) - } - - if err := repository.BatchDecreaseLabelUsages(tx, labelIDs, 1); err != nil { - return fmt.Errorf("failed to decrease label usage counts: %w", err) - } - } - - labels, err := repository.ListLabelsByContainerID(database.DB, container.ID) - if err != nil { - return fmt.Errorf("failed to get container labels: %w", err) - } - - container.Labels = labels - managedContainer = container - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewContainerResp(managedContainer), nil -} - -// ===================================================================== -// ContainerVersion Service Layer -// ===================================================================== - -// CreateContainerVersion creates a new version for an existing container -func CreateContainerVersion(req *dto.CreateContainerVersionReq, containerID, userID int) (*dto.ContainerVersionResp, error) { - if req == nil { - return nil, fmt.Errorf("create container version request is nil") - } - - version := req.ConvertToContainerVersion() - version.ContainerID = containerID - version.UserID = userID - - var createdVersion *database.ContainerVersion - err := database.DB.Transaction(func(tx *gorm.DB) error { - versions, err := createContainerVersionsCore(tx, []database.ContainerVersion{*version}) - if err != nil { - return fmt.Errorf("failed to create container version: %w", err) - } - - createdVersion = &versions[0] - return nil - }) - if err != nil { - return nil, fmt.Errorf("failed to create container version: %w", err) - } - - return dto.NewContainerVersionResp(createdVersion), nil -} - -// DeleteContainerVersion deletes a specific version of a container -func DeleteContainerVersion(versionID int) error { - rows, err := repository.DeleteContainer(database.DB, versionID) - if err != nil { - return fmt.Errorf("failed to delete container version: %w", err) - } - if rows == 0 { - return fmt.Errorf("%w: container version id %d not found", consts.ErrNotFound, versionID) - } - return nil -} - -// GetContainerVersionDetail retrieves detailed information about a specific container version, -// including its Helm configuration if available -func GetContainerVersionDetail(containerID, versionID int) (*dto.ContainerVersionDetailResp, error) { - _, err := repository.GetContainerByID(database.DB, containerID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: container id: %d", consts.ErrNotFound, containerID) - } - return nil, fmt.Errorf("failed to get container: %w", err) - } - - version, err := repository.GetContainerVersionByID(database.DB, versionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) - } - return nil, fmt.Errorf("failed to get container version: %w", err) - } - - resp := dto.NewContainerVersionDetailResp(version) - - helmConfig, err := repository.GetHelmConfigByContainerVersionID(database.DB, version.ID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("failed to get helm config: %w", err) - } - if helmConfig != nil { - helmConfigResp, err := dto.NewHelmConfigDetailResp(helmConfig) - if err != nil { - return nil, fmt.Errorf("failed to convert helm config: %w", err) - } - resp.HelmConfig = helmConfigResp - } - - return resp, nil -} - -// ListContainerVersions lists container versions with pagination and optional status filtering -func ListContainerVersions(req *dto.ListContainerVersionReq, containerID int) (*dto.ListResp[dto.ContainerVersionResp], error) { - limit, offset := req.ToGormParams() - - versions, total, err := repository.ListContainerVersions(database.DB, limit, offset, containerID, req.Status) - if err != nil { - return nil, fmt.Errorf("failed to list container versions: %w", err) - } - - versionResps := make([]dto.ContainerVersionResp, len(versions)) - for i, v := range versions { - versionResps[i] = *dto.NewContainerVersionResp(&v) - } - - resp := dto.ListResp[dto.ContainerVersionResp]{ - Items: versionResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// UpdateContainerVersion updates an existing container version's details -func UpdateContainerVersion(req *dto.UpdateContainerVersionReq, containerID, versionID int) (*dto.ContainerVersionResp, error) { - var updatedVersion *database.ContainerVersion - - err := database.DB.Transaction(func(tx *gorm.DB) error { - existingVersion, err := repository.GetContainerVersionByID(tx, versionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) - } - return fmt.Errorf("failed to get container: %w", err) - } - - req.PatchContainerVersionModel(existingVersion) - if err := repository.UpdateContainerVersion(tx, existingVersion); err != nil { - return fmt.Errorf("failed to update container: %w", err) - } - - updatedVersion = existingVersion - - if req.HelmConfigRequest != nil { - existingHelmConfig, err := repository.GetHelmConfigByContainerVersionID(tx, existingVersion.ID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("failed to get helm config: %w", err) - } - - if err := req.HelmConfigRequest.PatchHelmConfigModel(existingHelmConfig); err != nil { - return fmt.Errorf("failed to patch helm config model: %w", err) - } - if err := repository.UpdateHelmConfig(tx, existingHelmConfig); err != nil { - return fmt.Errorf("failed to update helm config: %w", err) - } - } - - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewContainerVersionResp(updatedVersion), nil -} - -// SetContainerVersionImage atomically rewrites the four image reference -// columns (registry, namespace, repository, tag) on a container_versions row. -func SetContainerVersionImage(req *dto.SetContainerVersionImageReq, versionID int) (*dto.SetContainerVersionImageResp, error) { - var updated *database.ContainerVersion - - err := database.DB.Transaction(func(tx *gorm.DB) error { - existing, err := repository.GetContainerVersionByID(tx, versionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) - } - return fmt.Errorf("failed to get container version: %w", err) - } - _ = existing // existence check only; update uses a targeted UPDATE below. - - rows, err := repository.UpdateContainerVersionImageColumns(tx, versionID, req.Registry, req.Namespace, req.Repository, req.Tag) - if err != nil { - return err - } - if rows == 0 { - return fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) - } - - // Reload so AfterFind recomputes ImageRef. - refreshed, err := repository.GetContainerVersionByID(tx, versionID) - if err != nil { - return fmt.Errorf("failed to reload container version: %w", err) - } - updated = refreshed - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewSetContainerVersionImageResp(updated), nil -} - -// UploadHelmChart handles uploading a Helm chart package to local storage -func UploadHelmChart(fileHeader *multipart.FileHeader, containerID, versionID, userID int) (*dto.UploadHelmChartResp, error) { - filename := fileHeader.Filename - - containerVersion, err := validateHelmConfigVersion(containerID, versionID) - if err != nil { - return nil, err - } - - // Get JuiceFS base path - jfsBasePath := config.GetString("jfs.dataset_path") - if jfsBasePath == "" { - return nil, fmt.Errorf("jfs.dataset_path is not configured") - } - - // Create directory: {jfs.dataset_path}/helm-charts - targetDir := filepath.Join(jfsBasePath, "helm-charts") - if err := os.MkdirAll(targetDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create directory: %w", err) - } - - // Generate target filename with timestamp - timestamp := time.Now().Unix() - ext := filepath.Ext(filename) - targetFilename := fmt.Sprintf("%s_chart_%d%s", containerVersion.Container.Name, timestamp, ext) - targetPath := filepath.Join(targetDir, targetFilename) - - // Save the uploaded file - if err := utils.CopyFileFromFileHeader(fileHeader, targetPath); err != nil { - return nil, fmt.Errorf("failed to save chart file: %w", err) - } - - // Calculate SHA256 checksum - checksum, err := utils.CalculateFileSHA256(targetPath) - if err != nil { - logrus.WithField("file_path", targetPath).Warnf("failed to calculate checksum: %v", err) - checksum = "" - } - - logrus.WithFields(logrus.Fields{ - "file_path": targetPath, - "checksum": checksum, - }).Info("Helm chart package uploaded successfully") - - // Update HelmConfig with local path and checksum - containerVersion.HelmConfig.LocalPath = targetPath - containerVersion.HelmConfig.Checksum = checksum - if err := repository.UpdateHelmConfig(database.DB, containerVersion.HelmConfig); err != nil { - return nil, fmt.Errorf("failed to update helm config: %w", err) - } - - return &dto.UploadHelmChartResp{ - FilePath: targetPath, - FileName: filename, - Checksum: checksum, - }, nil -} - -// UploadHelmValueFile handles uploading a Helm values file to JuiceFS storage -func UploadHelmValueFile(fileHeader *multipart.FileHeader, containerID, versionID, userID int) (*dto.UploadHelmValueFileResp, error) { - filename := fileHeader.Filename - - containerVersion, err := validateHelmConfigVersion(containerID, versionID) - if err != nil { - return nil, err - } - - if err := UploadHemlValueFileCore(database.DB, containerVersion.Container.Name, containerVersion.HelmConfig, fileHeader, ""); err != nil { - return nil, fmt.Errorf("failed to upload helm value file: %w", err) - } - - return &dto.UploadHelmValueFileResp{ - FilePath: containerVersion.HelmConfig.ValueFile, - FileName: filename, - }, nil -} - -// UploadHemlValueFileCore handles the core logic of uploading a Helm values file to JuiceFS storage -func UploadHemlValueFileCore(db *gorm.DB, containerName string, helmConfig *database.HelmConfig, srcFileHeader *multipart.FileHeader, srcFilePath string) error { - jfsBasePath := config.GetString("jfs.dataset_path") - if jfsBasePath == "" { - return fmt.Errorf("jfs.dataset_path is not configured") - } - - // Create directory structure: {jfs.dataset_path}/helm-values - targetDir := filepath.Join(jfsBasePath, "helm-values") - if err := os.MkdirAll(targetDir, 0755); err != nil { - return fmt.Errorf("failed to create directory: %w", err) - } - - timestamp := time.Now().Unix() - - var ext, targetFilename, targetPath string - if srcFileHeader != nil { - ext = filepath.Ext(srcFileHeader.Filename) - targetFilename = fmt.Sprintf("%s_values_%d%s", containerName, timestamp, ext) - targetPath = filepath.Join(targetDir, targetFilename) - - if err := utils.CopyFileFromFileHeader(srcFileHeader, targetPath); err != nil { - return fmt.Errorf("failed to save file: %w", err) - } - } - - if srcFilePath != "" { - ext = filepath.Ext(srcFilePath) - targetFilename = fmt.Sprintf("%s_values_%d%s", containerName, timestamp, ext) - targetPath = filepath.Join(targetDir, targetFilename) - - if err := utils.CopyFile(srcFilePath, targetPath); err != nil { - return fmt.Errorf("failed to save file: %w", err) - } - } - - logrus.WithFields(logrus.Fields{ - "file_path": targetPath, - }).Info("Helm values file uploaded successfully") - - helmConfig.ValueFile = targetPath - if err := repository.UpdateHelmConfig(db, helmConfig); err != nil { - return fmt.Errorf("failed to update helm config: %w", err) - } - - return nil -} - -// ===================================================================== -// Container Building Task Service Layer -// ===================================================================== - -// ProduceContainerBuildingTask produces a container building task into Redis based on the provided request -func ProduceContainerBuildingTask(ctx context.Context, req *dto.SubmitBuildContainerReq, groupID string, userID int) (*dto.SubmitContainerBuildResp, error) { - if req == nil { - return nil, fmt.Errorf("build container request is nil") - } - - sourcePath, err := processGitHubSource(req) - if err != nil { - return nil, fmt.Errorf("failed to process GitHub source: %w", err) - } - - if err := req.ValidateInfoContent(sourcePath); err != nil { - return nil, fmt.Errorf("invalid container info content: %w", err) - } - if err := req.Options.ValidateRequiredFiles(sourcePath); err != nil { - return nil, fmt.Errorf("invalid container options: %w", err) - } - - imageRef := fmt.Sprintf("%s/%s/%s:%s", config.GetString("harbor.registry"), config.GetString("harbor.namespace"), req.ImageName, req.Tag) - payload := map[string]any{ - consts.BuildImageRef: imageRef, - consts.BuildSourcePath: sourcePath, - consts.BuildBuildOptions: req.Options, - } - - task := &dto.UnifiedTask{ - Type: consts.TaskTypeBuildContainer, - Immediate: true, - Payload: payload, - GroupID: groupID, - UserID: userID, - State: consts.TaskPending, - } - task.SetGroupCtx(ctx) - - err = common.SubmitTask(ctx, task) - if err != nil { - return nil, fmt.Errorf("failed to submit container building task: %w", err) - } - - resp := &dto.SubmitContainerBuildResp{ - GroupID: task.GroupID, - TraceID: task.TraceID, - TaskID: task.TaskID, - } - return resp, nil -} - -// createContainerVersionCore performs the core logic of creating container versions within a transaction -func createContainerVersionsCore(db *gorm.DB, versions []database.ContainerVersion) ([]database.ContainerVersion, error) { - if len(versions) == 0 { - return nil, nil - } - - if err := repository.BatchCreateContainerVersions(db, versions); err != nil { - return nil, fmt.Errorf("failed to create container versions: %w", err) - } - - // Collect all envVars with their corresponding version index - type envVarWithVersionIdx struct { - envVar database.ParameterConfig - versionIdx int - envVarIdx int - } - - envVarsWithIdx := []envVarWithVersionIdx{} - for versionIdx, version := range versions { - for envVarIdx, envVar := range version.EnvVars { - envVarsWithIdx = append(envVarsWithIdx, envVarWithVersionIdx{ - envVar: envVar, - versionIdx: versionIdx, - envVarIdx: envVarIdx, - }) - } - } - - if len(envVarsWithIdx) > 0 { - // Extract envVars for batch creation/upsert - envVars := make([]database.ParameterConfig, len(envVarsWithIdx)) - for i, item := range envVarsWithIdx { - envVars[i] = item.envVar - } - - // Use OnConflict to insert or ignore existing configs - if err := repository.BatchCreateOrFindParameterConfigs(db, envVars); err != nil { - return nil, fmt.Errorf("failed to create parameter configs: %w", err) - } - - // Query back the actual IDs from database (including existing ones) - actualEnvVars, err := repository.ListParameterConfigsByKeys(db, envVars) - if err != nil { - return nil, fmt.Errorf("failed to list parameter configs: %w", err) - } - - // Build a map for quick lookup: (key, type, category) -> ID - configMap := make(map[string]int) - for _, cfg := range actualEnvVars { - key := fmt.Sprintf("%s:%d:%d", cfg.Key, cfg.Type, cfg.Category) - configMap[key] = cfg.ID - } - - // Build relations using the actual IDs from database - relations := make([]database.ContainerVersionEnvVar, 0, len(envVarsWithIdx)) - for _, item := range envVarsWithIdx { - cfg := item.envVar - key := fmt.Sprintf("%s:%d:%d", cfg.Key, cfg.Type, cfg.Category) - if paramID, exists := configMap[key]; exists { - relations = append(relations, database.ContainerVersionEnvVar{ - ContainerVersionID: versions[item.versionIdx].ID, - ParameterConfigID: paramID, - }) - } else { - return nil, fmt.Errorf("parameter config not found after creation: %s", key) - } - } - - if err := repository.AddContainerVersionEnvVars(db, relations); err != nil { - return nil, fmt.Errorf("failed to create container version env var relations: %w", err) - } - } - - var helmConfigs []*database.HelmConfig - for versionIdx := range versions { - if versions[versionIdx].HelmConfig != nil { - versions[versionIdx].HelmConfig.ContainerVersionID = versions[versionIdx].ID - helmConfigs = append(helmConfigs, versions[versionIdx].HelmConfig) - } - } - - if len(helmConfigs) == 0 { - return versions, nil - } - - if err := repository.BatchCreateHelmConfigs(db, helmConfigs); err != nil { - return nil, fmt.Errorf("failed to create helm configs: %w", err) - } - - // Collect all helm values with their corresponding helmConfig index - type helmValueWithConfigIdx struct { - value database.ParameterConfig - helmConfigIdx int - valueIdx int - } - - helmValuesWithIdx := []helmValueWithConfigIdx{} - for helmConfigIdx, helmConfig := range helmConfigs { - for valueIdx, value := range helmConfig.DynamicValues { - helmValuesWithIdx = append(helmValuesWithIdx, helmValueWithConfigIdx{ - value: value, - helmConfigIdx: helmConfigIdx, - valueIdx: valueIdx, - }) - } - } - - if len(helmValuesWithIdx) > 0 { - // Extract helm values for batch creation/upsert - helmValues := make([]database.ParameterConfig, len(helmValuesWithIdx)) - for i, item := range helmValuesWithIdx { - helmValues[i] = item.value - } - - // Use OnConflict to insert or ignore existing configs - if err := repository.BatchCreateOrFindParameterConfigs(db, helmValues); err != nil { - return nil, fmt.Errorf("failed to create helm parameter configs: %w", err) - } - - // Query back the actual IDs from database (including existing ones) - actualHelmValues, err := repository.ListParameterConfigsByKeys(db, helmValues) - if err != nil { - return nil, fmt.Errorf("failed to list helm parameter configs: %w", err) - } - - // Build a map for quick lookup: (key, type, category) -> ID - configMap := make(map[string]int) - for _, cfg := range actualHelmValues { - key := fmt.Sprintf("%s:%d:%d", cfg.Key, cfg.Type, cfg.Category) - configMap[key] = cfg.ID - } - - // Build relations using the actual IDs from database - relations := make([]database.HelmConfigValue, 0, len(helmValuesWithIdx)) - for _, item := range helmValuesWithIdx { - cfg := item.value - key := fmt.Sprintf("%s:%d:%d", cfg.Key, cfg.Type, cfg.Category) - if paramID, exists := configMap[key]; exists { - relations = append(relations, database.HelmConfigValue{ - HelmConfigID: helmConfigs[item.helmConfigIdx].ID, - ParameterConfigID: paramID, - }) - } else { - return nil, fmt.Errorf("helm parameter config not found after creation: %s", key) - } - } - - if err := repository.AddHelmConfigValues(db, relations); err != nil { - return nil, fmt.Errorf("failed to create helm config value relations: %w", err) - } - } - - return versions, nil -} - -// fetchContainersMapByIDBatch fetches containers by their IDs and returns a map of container ID to Container -func fetchContainersMapByIDBatch(db *gorm.DB, containerIDs []int) (map[int]database.Container, error) { - if len(containerIDs) == 0 { - return make(map[int]database.Container), nil - } - - containers, err := repository.ListContainersByID(db, utils.ToUniqueSlice(containerIDs)) - if err != nil { - return nil, fmt.Errorf("failed to list containers by IDs: %w", err) - } - - containerMap := make(map[int]database.Container, len(containers)) - for _, c := range containers { - containerMap[c.ID] = c - } - - return containerMap, nil -} - -// processGitHubSource processes the GitHub source for building the container -func processGitHubSource(req *dto.SubmitBuildContainerReq) (string, error) { - targetDir := filepath.Join(config.GetString("jfs.container_path"), req.ImageName, fmt.Sprintf("build_%d", time.Now().Unix())) - if err := os.MkdirAll(targetDir, 0755); err != nil { - return "", fmt.Errorf("failed to create target directory: %w", err) - } - - repoURL := fmt.Sprintf("https://github.com/%s.git", req.GithubRepository) - if req.GithubToken != "" { - repoURL = fmt.Sprintf("https://%s@github.com/%s.git", req.GithubToken, req.GithubRepository) - } - - gitCmd := []string{"git", "clone"} - if req.GithubBranch != "" { - gitCmd = append(gitCmd, repoURL, targetDir) - } else { - gitCmd = append(gitCmd, "--branch", req.GithubBranch, "--single-branch", repoURL, targetDir) - } - - if req.GithubCommit != "" { - cmd := exec.Command(gitCmd[0], gitCmd[1:]...) - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("failed to clone repository: %w", err) - } - - // Checkout specific commit - cmd = exec.Command("git", "-C", targetDir, "checkout", req.GithubCommit) - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("failed to checkout commit %s: %w", req.GithubCommit, err) - } - } else { - cmd := exec.Command(gitCmd[0], gitCmd[1:]...) - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("failed to clone repository: %w", err) - } - } - - // If a specific path is provided, copy only that subdirectory - if req.SubPath != "" { - sourcePath := filepath.Join(targetDir, req.SubPath) - if _, err := os.Stat(sourcePath); os.IsNotExist(err) { - return "", fmt.Errorf("sub path '%s' does not exist in repository", req.SubPath) - } - - newTargetDir := filepath.Join(config.GetString("jfs.container_path"), req.ImageName, fmt.Sprintf("build_final_%d", time.Now().Unix())) - if err := utils.CopyDir(sourcePath, newTargetDir); err != nil { - return "", fmt.Errorf("failed to copy subdirectory: %w", err) - } - - // Clean up the full clone - if err := os.RemoveAll(targetDir); err != nil { - logrus.WithField("target_dir", targetDir).Warnf("failed to remove temporary directory: %v", err) - } - - targetDir = newTargetDir - } - - return targetDir, nil -} - -// validateHelmConfigVersion validates that a container version exists, belongs to the specified container, -// is a pedestal type, and has an associated Helm configuration -func validateHelmConfigVersion(containerID, versionID int) (*database.ContainerVersion, error) { - containerVersion, err := repository.GetContainerVersionByID(database.DB, versionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: container version %d not found", consts.ErrNotFound, versionID) - } - return nil, fmt.Errorf("failed to get container version: %w", err) - } - - if containerVersion.ContainerID != containerID { - return nil, fmt.Errorf("version %d does not belong to container %d", versionID, containerID) - } - - if containerVersion.Container == nil || containerVersion.Container.Type != consts.ContainerTypePedestal { - return nil, fmt.Errorf("only pedestal container versions support Helm configurations") - } - - if containerVersion.HelmConfig == nil { - return nil, fmt.Errorf("container version %d does not have an associated Helm configuration", versionID) - } - - return containerVersion, nil -} diff --git a/src/service/producer/dataset.go b/src/service/producer/dataset.go deleted file mode 100644 index 10a546bc..00000000 --- a/src/service/producer/dataset.go +++ /dev/null @@ -1,636 +0,0 @@ -package producer - -import ( - "aegis/config" - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "aegis/service/common" - "aegis/utils" - "archive/zip" - "errors" - "fmt" - "io/fs" - "path/filepath" - - "gorm.io/gorm" -) - -// ===================================================================== -// Dataset Service Layer -// ===================================================================== - -// CreateDataset creates a new dataset -func CreateDataset(req *dto.CreateDatasetReq, userID int) (*dto.DatasetResp, error) { - if req == nil { - return nil, fmt.Errorf("request cannot be nil") - } - - dataset := req.ConvertToDataset() - - var version *database.DatasetVersion - if req.VersionReq != nil { - version = req.VersionReq.ConvertToDatasetVersion() - } - - var createdDataset *database.Dataset - err := database.DB.Transaction(func(tx *gorm.DB) error { - var err error - if version != nil { - dataset, err = CreateDatasetCore(tx, dataset, []database.DatasetVersion{*version}, userID) - } else { - dataset, err = CreateDatasetCore(tx, dataset, nil, userID) - } - - if err != nil { - return fmt.Errorf("failed to create dataset: %w", err) - } - - createdDataset = dataset - return nil - }) - if err != nil { - return nil, fmt.Errorf("failed to create dataset: %w", err) - } - - return dto.NewDatasetResp(createdDataset), nil -} - -// CreateDatasetCore performs the core logic of creating a dataset within a transaction -func CreateDatasetCore(tx *gorm.DB, dataset *database.Dataset, versions []database.DatasetVersion, userID int) (*database.Dataset, error) { - role, err := repository.GetRoleByName(tx, consts.RoleDatasetAdmin.String()) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: role %v not found", err, consts.RoleDatasetAdmin) - } - return nil, fmt.Errorf("failed to get dataset owner role: %w", err) - } - - if err := repository.CreateDataset(tx, dataset); err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return nil, consts.ErrAlreadyExists - } - - return nil, err - } - - if err := repository.CreateUserDataset(tx, &database.UserDataset{ - UserID: userID, - DatasetID: dataset.ID, - RoleID: role.ID, - Status: consts.CommonEnabled, - }); err != nil { - return nil, fmt.Errorf("failed to associate dataset with user: %w", err) - } - - if len(versions) > 0 { - for i := range versions { - versions[i].DatasetID = dataset.ID - versions[i].UserID = userID - } - - _, err = createDatasetVersionsCore(tx, versions) - if err != nil { - return nil, fmt.Errorf("failed to create dataset versions: %w", err) - } - } - - return dataset, nil -} - -func DeleteDataset(datasetID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - if _, err := repository.BatchDeleteDatasetVersions(tx, datasetID); err != nil { - return fmt.Errorf("failed to delete dataset versions: %w", err) - } - - if _, err := repository.RemoveUsersFromDataset(tx, datasetID); err != nil { - return fmt.Errorf("failed to remove all users from dataset: %w", err) - } - - rows, err := repository.DeleteDataset(tx, datasetID) - if err != nil { - return fmt.Errorf("failed to delete dataset: %w", err) - } - if rows == 0 { - return fmt.Errorf("%w: dataset id %d not found", consts.ErrNotFound, datasetID) - } - - return nil - }) -} - -func GetDatasetDetail(datasetID int) (*dto.DatasetDetailResp, error) { - dataset, err := repository.GetDatasetByID(database.DB, datasetID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: dataset id: %d", consts.ErrNotFound, datasetID) - } - return nil, fmt.Errorf("failed to get dataset: %w", err) - } - - versions, err := repository.ListDatasetVersionsByDatasetID(database.DB, dataset.ID) - if err != nil { - return nil, fmt.Errorf("failed to get dataset versions: %w", err) - } - - resp := dto.NewDatasetDetailResp(dataset) - - for _, version := range versions { - resp.Versions = append(resp.Versions, *dto.NewDatasetVersionResp(&version)) - } - - return dto.NewDatasetDetailResp(dataset), nil -} - -// ListDatasets lists datasets with pagination and optional filtering -func ListDatasets(req *dto.ListDatasetReq) (*dto.ListResp[dto.DatasetResp], error) { - limit, offset := req.ToGormParams() - - datasets, total, err := repository.ListDatasets(database.DB, limit, offset, req.Type, req.IsPublic, req.Status) - if err != nil { - return nil, fmt.Errorf("failed to list datasets: %w", err) - } - - datasetIDs := make([]int, 0, len(datasets)) - for _, d := range datasets { - datasetIDs = append(datasetIDs, d.ID) - } - - labelsMap, err := repository.ListDatasetLabels(database.DB, datasetIDs) - if err != nil { - return nil, fmt.Errorf("failed to list dataset labels: %w", err) - } - - datasetResps := make([]dto.DatasetResp, 0, len(datasets)) - for _, dataset := range datasets { - if labels, exists := labelsMap[dataset.ID]; exists { - dataset.Labels = labels - } - datasetResps = append(datasetResps, *dto.NewDatasetResp(&dataset)) - } - - resp := dto.ListResp[dto.DatasetResp]{ - Items: datasetResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// SearchDataset searches datasets based on the provided search request -func SearchDatasets(req *dto.SearchDatasetReq) (*dto.ListResp[dto.DatasetDetailResp], error) { - if req == nil { - return nil, fmt.Errorf("search dataset request is nil") - } - - searchReq := req.ConvertToSearchReq() - dataests, total, err := repository.ExecuteSearch(database.DB, searchReq, database.Dataset{}, consts.DatasetAllowedFields) - if err != nil { - return nil, fmt.Errorf("failed to search datasets: %w", err) - } - - datasetResps := make([]dto.DatasetDetailResp, 0, len(dataests)) - for _, dataset := range dataests { - datasetResps = append(datasetResps, *dto.NewDatasetDetailResp(&dataset)) - } - - resp := dto.ListResp[dto.DatasetDetailResp]{ - Items: datasetResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -func UpdateDataset(req *dto.UpdateDatasetReq, datasetID int) (*dto.DatasetResp, error) { - var updatedDataset *database.Dataset - - err := database.DB.Transaction(func(tx *gorm.DB) error { - existingDataset, err := repository.GetDatasetByID(tx, datasetID) - if err != nil { - return fmt.Errorf("failed to get dataset: %w", err) - } - - req.PatchDatasetModel(existingDataset) - - if err := repository.UpdateDataset(tx, existingDataset); err != nil { - return fmt.Errorf("failed to update dataset: %w", err) - } - - updatedDataset = existingDataset - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewDatasetResp(updatedDataset), nil -} - -// ===================== Dataset-Label ===================== - -func ManageDatasetLabels(req *dto.ManageDatasetLabelReq, datasetID int) (*dto.DatasetResp, error) { - if req == nil { - return nil, fmt.Errorf("manage dataset labels request is nil") - } - - var managedDataset *database.Dataset - err := database.DB.Transaction(func(tx *gorm.DB) error { - dataset, err := repository.GetDatasetByID(tx, datasetID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: dataset id: %d", consts.ErrNotFound, datasetID) - } - return fmt.Errorf("failed to get dataset: %w", err) - } - - if len(req.AddLabels) > 0 { - labels, err := common.CreateOrUpdateLabelsFromItems(tx, req.AddLabels, consts.DatasetCategory) - if err != nil { - return fmt.Errorf("failed to create or update labels: %w", err) - } - - datasetLabels := make([]database.DatasetLabel, 0, len(labels)) - for _, label := range labels { - datasetLabels = append(datasetLabels, database.DatasetLabel{ - DatasetID: datasetID, - LabelID: label.ID, - }) - } - - if err := repository.AddDatasetLabels(tx, datasetLabels); err != nil { - return fmt.Errorf("failed to add dataset labels: %w", err) - } - } - - if len(req.RemoveLabels) > 0 { - labelIDs, err := repository.ListLabelIDsByKeyAndDatasetID(tx, datasetID, req.RemoveLabels) - if err != nil { - return fmt.Errorf("failed to find label ids by keys: %w", err) - } - - if len(labelIDs) > 0 { - if err := repository.ClearDatasetLabels(tx, []int{datasetID}, labelIDs); err != nil { - return fmt.Errorf("failed to clear dataset labels: %w", err) - } - - if err := repository.BatchDecreaseLabelUsages(tx, labelIDs, 1); err != nil { - return fmt.Errorf("failed to decrease label usage counts: %w", err) - } - } - } - - labels, err := repository.ListLabelsByDatasetID(database.DB, dataset.ID) - if err != nil { - return fmt.Errorf("failed to get dataset labels: %w", err) - } - - dataset.Labels = labels - managedDataset = dataset - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewDatasetResp(managedDataset), nil -} - -// ===================================================================== -// DatasetVersion Service Layer -// ===================================================================== - -func CreateDatasetVersion(req *dto.CreateDatasetVersionReq, datasetID, userID int) (*dto.DatasetVersionResp, error) { - if req == nil { - return nil, fmt.Errorf("create dataset version request is nil") - } - - version := req.ConvertToDatasetVersion() - version.DatasetID = datasetID - version.UserID = userID - - var createdVersion *database.DatasetVersion - err := database.DB.Transaction(func(tx *gorm.DB) error { - versions, err := createDatasetVersionsCore(tx, []database.DatasetVersion{*version}) - if err != nil { - return fmt.Errorf("failed to create dataset version: %w", err) - } - - version := versions[0] - if len(req.Datapacks) > 0 { - if err := linkDatapacksToDatasetVersion(tx, version.ID, req.Datapacks); err != nil { - return fmt.Errorf("failed to link datapacks to dataset version: %w", err) - } - } - - createdVersion = &version - return nil - }) - if err != nil { - return nil, fmt.Errorf("failed to create dataset version: %w", err) - } - - return dto.NewDatasetVersionResp(createdVersion), nil -} - -// DeleteDatasetVersion deletes a specific version of a dataset -func DeleteDatasetVersion(versionID int) error { - rows, err := repository.DeleteDatasetVersion(database.DB, versionID) - if err != nil { - return fmt.Errorf("failed to delete dataset version: %w", err) - } - if rows == 0 { - return fmt.Errorf("%w: dataset version id %d not found", consts.ErrNotFound, versionID) - } - return nil -} - -// GetDatasetVersionDetail retrieves the details of a specific dataset version by its ID -func GetDatasetVersionDetail(datasetID, versionID int) (*dto.DatasetVersionDetailResp, error) { - _, err := repository.GetDatasetByID(database.DB, datasetID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: dataset id: %d", consts.ErrNotFound, datasetID) - } - return nil, fmt.Errorf("failed to get dataset: %w", err) - } - - version, err := repository.GetDatasetVersionByID(database.DB, versionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) - } - return nil, fmt.Errorf("failed to get dataset version: %w", err) - } - - return dto.NewDatasetVersionDetailResp(version), nil -} - -// ListDatasetVersions lists dataset versions with pagination and optional status filtering -func ListDatasetVersions(req *dto.ListDatasetVersionReq, datasetID int) (*dto.ListResp[dto.DatasetVersionResp], error) { - limit, offset := req.ToGormParams() - - versions, total, err := repository.ListDatasetVersions(database.DB, limit, offset, datasetID, req.Status) - if err != nil { - return nil, fmt.Errorf("failed to list dataset versions: %w", err) - } - - versionResps := make([]dto.DatasetVersionResp, 0, len(versions)) - for _, version := range versions { - versionResps = append(versionResps, *dto.NewDatasetVersionResp(&version)) - } - - resp := dto.ListResp[dto.DatasetVersionResp]{ - Items: versionResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// UpdateDatasetVersion updates the details of a specific dataset version -func UpdateDatasetVersion(req *dto.UpdateDatasetVersionReq, datasetID, versionID int) (*dto.DatasetVersionResp, error) { - var updatedVersion *database.DatasetVersion - - err := database.DB.Transaction(func(tx *gorm.DB) error { - version, err := repository.GetDatasetVersionByID(tx, versionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) - } - return fmt.Errorf("failed to get dataset version: %w", err) - } - - req.PatchDatasetVersionModel(version) - - if err := repository.UpdateDatasetVersion(tx, version); err != nil { - return fmt.Errorf("failed to update dataset version: %w", err) - } - - updatedVersion = version - return nil - }) - if err != nil { - return nil, fmt.Errorf("failed to update dataset version: %w", err) - } - - return dto.NewDatasetVersionResp(updatedVersion), nil -} - -// GetDatasetVersionFilename generates a filename for the dataset version download -func GetDatasetVersionFilename(datasetID, versionID int) (string, error) { - dataset, err := repository.GetDatasetByID(database.DB, datasetID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return "", fmt.Errorf("%w: dataset id: %d", consts.ErrNotFound, datasetID) - } - return "", fmt.Errorf("failed to get dataset: %w", err) - } - - version, err := repository.GetDatasetVersionByID(database.DB, versionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return "", fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) - } - return "", fmt.Errorf("failed to get dataset version: %w", err) - } - - return fmt.Sprintf("%s-%s", dataset.Name, version.Name), nil -} - -// DownloadDatasetVersion handles the downloading of a specific dataset version -func DownloadDatasetVersion(zipWriter *zip.Writer, excludeRules []utils.ExculdeRule, versionID int) error { - if zipWriter == nil { - return fmt.Errorf("zip writer cannot be nil") - } - - datapacks, err := repository.ListInjectionsByDatasetVersionID(database.DB, versionID, false) - if err != nil { - return fmt.Errorf("failed to list datapacks for dataset version: %w", err) - } - - if err := packageDatasetVersionToZip(zipWriter, datapacks, excludeRules); err != nil { - return fmt.Errorf("failed to package dataset to zip: %w", err) - } - - return nil -} - -// ===================== DatasetVersion-Injection ===================== - -func ManageDatasetVersionInjections(req *dto.ManageDatasetVersionInjectionReq, versionID int) (*dto.DatasetVersionDetailResp, error) { - if req == nil { - return nil, fmt.Errorf("manage dataset version injections request is nil") - } - - var managedVersion *database.DatasetVersion - err := database.DB.Transaction(func(tx *gorm.DB) error { - version, err := repository.GetDatasetVersionByID(tx, versionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: dataset version id: %d", consts.ErrNotFound, versionID) - } - return fmt.Errorf("failed to get dataset version: %w", err) - } - - if len(req.AddDatapacks) > 0 { - if err := linkDatapacksToDatasetVersion(tx, versionID, req.AddDatapacks); err != nil { - return fmt.Errorf("failed to link datapacks to dataset version: %w", err) - } - } - - if len(req.RemoveDatapacks) > 0 { - injectionIDMap, err := repository.ListInjectionIDsByNames(tx, req.AddDatapacks) - if err != nil { - return fmt.Errorf("failed to list injections by names: %w", err) - } - - if len(injectionIDMap) != len(req.RemoveDatapacks) { - return fmt.Errorf("some datapacks to remove were not found") - } - - injectionIDs := make([]int, 0, len(req.RemoveDatapacks)) - for _, datapack := range req.RemoveDatapacks { - injectionID, exists := injectionIDMap[datapack] - if !exists { - return fmt.Errorf("injection not found: %s", datapack) - } - injectionIDs = append(injectionIDs, injectionID) - } - - if err := repository.ClearDatasetVersionInjections(tx, []int{version.ID}, injectionIDs); err != nil { - return fmt.Errorf("failed to remove dataset version datapacks: %w", err) - } - } - - datapacks, err := repository.ListInjectionsByDatasetVersionID(tx, version.ID, false) - if err != nil { - return fmt.Errorf("failed to list datapacks for dataset version: %w", err) - } - - version.Datapacks = datapacks - version.FileCount = version.FileCount + len(req.AddDatapacks) - len(req.RemoveDatapacks) - if err := repository.UpdateDatasetVersion(tx, version); err != nil { - return fmt.Errorf("failed to update dataset version file count: %w", err) - } - - managedVersion = version - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewDatasetVersionDetailResp(managedVersion), nil -} - -// createDatasetVersionCore performs the core logic of creating dataset versions within a transaction -func createDatasetVersionsCore(db *gorm.DB, versions []database.DatasetVersion) ([]database.DatasetVersion, error) { - if len(versions) == 0 { - return nil, nil - } - - if err := repository.BatchCreateDatasetVersions(db, versions); err != nil { - return nil, fmt.Errorf("failed to create dataset versions: %w", err) - } - - return versions, nil -} - -// fetchDatasetsMapByIDBatch fetches datasets by their IDs and returns a map of dataset ID to Dataset -func fetchDatasetsMapByIDBatch(db *gorm.DB, datasetIDs []int) (map[int]database.Dataset, error) { - if len(datasetIDs) == 0 { - return make(map[int]database.Dataset), nil - } - - datasets, err := repository.ListDatasetsByID(db, utils.ToUniqueSlice(datasetIDs)) - if err != nil { - return nil, fmt.Errorf("failed to list datasets by IDs: %w", err) - } - - datasetMap := make(map[int]database.Dataset, len(datasets)) - for _, d := range datasets { - datasetMap[d.ID] = d - } - - return datasetMap, nil -} - -// linkDatapacksToDatasetVersion links the specified datapacks to the given dataset version -func linkDatapacksToDatasetVersion(db *gorm.DB, versionID int, datapacks []string) error { - injectionIDMap, err := repository.ListInjectionIDsByNames(db, datapacks) - if err != nil { - return fmt.Errorf("failed to list injections by names: %w", err) - } - - datasetVersionInjections := make([]database.DatasetVersionInjection, 0, len(datapacks)) - for _, datapack := range datapacks { - injectionID, exists := injectionIDMap[datapack] - if !exists { - return fmt.Errorf("injection not found: %s", datapack) - } - datasetVersionInjections = append(datasetVersionInjections, database.DatasetVersionInjection{ - DatasetVersionID: versionID, - InjectionID: injectionID, - }) - } - - if err := repository.AddDatasetVersionInjections(db, datasetVersionInjections); err != nil { - return fmt.Errorf("failed to add dataset version injections: %w", err) - } - - return nil -} - -// packageDatasetVersionToZip packages the specified datapacks into a zip archive, applying exclusion rules -func packageDatasetVersionToZip(zipWriter *zip.Writer, datapacks []database.FaultInjection, excludeRules []utils.ExculdeRule) error { - for _, datapack := range datapacks { - if err := packageDatapackToZip(zipWriter, &datapack, excludeRules); err != nil { - return err - } - } - return nil -} - -// packageDatapackToZip packages a single datapack into a zip archive, applying exclusion rules -func packageDatapackToZip(zipWriter *zip.Writer, datapack *database.FaultInjection, excludeRules []utils.ExculdeRule) error { - if datapack.State < consts.DatapackBuildSuccess { - return fmt.Errorf("datapack %s is not in a downloadable state", datapack.Name) - } - - workDir := filepath.Join(config.GetString("jfs.dataset_path"), datapack.Name) - if !utils.IsAllowedPath(workDir) { - return fmt.Errorf("invalid path access to %s", workDir) - } - - err := filepath.WalkDir(workDir, func(path string, dir fs.DirEntry, err error) error { - if err != nil || dir.IsDir() { - return err - } - - relPath, _ := filepath.Rel(workDir, path) - fullRelPath := filepath.Join(consts.DownloadFilename, filepath.Base(workDir), relPath) - fileName := filepath.Base(path) - - // Apply exclusion rules - for _, rule := range excludeRules { - if utils.MatchFile(fileName, rule) { - return nil - } - } - - // Get file info to read modification time - fileInfo, err := dir.Info() - if err != nil { - return err - } - - // Convert path separators to "/" - zipPath := filepath.ToSlash(fullRelPath) - return utils.AddToZip(zipWriter, fileInfo, path, zipPath) - }) - if err != nil { - return fmt.Errorf("failed to package datapack %s: %w", datapack.Name, err) - } - - return nil -} diff --git a/src/service/producer/dynamic_config.go b/src/service/producer/dynamic_config.go deleted file mode 100644 index 50452d34..00000000 --- a/src/service/producer/dynamic_config.go +++ /dev/null @@ -1,544 +0,0 @@ -package producer - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - "aegis/client" - "aegis/config" - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "aegis/service/common" - "aegis/utils" - - "github.com/sirupsen/logrus" - "gorm.io/gorm" -) - -// ===================================================================== -// Private Types (migrated from common for producer-only usage) -// ===================================================================== - -// configUpdateContext holds context information for a configuration update -type configUpdateContext struct { - ChangeField consts.ConfigHistoryChangeField - OldValue string - NewValue string - Reason string - OperatorID int - IpAddress string - UserAgent string -} - -// configHistoryParams encapsulates parameters for creating config history entries -type configHistoryParams struct { - ConfigID int - ChangeType consts.ConfigHistoryChangeType - RollbackFromID *int - - ConfigUpdateContext configUpdateContext -} - -// ===================================================================== -// Configuration Service Layer -// ===================================================================== - -// etcdPrefixForScope returns the etcd key prefix for the given config scope. -func etcdPrefixForScope(scope consts.ConfigScope) string { - switch scope { - case consts.ConfigScopeProducer: - return consts.ConfigEtcdProducerPrefix - case consts.ConfigScopeConsumer: - return consts.ConfigEtcdConsumerPrefix - case consts.ConfigScopeGlobal: - return consts.ConfigEtcdGlobalPrefix - } - return "" -} - -// GetConfigDetail retrieves detailed information about a configuration by its key -func GetConfigDetail(containerID int) (*dto.ConfigDetailResp, error) { - config, err := repository.GetConfigByID(database.DB, containerID, true) - if err != nil { - return nil, fmt.Errorf("failed to get config detail: %w", err) - } - - histories, err := repository.ListConfigHistoriesByConfigID(database.DB, config.ID) - if err != nil { - return nil, fmt.Errorf("failed to get config histories: %w", err) - } - - resp := dto.NewConfigDetailResp(config) - for _, history := range histories { - resp.Histories = append(resp.Histories, *dto.NewConfigHistoryResp(&history)) - } - - return resp, nil -} - -// ListConfigs lists configurations based on the provided filters -func ListConfigs(req *dto.ListConfigReq) (*dto.ListResp[dto.ConfigResp], error) { - limit, offset := req.ToGormParams() - - configs, total, err := repository.ListConfigs(database.DB, limit, offset, req.ValueType, req.Category, req.IsSecret, req.UpdatedBy) - if err != nil { - return nil, fmt.Errorf("failed to list configs: %w", err) - } - - configResps := make([]dto.ConfigResp, 0, len(configs)) - for _, config := range configs { - configResps = append(configResps, *dto.NewConfigResp(&config)) - } - - resp := dto.ListResp[dto.ConfigResp]{ - Items: configResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// RollbackConfigValue rolls back a configuration value from history -func RollbackConfigValue(ctx context.Context, req *dto.RollbackConfigReq, configID, operatorID int, ipAddress, userAgent string) error { - // Get the history entry to rollback to - history, err := repository.GetConfigHistory(database.DB, req.HistoryID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("%w: history entry with id %d not found", consts.ErrNotFound, req.HistoryID) - } - return fmt.Errorf("failed to get config history: %w", err) - } - - // Validate this is a value change history - if history.ChangeField != consts.ChangeFieldValue { - return fmt.Errorf("history entry %d is not a value change (field: %v)", req.HistoryID, history.ChangeField) - } - - // Get existing config - existingConfig, err := repository.GetConfigByID(database.DB, configID, false) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("%w: configuration with id %d not found", consts.ErrNotFound, configID) - } - return fmt.Errorf("failed to get config: %w", err) - } - - oldValue, err := client.EtcdGet(ctx, fmt.Sprintf("%s%s", etcdPrefixForScope(existingConfig.Scope), existingConfig.Key)) - if err != nil { - return fmt.Errorf("failed to get current config value from etcd: %w", err) - } - - newValue := history.OldValue - - if err := common.ValidateConfig(existingConfig, newValue); err != nil { - return fmt.Errorf("invalid config after rollback: %w", err) - } - - if err := setViperIfNeeded(existingConfig, newValue); err != nil { - return fmt.Errorf("failed to set config value in viper: %w", err) - } - - if _, err := createConfigRollback(existingConfig, utils.IntPtr(history.ID), configUpdateContext{ - ChangeField: consts.ChangeFieldValue, - OldValue: oldValue, - NewValue: newValue, - Reason: req.Reason, - OperatorID: operatorID, - IpAddress: ipAddress, - UserAgent: userAgent, - }); err != nil { - return err - } - - return propagateValueChange(ctx, existingConfig, newValue, "rollback") -} - -// RollbackConfigMetadata rolls back a configuration metadata field from history -func RollbackConfigMetadata(req *dto.RollbackConfigReq, configID, operatorID int, ipAddress, userAgent string) (*dto.ConfigResp, error) { - // Get the history entry to rollback to - history, err := repository.GetConfigHistory(database.DB, req.HistoryID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("%w: history entry with id %d not found", consts.ErrNotFound, req.HistoryID) - } - return nil, fmt.Errorf("failed to get config history: %w", err) - } - - // Validate this is a metadata change history - if history.ChangeField == consts.ChangeFieldValue { - return nil, fmt.Errorf("history entry %d is a value change, use RollbackConfigValue instead", req.HistoryID) - } - - // Get existing config - existingConfig, err := repository.GetConfigByID(database.DB, configID, false) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("%w: configuration with id %d not found", consts.ErrNotFound, configID) - } - return nil, fmt.Errorf("failed to get config: %w", err) - } - - // Rollback the metadata field - oldValue, newValue, err := rollbackMetaFieldValue(existingConfig, history.ChangeField, history.OldValue) - if err != nil { - return nil, fmt.Errorf("failed to rollback metadata field: %w", err) - } - - // Validate the configuration after metadata rollback - if err := common.ValidateConfigMetadataConstraints(existingConfig); err != nil { - return nil, fmt.Errorf("invalid config after metadata rollback: %w", err) - } - - // Save to database with rollback history - updatedConfig, err := createConfigRollback(existingConfig, utils.IntPtr(history.ID), configUpdateContext{ - ChangeField: history.ChangeField, - OldValue: oldValue, - NewValue: newValue, - Reason: req.Reason, - OperatorID: operatorID, - IpAddress: ipAddress, - UserAgent: userAgent, - }) - if err != nil { - return nil, err - } - - return dto.NewConfigResp(updatedConfig), nil -} - -// UpdateConfigValue updates the value of a configuration and handles propagation based on its scope -func UpdateConfigValue(ctx context.Context, req *dto.UpdateConfigValueReq, configID, operatorID int, ipAddress, userAgent string) error { - existingConfig, err := repository.GetConfigByID(database.DB, configID, false) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("%w: configuration with id %d not found", consts.ErrNotFound, configID) - } - } - - oldValue, err := client.EtcdGet(ctx, fmt.Sprintf("%s%s", etcdPrefixForScope(existingConfig.Scope), existingConfig.Key)) - if err != nil { - return fmt.Errorf("failed to get current config value from etcd: %w", err) - } - - newValue := req.Value - - if err := common.ValidateConfig(existingConfig, newValue); err != nil { - return fmt.Errorf("invalid config value: %w", err) - } - - if err := setViperIfNeeded(existingConfig, newValue); err != nil { - return fmt.Errorf("failed to set config value in viper: %w", err) - } - - if err := createConfigHistory(database.DB, configHistoryParams{ - ConfigID: existingConfig.ID, - ChangeType: consts.ChangeTypeUpdate, - ConfigUpdateContext: configUpdateContext{ - ChangeField: consts.ChangeFieldValue, - OldValue: oldValue, - NewValue: newValue, - Reason: req.Reason, - OperatorID: operatorID, - IpAddress: ipAddress, - UserAgent: userAgent, - }, - }); err != nil { - return fmt.Errorf("failed to create config history: %w", err) - } - - return propagateValueChange(ctx, existingConfig, newValue, "update") -} - -// UpdateConfigMetadata updates the metadata of a configuration -func UpdateConfigMetadata(req *dto.UpdateConfigMetadataReq, configID, operatorID int, ipAddress, userAgent string) (*dto.ConfigResp, error) { - existingConfig, err := repository.GetConfigByID(database.DB, configID, false) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("%w: configuration with id %d not found", consts.ErrNotFound, configID) - } - } - - oldValue, newValue := req.PatchConfigModel(existingConfig) - - // Validate the configuration after metadata update - if err := common.ValidateConfigMetadataConstraints(existingConfig); err != nil { - return nil, fmt.Errorf("invalid config after metadata update: %w", err) - } - - var updatedConfig *database.DynamicConfig - err = database.DB.Transaction(func(tx *gorm.DB) error { - existingConfig.UpdatedBy = utils.IntPtr(operatorID) - - if err := repository.UpdateConfig(tx, existingConfig); err != nil { - return fmt.Errorf("failed to update config: %w", err) - } - - updatedConfig = existingConfig - - if err := createConfigHistory(tx, configHistoryParams{ - ConfigID: updatedConfig.ID, - ChangeType: consts.ChangeTypeUpdate, - ConfigUpdateContext: configUpdateContext{ - ChangeField: req.GetChangeField(), - OldValue: oldValue, - NewValue: newValue, - Reason: req.Reason, - OperatorID: operatorID, - IpAddress: ipAddress, - UserAgent: userAgent, - }, - }); err != nil { - return fmt.Errorf("failed to create config history: %w", err) - } - - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewConfigResp(updatedConfig), nil -} - -// ===================== ConfigHistory ===================== - -func ListConfigHistories(req *dto.ListConfigHistoryReq, configID int) (*dto.ListResp[dto.ConfigHistoryResp], error) { - limit, offset := req.ToGormParams() - - histories, total, err := repository.ListConfigHistories(database.DB, limit, offset, configID, req.ChangeType, req.OperatorID) - if err != nil { - return nil, fmt.Errorf("failed to list config histories: %w", err) - } - - historyResps := make([]dto.ConfigHistoryResp, 0, len(histories)) - for _, history := range histories { - historyResps = append(historyResps, *dto.NewConfigHistoryResp(&history)) - } - - resp := dto.ListResp[dto.ConfigHistoryResp]{ - Items: historyResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// ===================== Helper Functions ===================== - -// createConfigHistory creates a ConfigHistory entry from a config update (private version) -func createConfigHistory(db *gorm.DB, params configHistoryParams) error { - entry := &database.ConfigHistory{ - ChangeType: params.ChangeType, - OldValue: params.ConfigUpdateContext.OldValue, - NewValue: params.ConfigUpdateContext.NewValue, - Reason: params.ConfigUpdateContext.Reason, - ConfigID: params.ConfigID, - OperatorID: utils.IntPtr(params.ConfigUpdateContext.OperatorID), - IPAddress: params.ConfigUpdateContext.IpAddress, - UserAgent: params.ConfigUpdateContext.UserAgent, - RolledBackFromID: params.RollbackFromID, - ChangeField: params.ConfigUpdateContext.ChangeField, - } - if err := repository.CreateConfigHistory(db, entry); err != nil { - return fmt.Errorf("failed to create config history: %w", err) - } - return nil -} - -// createConfigRollback updates config and creates a rollback history entry -// This wraps the common history creation logic but with rollback-specific parameters -func createConfigRollback(config *database.DynamicConfig, historyID *int, updateContext configUpdateContext) (*database.DynamicConfig, error) { - var updatedConfig *database.DynamicConfig - - err := database.DB.Transaction(func(tx *gorm.DB) error { - // Update the config in database - if err := repository.UpdateConfig(tx, config); err != nil { - return fmt.Errorf("failed to update config: %w", err) - } - - updatedConfig = config - - // Create rollback history entry using common function - if err := createConfigHistory(tx, configHistoryParams{ - ConfigID: config.ID, - ChangeType: consts.ChangeTypeRollback, - ConfigUpdateContext: updateContext, - RollbackFromID: historyID, - }); err != nil { - return fmt.Errorf("failed to create rollback history: %w", err) - } - - return nil - }) - if err != nil { - return nil, err - } - - return updatedConfig, nil -} - -// rollbackMetaFieldValue rolls back a specific field in the config based on the change field type -// Returns the old value (before rollback) and new value (after rollback) -func rollbackMetaFieldValue(config *database.DynamicConfig, changeField consts.ConfigHistoryChangeField, targetValue string) (oldValue string, newValue string, err error) { - newValue = targetValue - - switch changeField { - case consts.ChangeFieldDefaultValue: - oldValue = config.DefaultValue - config.DefaultValue = newValue - - case consts.ChangeFieldDescription: - oldValue = config.Description - config.Description = newValue - - case consts.ChangeFieldMinValue: - if config.MinValue != nil { - oldValue = fmt.Sprintf("%f", *config.MinValue) - } - if newValue == "" { - config.MinValue = nil - } else { - var minVal float64 - if _, err := fmt.Sscanf(newValue, "%f", &minVal); err != nil { - return "", "", fmt.Errorf("failed to parse min value: %w", err) - } - config.MinValue = &minVal - } - - case consts.ChangeFieldMaxValue: - if config.MaxValue != nil { - oldValue = fmt.Sprintf("%f", *config.MaxValue) - } - if newValue == "" { - config.MaxValue = nil - } else { - var maxVal float64 - if _, err := fmt.Sscanf(newValue, "%f", &maxVal); err != nil { - return "", "", fmt.Errorf("failed to parse max value: %w", err) - } - config.MaxValue = &maxVal - } - - case consts.ChangeFieldPattern: - oldValue = config.Pattern - config.Pattern = newValue - - case consts.ChangeFieldOptions: - oldValue = config.Options - config.Options = newValue - - default: - return "", "", fmt.Errorf("unknown change field: %d", changeField) - } - - return oldValue, newValue, nil -} - -// setViperIfNeeded updates the local Viper cache for scopes that need immediate local reflection -// (producer and global). Consumer configs live only in etcd and are applied remotely. -func setViperIfNeeded(cfg *database.DynamicConfig, newValue string) error { - if cfg.Scope == consts.ConfigScopeConsumer { - return nil - } - return config.SetViperValue(cfg.Key, newValue, cfg.ValueType) -} - -// propagateValueChange publishes the new value to etcd and, for consumer scope, waits for ack. -// Producer scope requires no network propagation, so this is a no-op for that scope. -func propagateValueChange(ctx context.Context, cfg *database.DynamicConfig, newValue, opDesc string) error { - if cfg.Scope != consts.ConfigScopeGlobal && cfg.Scope != consts.ConfigScopeConsumer { - return nil - } - - etcdKey := fmt.Sprintf("%s%s", etcdPrefixForScope(cfg.Scope), cfg.Key) - if err := publishConfigToEtcdWithRetry(etcdKey, newValue, 3); err != nil { - return fmt.Errorf("config saved to database but failed to publish to etcd: %w", err) - } - - if cfg.Scope == consts.ConfigScopeConsumer { - logrus.Infof("Waiting for consumer config %s response...", opDesc) - resp, err := waitForConfigUpdateResponse(10 * time.Second) - if err != nil { - return fmt.Errorf("config %s but consumer did not respond: %w", opDesc, err) - } - if !resp.Success { - return fmt.Errorf("consumer failed to process config %s: %s", opDesc, resp.Error) - } - logrus.Infof("Config %s successfully processed by consumer", opDesc) - } - - return nil -} - -// publishConfigToEtcdWithRetry publishes configuration to etcd with exponential backoff retry -func publishConfigToEtcdWithRetry(key, value string, maxRetries int) error { - var lastErr error - baseDelay := 500 * time.Millisecond - - for attempt := range maxRetries { - if attempt > 0 { - delay := baseDelay * time.Duration(1< 0 { - logrus.Infof("Successfully published config to etcd after %d retries", attempt) - } - return nil - } - - lastErr = err - logrus.Warnf("Failed to publish config to etcd (attempt %d/%d): %v", attempt+1, maxRetries, err) - } - - return fmt.Errorf("failed to publish config to etcd after %d attempts: %w", maxRetries, lastErr) -} - -// waitForConfigUpdateResponse uses Redis Pub/Sub to synchronously wait for a response to a configuration update with timeout -func waitForConfigUpdateResponse(timeout time.Duration) (*dto.ConfigUpdateResponse, error) { - redisClient := client.GetRedisClient() - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - pubsub := redisClient.Subscribe(ctx, consts.ConfigUpdateResponseChannel) - defer func() { _ = pubsub.Close() }() - - if _, err := pubsub.Receive(ctx); err != nil { - return nil, fmt.Errorf("failed to confirm subscription: %w", err) - } - - msgChan := pubsub.Channel() - for { - select { - case <-ctx.Done(): - return nil, fmt.Errorf("timeout waiting for config update response after %v", timeout) - - case msg, ok := <-msgChan: - if !ok { - return nil, fmt.Errorf("subscription channel closed unexpectedly") - } - - var response dto.ConfigUpdateResponse - if err := json.Unmarshal([]byte(msg.Payload), &response); err != nil { - logrus.Warnf("failed to parse response message: %v", err) - continue - } - - logrus.WithFields(logrus.Fields{ - "response_id": response.ID, - "success": response.Success, - }).Info("Received matching config update response") - return &response, nil - } - } -} diff --git a/src/service/producer/evaluation.go b/src/service/producer/evaluation.go deleted file mode 100644 index 9b366e83..00000000 --- a/src/service/producer/evaluation.go +++ /dev/null @@ -1,44 +0,0 @@ -package producer - -import ( - "aegis/database" - "aegis/dto" - "aegis/repository" - "fmt" -) - -// ListEvaluations lists evaluations with pagination -func ListEvaluations(req *dto.ListEvaluationReq) (*dto.ListResp[dto.EvaluationResp], error) { - limit, offset := req.ToGormParams() - - evaluations, total, err := repository.ListEvaluations(database.DB, limit, offset) - if err != nil { - return nil, fmt.Errorf("failed to list evaluations: %w", err) - } - - evalResps := make([]dto.EvaluationResp, 0, len(evaluations)) - for _, eval := range evaluations { - evalResps = append(evalResps, *dto.NewEvaluationResp(&eval)) - } - - resp := dto.ListResp[dto.EvaluationResp]{ - Items: evalResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// GetEvaluation retrieves a single evaluation by ID -func GetEvaluation(id int) (*dto.EvaluationResp, error) { - eval, err := repository.GetEvaluationByID(database.DB, id) - if err != nil { - return nil, err - } - - return dto.NewEvaluationResp(eval), nil -} - -// DeleteEvaluation soft-deletes an evaluation by ID -func DeleteEvaluation(id int) error { - return repository.DeleteEvaluation(database.DB, id) -} diff --git a/src/service/producer/execution.go b/src/service/producer/execution.go deleted file mode 100644 index e20f8dfc..00000000 --- a/src/service/producer/execution.go +++ /dev/null @@ -1,421 +0,0 @@ -package producer - -import ( - "aegis/config" - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "aegis/service/common" - "aegis/utils" - "context" - "errors" - "fmt" - "strings" - "time" - - "gorm.io/gorm" -) - -// BatchCreateDetectorResults saves multiple detector results for a given execution -func BatchCreateDetectorResults(req *dto.UploadDetectorResultReq, executionID int) (*dto.UploadExecutionResultResp, error) { - err := database.DB.Transaction(func(tx *gorm.DB) error { - if err := updateExecutionDuration(tx, executionID, req.Duration); err != nil { - return err - } - - var detectorResults []database.DetectorResult - for _, item := range req.Results { - detectorResults = append(detectorResults, *item.ConvertToDetectorResult(executionID)) - } - - if err := repository.SaveDetectorResults(tx, detectorResults); err != nil { - return fmt.Errorf("failed to save detector results for execution %d: %w", executionID, err) - } - - return nil - }) - if err != nil { - return nil, err - } - - resp := &dto.UploadExecutionResultResp{ - ResultCount: len(req.Results), - UploadedAt: time.Now(), - HasAnomalies: req.HasAnomalies(), - } - return resp, nil -} - -// BatchCreateGranularityResults saves multiple granularity results for a given execution -func BatchCreateGranularityResults(req *dto.UploadGranularityResultReq, executionID int) (*dto.UploadExecutionResultResp, error) { - err := database.DB.Transaction(func(tx *gorm.DB) error { - if err := updateExecutionDuration(tx, executionID, req.Duration); err != nil { - return err - } - - var granularityResults []database.GranularityResult - for _, item := range req.Results { - granularityResults = append(granularityResults, *item.ConvertToGranularityResult(executionID)) - } - - if err := repository.SaveGranularityResults(tx, granularityResults); err != nil { - return fmt.Errorf("failed to save detector results for execution %d: %w", executionID, err) - } - - return nil - }) - if err != nil { - return nil, err - } - - resp := &dto.UploadExecutionResultResp{ - ResultCount: len(req.Results), - UploadedAt: time.Now(), - } - return resp, nil -} - -// BatchDeleteExecutions deletes multiple executions by their IDs -func BatchDeleteExecutionsByIDs(executionIDs []int) error { - if len(executionIDs) == 0 { - return nil - } - - return database.DB.Transaction(func(tx *gorm.DB) error { - return batchDeleteExecutionsCore(tx, executionIDs) - }) -} - -// BatchDeleteExecutionsByLabels deletes fault executions based on label conditions -func BatchDeleteExecutionsByLabels(labelItems []dto.LabelItem) error { - if len(labelItems) == 0 { - return nil - } - - labelConditions := make([]map[string]string, 0, len(labelItems)) - for _, item := range labelItems { - labelConditions = append(labelConditions, map[string]string{ - "key": item.Key, - "value": item.Value, - }) - } - - return database.DB.Transaction(func(tx *gorm.DB) error { - executionIDs, err := repository.ListExecutionIDsByLabels(database.DB, labelConditions) - if err != nil { - return fmt.Errorf("failed to list execution ids by labels: %w", err) - } - - return batchDeleteExecutionsCore(tx, executionIDs) - }) -} - -// GetExecutionDetail retrieves detailed information about a specific execution -func GetExecutionDetail(executionID int) (*dto.ExecutionDetailResp, error) { - execution, err := repository.GetExecutionByID(database.DB, executionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: execution id: %d", consts.ErrNotFound, executionID) - } - return nil, fmt.Errorf("failed to get execution: %w", err) - } - - labels, err := repository.ListLabelsByExecutionID(database.DB, execution.ID) - if err != nil { - return nil, fmt.Errorf("failed to get execution labels: %w", err) - } - - resp := dto.NewExecutionDetailResp(execution, labels) - - if execution.AlgorithmVersion.Container.Name == config.GetDetectorName() { - detectorResults, err := repository.ListDetectorResultsByExecutionID(database.DB, execution.ID) - if err != nil { - return nil, fmt.Errorf("failed to get detector results: %w", err) - } - - items := make([]dto.DetectorResultItem, 0, len(detectorResults)) - for _, result := range detectorResults { - items = append(items, dto.NewDetectorResultItem(&result)) - } - - resp.DetectorResults = items - } else { - granularityResults, err := repository.ListGranularityResultsByExecutionID(database.DB, execution.ID) - if err != nil { - return nil, fmt.Errorf("failed to get granularity results: %w", err) - } - - items := make([]dto.GranularityResultItem, 0, len(granularityResults)) - for _, result := range granularityResults { - items = append(items, dto.NewGranularityResultItem(&result)) - } - - resp.GranularityResults = items - } - - return resp, err -} - -// ListExecutions lists executions based on the provided request parameters -func ListExecutions(req *dto.ListExecutionReq) (*dto.ListResp[dto.ExecutionResp], error) { - limit, offset := req.ToGormParams() - - labelConditions := make([]map[string]string, 0, len(req.Labels)) - for _, item := range req.Labels { - parts := strings.SplitN(item, ":", 2) - labelConditions = append(labelConditions, map[string]string{ - "key": parts[0], - "value": parts[1], - }) - } - - executions, total, err := repository.ListExecutions(database.DB, limit, offset, req.State, req.Status, labelConditions) - if err != nil { - return nil, fmt.Errorf("failed to list executions: %w", err) - } - - executionIDs := make([]int, 0, len(executions)) - for _, execution := range executions { - executionIDs = append(executionIDs, execution.ID) - } - - labelsMap, err := repository.ListExecutionLabels(database.DB, executionIDs) - if err != nil { - return nil, fmt.Errorf("failed to list execution labels: %w", err) - } - - executionResps := make([]dto.ExecutionResp, 0, len(executions)) - for _, execution := range executions { - labels := labelsMap[execution.ID] - executionResps = append(executionResps, *dto.NewExecutionResp(&execution, labels)) - } - - resp := dto.ListResp[dto.ExecutionResp]{ - Items: executionResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// ListAvaliableExecutionLabels lists all available labels for executions -func ListAvaliableExecutionLabels() ([]dto.LabelItem, error) { - labelsMap, err := repository.ListLabelsGroupByCategory(database.DB) - if err != nil { - return nil, fmt.Errorf("failed to list labels grouped by category: %w", err) - } - - if _, exists := labelsMap[consts.ExecutionCategory]; !exists { - return []dto.LabelItem{}, nil - } - - labels := labelsMap[consts.ExecutionCategory] - labelItems := make([]dto.LabelItem, 0, len(labels)) - for _, label := range labels { - labelItems = append(labelItems, dto.LabelItem{ - Key: label.Key, - Value: label.Value, - }) - } - - return labelItems, nil -} - -// ManageExecutionLabels adds or removes labels for a specific execution -func ManageExecutionLabels(req *dto.ManageExecutionLabelReq, executionID int) (*dto.ExecutionResp, error) { - if req == nil { - return nil, fmt.Errorf("manage execution labels request is nil") - } - - var managedExecution *database.Execution - var managedLabels []database.Label - err := database.DB.Transaction(func(tx *gorm.DB) error { - execution, err := repository.GetExecutionByID(database.DB, executionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: execution id: %d", consts.ErrNotFound, executionID) - } - return fmt.Errorf("failed to get execution: %w", err) - } - - if len(req.AddLabels) > 0 { - labels, err := common.CreateOrUpdateLabelsFromItems(tx, req.AddLabels, consts.ExecutionCategory) - if err != nil { - return fmt.Errorf("failed to create or update labels: %w", err) - } - - labelIDs := make([]int, 0, len(labels)) - for _, label := range labels { - labelIDs = append(labelIDs, label.ID) - } - - if err := repository.AddExecutionLabels(tx, execution.ID, labelIDs); err != nil { - return fmt.Errorf("failed to add execution labels: %w", err) - } - } - - if len(req.RemoveLabels) > 0 { - labelIDs, err := repository.ListLabelIDsByKeyAndExecutionID(tx, execution.ID, req.RemoveLabels) - if err != nil { - return fmt.Errorf("failed to find label ids by keys: %w", err) - } - - if len(labelIDs) == 0 { - if err := repository.ClearExecutionLabels(tx, []int{executionID}, labelIDs); err != nil { - return fmt.Errorf("failed to clear execution labels: %w", err) - } - - if err := repository.BatchDecreaseLabelUsages(tx, labelIDs, 1); err != nil { - return fmt.Errorf("failed to decrease label usage counts: %w", err) - } - } - } - - labels, err := repository.ListLabelsByExecutionID(database.DB, executionID) - if err != nil { - return fmt.Errorf("failed to get execution labels: %w", err) - } - - managedExecution = execution - managedLabels = labels - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewExecutionResp(managedExecution, managedLabels), nil -} - -// ProduceAlgorithmExeuctionTasks produces execution tasks into Redis based on the submission request -func ProduceAlgorithmExeuctionTasks(ctx context.Context, req *dto.SubmitExecutionReq, groupID string, userID int) (*dto.SubmitExecutionResp, error) { - project, err := repository.GetProjectByName(database.DB, req.ProjectName) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("%w: project %s not found", consts.ErrNotFound, req.ProjectName) - } - return nil, fmt.Errorf("failed to get project: %w", err) - } - - refs := make([]*dto.ContainerRef, 0, len(req.Specs)) - for _, spec := range req.Specs { - refs = append(refs, &spec.Algorithm.ContainerRef) - } - - algorithmVersionResults, err := common.MapRefsToContainerVersions(refs, consts.ContainerTypeAlgorithm, userID) - if err != nil { - return nil, fmt.Errorf("failed to map container refs to versions: %w", err) - } - if len(algorithmVersionResults) == 0 { - return nil, fmt.Errorf("no valid algorithm versions found for the provided specs") - } - - var allExecutionItems []dto.SubmitExecutionItem - for idx, spec := range req.Specs { - datapacks, datasetID, err := extractDatapacks(database.DB, spec.Datapack, spec.Dataset, userID, consts.TaskTypeRunAlgorithm) - if err != nil { - return nil, fmt.Errorf("failed to extract datapacks: %w", err) - } - - algorithmVersion, exists := algorithmVersionResults[refs[idx]] - if !exists { - return nil, fmt.Errorf("algorithm version not found for %v", spec.Algorithm) - } - - var executionItems []dto.SubmitExecutionItem - for _, datapack := range datapacks { - if datapack.StartTime == nil || datapack.EndTime == nil { - return nil, fmt.Errorf("datapack %s does not have valid start_time and end_time", datapack.Name) - } - - algorithmItem := dto.NewContainerVersionItem(&algorithmVersion) - envVars, err := common.ListContainerVersionEnvVars(spec.Algorithm.EnvVars, &algorithmVersion) - if err != nil { - return nil, fmt.Errorf("failed to list algorithm env vars: %w", err) - } - - algorithmItem.EnvVars = envVars - - payload := map[string]any{ - consts.ExecuteAlgorithm: algorithmItem, - consts.ExecuteDatapack: dto.NewInjectionItem(&datapack), - consts.ExecuteDatasetVersionID: utils.GetIntValue(datasetID, consts.DefaultInvalidID), - consts.ExecuteLabels: req.Labels, - } - - task := &dto.UnifiedTask{ - Type: consts.TaskTypeRunAlgorithm, - Immediate: true, - Payload: payload, - GroupID: groupID, - ProjectID: project.ID, - UserID: userID, - State: consts.TaskPending, - } - task.SetGroupCtx(ctx) - - err = common.SubmitTask(ctx, task) - if err != nil { - return nil, fmt.Errorf("failed to submit task: %w", err) - } - - executionItem := dto.SubmitExecutionItem{ - Index: idx, - TraceID: task.TraceID, - TaskID: task.TaskID, - AlgorithmID: algorithmVersion.ContainerID, - AlgorithmVersionID: algorithmVersion.ID, - DatapackID: &datapack.ID, - } - executionItems = append(executionItems, executionItem) - } - - allExecutionItems = append(allExecutionItems, executionItems...) - } - - resp := &dto.SubmitExecutionResp{ - GroupID: groupID, - Items: allExecutionItems, - } - return resp, nil -} - -// updateExecutionDuration updates the duration of an execution -func updateExecutionDuration(db *gorm.DB, executionID int, duration float64) error { - execution, err := repository.GetExecutionByID(db, executionID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("%w: execution %d not found", consts.ErrNotFound, executionID) - } - return fmt.Errorf("execution %d not found: %w", executionID, err) - } - - if execution.Status != consts.CommonEnabled { - return fmt.Errorf("must upload results for an active execution %d", executionID) - } - - if execution.State == consts.ExecutionSuccess { - return fmt.Errorf("cannot upload results for a successful execution %d", executionID) - } - - if err := repository.UpdateExecution(db, executionID, map[string]any{ - "duration": duration, - }); err != nil { - return fmt.Errorf("failed to update execution %d duration: %w", executionID, err) - } - - return nil -} - -// batchDeleteExecutionsCore is the core logic for batch deleting executions -func batchDeleteExecutionsCore(db *gorm.DB, executionIDs []int) error { - if err := repository.RemoveLabelsFromExecutions(db, executionIDs); err != nil { - return fmt.Errorf("failed to delete execution labels: %w", err) - } - - if err := repository.BatchDeleteExecutions(db, executionIDs); err != nil { - return fmt.Errorf("failed to batch delete executions: %w", err) - } - - return nil -} diff --git a/src/service/producer/group.go b/src/service/producer/group.go deleted file mode 100644 index 28147f35..00000000 --- a/src/service/producer/group.go +++ /dev/null @@ -1,138 +0,0 @@ -package producer - -import ( - "aegis/client" - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "context" - "fmt" - "slices" - "strconv" - "time" - - "github.com/redis/go-redis/v9" -) - -// GetGroupStats retrieves statistics for a group of traces -func GetGroupStats(req *dto.GetGroupStatsReq) (*dto.GroupStats, error) { - if req == nil { - return nil, fmt.Errorf("request cannot be nil") - } - - // Query all traces belonging to this group - traces, err := repository.GetTracesByGroupID(database.DB, req.GroupID) - if err != nil { - return nil, fmt.Errorf("failed to query traces for group %s: %w", req.GroupID, err) - } - - if len(traces) == 0 { - return dto.NewDefaultGroupStats(), nil - } - - durations := make([]float64, 0, len(traces)) - totalDuration := 0.0 - for _, trace := range traces { - if trace.EndTime != nil { - duration := trace.EndTime.Sub(trace.StartTime).Seconds() - durations = append(durations, duration) - totalDuration += duration - } - } - - traceStateMap := make(map[string][]dto.TraceStatsItem, 4) - for _, trace := range traces { - stateName := consts.GetTraceStateName(trace.State) - if _, exists := traceStateMap[stateName]; !exists { - traceStateMap[stateName] = make([]dto.TraceStatsItem, 0) - } - - traceStateMap[stateName] = append(traceStateMap[stateName], *dto.NewTraceStats(&trace)) - } - - return &dto.GroupStats{ - TotalTraces: len(traces), - AvgDuration: totalDuration / float64(len(durations)), - MinDuration: slices.Min(durations), - MaxDuration: slices.Max(durations), - TraceStateMap: traceStateMap, - }, nil -} - -// ===================== Group Stream Service ===================== - -// GroupStreamProcessor tracks group-level trace completion for SSE streaming. -// It counts how many traces have reached terminal states (Completed/Failed) -// and determines when the group stream should be considered complete. -type GroupStreamProcessor struct { - totalTraces int - finishedCount int -} - -// NewGroupStreamProcessor creates a processor that tracks progress for a group -func NewGroupStreamProcessor(groupID string) (*GroupStreamProcessor, error) { - total, err := repository.CountTracesByGroupID(database.DB, groupID) - if err != nil { - return nil, fmt.Errorf("failed to count traces for group %s: %w", groupID, err) - } - - if total == 0 { - return nil, fmt.Errorf("the group %s does not exist", groupID) - } - - return &GroupStreamProcessor{ - totalTraces: int(total), - finishedCount: 0, - }, nil -} - -// ProcessGroupMessage processes a single group stream Redis message and returns a GroupStreamEvent -func (p *GroupStreamProcessor) ProcessGroupMessage(msg redis.XMessage) (*dto.GroupStreamEvent, error) { - traceID, ok := msg.Values[consts.RdbEventTraceID].(string) - if !ok || traceID == "" { - return nil, fmt.Errorf("missing or invalid %s in group stream message", consts.RdbEventTraceID) - } - - stateStr, ok := msg.Values[consts.RdbEventTraceState].(string) - if !ok { - return nil, fmt.Errorf("missing or invalid %s in group stream message", consts.RdbEventTraceState) - } - stateInt, err := strconv.Atoi(stateStr) - if err != nil { - return nil, fmt.Errorf("invalid trace state value %s in group stream message: %w", stateStr, err) - } - state := consts.TraceState(stateInt) - - lastEventStr, ok := msg.Values[consts.RdbEventTraceLastEvent].(string) - if !ok { - return nil, fmt.Errorf("missing or invalid %s in group stream message", consts.RdbEventTraceLastEvent) - } - lastEvent := consts.EventType(lastEventStr) - - p.finishedCount++ - - return &dto.GroupStreamEvent{ - TraceID: traceID, - State: state, - LastEvent: lastEvent, - }, nil -} - -// IsCompleted returns true when all traces in the group have reached terminal states -func (p *GroupStreamProcessor) IsCompleted() bool { - return p.totalTraces > 0 && p.finishedCount >= p.totalTraces -} - -// ReadGroupStreamMessages reads messages from the group-level Redis stream -func ReadGroupStreamMessages(ctx context.Context, streamKey, lastID string, count int64, block time.Duration) ([]redis.XStream, error) { - if lastID == "" { - lastID = "0" - } - - messages, err := client.RedisXRead(ctx, []string{streamKey, lastID}, count, block) - if err != nil { - return nil, fmt.Errorf("failed to read group stream messages: %w", err) - } - return messages, nil -} diff --git a/src/service/producer/injection.go b/src/service/producer/injection.go deleted file mode 100644 index eebee43c..00000000 --- a/src/service/producer/injection.go +++ /dev/null @@ -1,1557 +0,0 @@ -package producer - -import ( - "aegis/client" - "aegis/config" - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "aegis/service/common" - "aegis/utils" - "archive/zip" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - - chaos "github.com/OperationsPAI/chaos-experiment/handler" - "github.com/OperationsPAI/chaos-experiment/pkg/guidedcli" - "github.com/sirupsen/logrus" - "gorm.io/gorm" -) - -// injectionProcessItem represents a batch of parallel fault injections -type injectionProcessItem struct { - index int // Batch index in the original request - faultDuration int // Maximum duration among all faults in this batch - nodes []chaos.Node // Multiple fault nodes to be injected in parallel (legacy path) - // guidedConfigs holds chaos-experiment guided configs when the submission - // was routed through pkg/guidedcli (PR 2). Mutually exclusive with nodes: - // exactly one of the two slices is populated for a given item. - guidedConfigs []guidedcli.GuidedConfig - executeTime time.Time // Execution time for this batch -} - -// BatchDeleteInjectionsByIDs deletes fault injections based on their IDs -func BatchDeleteInjectionsByIDs(injectionIDs []int) error { - if len(injectionIDs) == 0 { - return nil - } - - return database.DB.Transaction(func(tx *gorm.DB) error { - return batchDeleteExecutionsCore(tx, injectionIDs) - }) -} - -// BatchDeleteInjectionsByLabels deletes fault injections based on label conditions -func BatchDeleteInjectionsByLabels(labelItems []dto.LabelItem) error { - if len(labelItems) == 0 { - return nil - } - - labelConditions := make([]map[string]string, 0, len(labelItems)) - for _, item := range labelItems { - labelConditions = append(labelConditions, map[string]string{ - "key": item.Key, - "value": item.Value, - }) - } - - return database.DB.Transaction(func(tx *gorm.DB) error { - injectionIDs, err := repository.ListInjectionIDsByLabels(tx, labelConditions) - if err != nil { - return fmt.Errorf("failed to list injection ids by labels: %w", err) - } - - return batchDeleteInjectionsCore(tx, injectionIDs) - }) -} - -// CreateInjection creates a new fault injection along with its associated project-container relationships and labels -func CreateInjection(injection *database.FaultInjection, labelItems []dto.LabelItem) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - if err := repository.CreateInjection(tx, injection); err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return fmt.Errorf("%w: injection with name %s already exists", consts.ErrAlreadyExists, injection.Name) - } - return fmt.Errorf("failed to create injection: %w", err) - } - - if len(labelItems) > 0 { - labels, err := common.CreateOrUpdateLabelsFromItems(tx, labelItems, consts.InjectionCategory) - if err != nil { - return fmt.Errorf("failed to create or update labels: %w", err) - } - - // Collect label IDs - labelIDs := make([]int, 0, len(labels)) - for _, label := range labels { - labelIDs = append(labelIDs, label.ID) - } - - // AddInjectionLabels now takes injectionID and labelIDs (stores as TaskLabel internally) - if err := repository.AddInjectionLabels(tx, injection.ID, labelIDs); err != nil { - return fmt.Errorf("failed to add injection labels: %w", err) - } - } - - return nil - }) -} - -// UpdateGroundtruth updates the ground truth for an existing injection -func UpdateGroundtruth(id int, req *dto.UpdateGroundtruthReq) error { - // Verify injection exists - _, err := repository.GetInjectionByID(database.DB, id) - if err != nil { - return err - } - return repository.UpdateGroundtruth(database.DB, id, req.Groundtruths, consts.GroundtruthSourceManual) -} - -// GetInjectionDetail retrieves detailed information about a specific fault injection -func GetInjectionDetail(injectionID int) (*dto.InjectionDetailResp, error) { - logEntry := logrus.WithFields(logrus.Fields{ - "injectionID": injectionID, - }) - - injection, err := repository.GetInjectionByID(database.DB, injectionID) - if err != nil { - logEntry.Error("failed to get injection from repository: %w", err) - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: injection id: %d", consts.ErrNotFound, injectionID) - } - return nil, fmt.Errorf("failed to get injection: %w", err) - } - - labels, err := repository.ListLabelsByInjectionID(database.DB, injection.ID) - if err != nil { - logEntry.Error("failed to get injection labels from repository: %w", err) - return nil, fmt.Errorf("failed to get injection labels: %w", err) - } - - injection.Labels = labels - resp := dto.NewInjectionDetailResp(injection) - - return resp, err -} - -// CloneInjection clones an existing injection with a new name -func CloneInjection(injectionID int, req *dto.CloneInjectionReq) (*dto.InjectionDetailResp, error) { - original, err := repository.GetInjectionByID(database.DB, injectionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: injection id: %d", consts.ErrNotFound, injectionID) - } - return nil, fmt.Errorf("failed to get injection: %w", err) - } - - cloned := &database.FaultInjection{ - Name: req.Name, - FaultType: original.FaultType, - Category: original.Category, - Description: original.Description, - DisplayConfig: original.DisplayConfig, - EngineConfig: original.EngineConfig, - Groundtruths: original.Groundtruths, - PreDuration: original.PreDuration, - StartTime: original.StartTime, - EndTime: original.EndTime, - BenchmarkID: original.BenchmarkID, - PedestalID: original.PedestalID, - State: consts.DatapackInitial, - Status: consts.CommonEnabled, - } - - if err := CreateInjection(cloned, req.Labels); err != nil { - return nil, err - } - - labels, err := repository.ListLabelsByInjectionID(database.DB, cloned.ID) - if err != nil { - return nil, fmt.Errorf("failed to get cloned injection labels: %w", err) - } - - cloned.Labels = labels - return dto.NewInjectionDetailResp(cloned), nil -} - -// GetInjectionLogs retrieves execution logs for an injection from Loki -func GetInjectionLogs(injectionID int) (*dto.InjectionLogsResp, error) { - injection, err := repository.GetInjectionByID(database.DB, injectionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: injection id: %d", consts.ErrNotFound, injectionID) - } - return nil, fmt.Errorf("failed to get injection: %w", err) - } - - resp := &dto.InjectionLogsResp{ - InjectionID: injectionID, - Logs: []string{}, - } - - if injection.TaskID != nil { - resp.TaskID = *injection.TaskID - - // Query historical logs from Loki - lokiCtx, lokiCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer lokiCancel() - - task, taskErr := repository.GetTaskByID(database.DB, *injection.TaskID) - if taskErr != nil { - logrus.Warnf("Failed to get task %s for log retrieval: %v", *injection.TaskID, taskErr) - return resp, nil - } - - lokiClient := client.NewLokiClient() - queryOpts := client.QueryOpts{ - Start: task.CreatedAt, - Direction: "forward", - } - - logEntries, lokiErr := lokiClient.QueryJobLogs(lokiCtx, *injection.TaskID, queryOpts) - if lokiErr != nil { - logrus.Warnf("Failed to query Loki for injection %d logs: %v", injectionID, lokiErr) - return resp, nil - } - - logs := make([]string, 0, len(logEntries)) - for _, entry := range logEntries { - logs = append(logs, entry.Line) - } - resp.Logs = logs - } - - return resp, nil -} - -// ListInjections lists fault injections based on the provided filters -func ListInjections(req *dto.ListInjectionReq) (*dto.ListResp[dto.InjectionResp], error) { - limit, offset := req.ToGormParams() - fitlerOptions := req.ToFilterOptions() - - injections, total, err := repository.ListInjections(database.DB, limit, offset, fitlerOptions) - if err != nil { - return nil, fmt.Errorf("failed to list injections: %w", err) - } - - injectionIDs := make([]int, 0, len(injections)) - for _, injection := range injections { - injectionIDs = append(injectionIDs, injection.ID) - } - - labelsMap, err := repository.ListInjectionLabels(database.DB, injectionIDs) - if err != nil { - return nil, fmt.Errorf("failed to list injection labels: %w", err) - } - - injectionResps := make([]dto.InjectionResp, 0, len(injections)) - for _, injection := range injections { - if labels, exists := labelsMap[injection.ID]; exists { - injection.Labels = labels - } - injectionResps = append(injectionResps, *dto.NewInjectionResp(&injection)) - } - - resp := dto.ListResp[dto.InjectionResp]{ - Items: injectionResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// SearchInjections performs advanced search on fault injections -func SearchInjections(req *dto.SearchInjectionReq, projectID *int) (*dto.SearchResp[dto.InjectionDetailResp], error) { - if req == nil { - return nil, fmt.Errorf("search injection request is nil") - } - - searchReq := req.ConvertToSearchReq() - - // Add project filter if projectID is provided - if projectID != nil { - searchReq.AddFilter("project_id", dto.OpEqual, *projectID) - } - - injections, total, err := repository.ExecuteSearch(database.DB, searchReq, database.FaultInjection{}, consts.InjectionAllowedFields) - if err != nil { - return nil, fmt.Errorf("failed to search injections: %w", err) - } - - labelConditions := make([]map[string]string, 0, len(req.Labels)) - for _, item := range req.Labels { - labelConditions = append(labelConditions, map[string]string{ - "key": item.Key, - "value": item.Value, - }) - } - - filteredInjections := []database.FaultInjection{} - if len(labelConditions) > 0 { - injectionIDs, err := repository.ListInjectionIDsByLabels(database.DB, labelConditions) - if err != nil { - return nil, fmt.Errorf("failed to list injection ids by labels: %w", err) - } - - injectionIDMap := make(map[int]struct{}, len(injectionIDs)) - for _, id := range injectionIDs { - injectionIDMap[id] = struct{}{} - } - - for _, injection := range injections { - if _, exists := injectionIDMap[injection.ID]; exists { - filteredInjections = append(filteredInjections, injection) - } - } - } else { - filteredInjections = injections - } - - // Convert to response format - injectionResps := make([]dto.InjectionDetailResp, 0, len(filteredInjections)) - for _, injection := range filteredInjections { - injectionResps = append(injectionResps, *dto.NewInjectionDetailResp(&injection)) - } - - resp := &dto.SearchResp[dto.InjectionDetailResp]{ - Pagination: req.ConvertToPaginationInfo(total), - } - - if len(req.GroupBy) > 0 { - resp.Groups = dto.BuildGroupTree(injectionResps, req.GroupBy) - } else { - resp.Items = injectionResps - } - - return resp, nil -} - -// GetDatapackFilename returns the filename for downloading a datapack -func GetDatapackFilename(injectionID int) (string, error) { - injection, err := repository.GetInjectionByID(database.DB, injectionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return "", fmt.Errorf("%w: injection id: %d", consts.ErrNotFound, injectionID) - } - return "", fmt.Errorf("failed to get injection: %w", err) - } - - if injection.State < consts.DatapackBuildSuccess { - return "", fmt.Errorf("datapack for injection id %d is not ready for download", injectionID) - } - - return injection.Name, nil -} - -// DownloadDatapack handles the downloading of a specific datapack -func DownloadDatapack(zipWriter *zip.Writer, excludeRules []utils.ExculdeRule, injectionID int) error { - if zipWriter == nil { - return fmt.Errorf("zip writer cannot be nil") - } - - injection, err := repository.GetInjectionByID(database.DB, injectionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: injection id: %d", consts.ErrNotFound, injectionID) - } - return fmt.Errorf("failed to get injection: %w", err) - } - - if err := packageDatapackToZip(zipWriter, injection, excludeRules); err != nil { - return fmt.Errorf("failed to package injection to zip: %w", err) - } - - return nil -} - -// GetDatapackFiles retrieves the file structure of a datapack in tree format -func GetDatapackFiles(datapackID int, baseURL string) (*dto.DatapackFilesResp, error) { - datapack, err := repository.GetInjectionByID(database.DB, datapackID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: datapack id: %d", consts.ErrNotFound, datapackID) - } - return nil, fmt.Errorf("failed to get datapack: %w", err) - } - - if datapack.State < consts.DatapackBuildSuccess { - return nil, fmt.Errorf("datapack %d is not ready", datapackID) - } - - workDir := filepath.Join(config.GetString("jfs.dataset_path"), datapack.Name) - if !utils.IsAllowedPath(workDir) { - return nil, fmt.Errorf("invalid path access to %s", workDir) - } - - // Check if directory exists - if _, err := os.Stat(workDir); os.IsNotExist(err) { - return nil, fmt.Errorf("datapack directory not found for datapack id %d", datapackID) - } - - resp := &dto.DatapackFilesResp{ - Files: []dto.DatapackFileItem{}, - FileCount: 0, - DirCount: 0, - } - - // Build tree structure - rootItems, err := buildFileTree(workDir, "", baseURL, datapackID, resp) - if err != nil { - return nil, fmt.Errorf("failed to build file tree: %w", err) - } - - resp.Files = rootItems - return resp, nil -} - -// DownloadDatapackFile downloads a specific file from a datapack -func DownloadDatapackFile(datapackID int, filePath string) (string, string, int64, io.ReadSeekCloser, error) { - fullPath, err := getFileFullPath(datapackID, filePath) - if err != nil { - return "", "", 0, nil, fmt.Errorf("invalid file path: %w", err) - } - - file, err := os.Open(fullPath) - if err != nil { - return "", "", 0, nil, fmt.Errorf("failed to open file: %w", err) - } - - stat, err := file.Stat() - if err != nil { - _ = file.Close() - return "", "", 0, nil, fmt.Errorf("failed to stat file: %w", err) - } - - fileName := filepath.Base(fullPath) - contentType := "application/octet-stream" - - // Determine content type based on file extension - switch filepath.Ext(fileName) { - case ".json": - contentType = "application/json" - case ".yaml", ".yml": - contentType = "application/x-yaml" - case ".txt", ".log": - contentType = "text/plain" - case ".csv": - contentType = "text/csv" - case ".xml": - contentType = "application/xml" - case ".html", ".htm": - contentType = "text/html" - case ".pdf": - contentType = "application/pdf" - case ".zip": - contentType = "application/zip" - case ".tar", ".gz", ".tgz": - contentType = "application/x-tar" - } - - return fileName, contentType, stat.Size(), file, nil -} - -// ListInjectionsNoissues handles the request to list fault injections without issues -func ListInjectionsNoIssues(req *dto.ListInjectionNoIssuesReq, projectID *int) ([]dto.InjectionNoIssuesResp, error) { - if len(req.Labels) == 0 { - return nil, nil - } - - labelConditions := make([]map[string]string, 0, len(req.Labels)) - for _, item := range req.Labels { - parts := strings.SplitN(item, ":", 2) - labelConditions = append(labelConditions, map[string]string{ - "key": parts[0], - "value": parts[1], - }) - } - - opts, err := req.Convert() - if err != nil { - return nil, fmt.Errorf("invalid time range: %w", err) - } - - records, err := repository.ListInjectionsNoIssues(database.DB, labelConditions, &opts.CustomStartTime, &opts.CustomEndTime, projectID) - if err != nil { - return nil, fmt.Errorf("failed to list fault injections without issues: %w", err) - } - - var items []dto.InjectionNoIssuesResp - for i, record := range records { - resp, err := dto.NewInjectionNoIssuesResp(record) - if err != nil { - return nil, fmt.Errorf("failed to create InjectionNoIssuesResp at index %d: %w", i, err) - } - - items = append(items, *resp) - } - - return items, nil -} - -// ListInjectionsNoissues handles the request to list fault injections without issues -func ListInjectionsWithIssues(req *dto.ListInjectionWithIssuesReq, projectID *int) ([]dto.InjectionWithIssuesResp, error) { - if len(req.Labels) == 0 { - return nil, nil - } - - labelConditions := make([]map[string]string, 0, len(req.Labels)) - for _, item := range req.Labels { - parts := strings.SplitN(item, ":", 2) - labelConditions = append(labelConditions, map[string]string{ - "key": parts[0], - "value": parts[1], - }) - } - - opts, err := req.Convert() - if err != nil { - return nil, fmt.Errorf("invalid time range: %w", err) - } - - records, err := repository.ListInjectionsWithIssues(database.DB, labelConditions, &opts.CustomStartTime, &opts.CustomEndTime, projectID) - if err != nil { - return nil, fmt.Errorf("failed to list fault injections without issues: %w", err) - } - - var items []dto.InjectionWithIssuesResp - for _, record := range records { - resp, err := dto.NewInjectionWithIssuesResp(record) - if err != nil { - return nil, fmt.Errorf("failed to create InjectionNoIssuesResp: %w", err) - } - - items = append(items, *resp) - } - - return items, nil -} - -// ManageInjectionTags manages labels associated with a fault injection -func ManageInjectionLabels(req *dto.ManageInjectionLabelReq, injectionID int) (*dto.InjectionResp, error) { - if req == nil { - return nil, fmt.Errorf("manage injection labels request is nil") - } - - var managedInjection *database.FaultInjection - - err := database.DB.Transaction(func(tx *gorm.DB) error { - injection, err := repository.GetInjectionByID(database.DB, injectionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: injection id: %d", consts.ErrNotFound, injectionID) - } - return fmt.Errorf("failed to get injection: %w", err) - } - - if len(req.AddLabels) > 0 { - labels, err := common.CreateOrUpdateLabelsFromItems(tx, req.AddLabels, consts.InjectionCategory) - if err != nil { - return fmt.Errorf("failed to create or update labels: %w", err) - } - - // Collect label IDs - labelIDs := make([]int, 0, len(labels)) - for _, label := range labels { - labelIDs = append(labelIDs, label.ID) - } - - // AddInjectionLabels now takes injectionID and labelIDs (stores as TaskLabel internally) - if err := repository.AddInjectionLabels(tx, injection.ID, labelIDs); err != nil { - return fmt.Errorf("failed to add injection labels: %w", err) - } - } - - if len(req.RemoveLabels) > 0 { - labelIDs, err := repository.ListLabelIDsByKeyAndInjectionID(tx, injection.ID, req.RemoveLabels) - if err != nil { - return fmt.Errorf("failed to find label ids by keys: %w", err) - } - - if len(labelIDs) > 0 { - if err := repository.ClearInjectionLabels(tx, []int{injectionID}, labelIDs); err != nil { - return fmt.Errorf("failed to clear injection labels: %w", err) - } - - if err := repository.BatchDecreaseLabelUsages(tx, labelIDs, 1); err != nil { - return fmt.Errorf("failed to decrease label usage counts: %w", err) - } - } - } - - labels, err := repository.ListLabelsByInjectionID(database.DB, injectionID) - if err != nil { - return fmt.Errorf("failed to get injection labels: %w", err) - } - - injection.Labels = labels - managedInjection = injection - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewInjectionResp(managedInjection), nil -} - -// BatchManageInjectionLabels adds or removes labels from multiple injections -// Each injection can have its own set of label operations -func BatchManageInjectionLabels(req *dto.BatchManageInjectionLabelReq) (*dto.BatchManageInjectionLabelResp, error) { - if req == nil { - return nil, fmt.Errorf("batch manage injection labels request is nil") - } - - resp := &dto.BatchManageInjectionLabelResp{ - FailedCount: 0, - FailedItems: []string{}, - SuccessCount: 0, - SuccessItems: []dto.InjectionResp{}, - } - - if len(req.Items) == 0 { - return resp, nil - } - - // Process all operations in a single transaction - return resp, database.DB.Transaction(func(tx *gorm.DB) error { - // Step 1: Collect all injection IDs and verify they exist (batch query) - allInjectionIDs := make([]int, 0, len(req.Items)) - operationMap := make(map[int]*dto.InjectionLabelOperation) - - for i := range req.Items { - item := &req.Items[i] - allInjectionIDs = append(allInjectionIDs, item.InjectionID) - operationMap[item.InjectionID] = item - } - - injections, err := repository.ListFaultInjectionsByID(tx, allInjectionIDs) - if err != nil { - return fmt.Errorf("failed to list injections: %w", err) - } - - foundIDMap := make(map[int]*database.FaultInjection) - for i := range injections { - foundIDMap[injections[i].ID] = &injections[i] - } - - // Track which IDs were not found - validIDs := make([]int, 0, len(foundIDMap)) - for _, id := range allInjectionIDs { - if _, found := foundIDMap[id]; !found { - resp.FailedItems = append(resp.FailedItems, fmt.Sprintf("Injection ID %d not found", id)) - resp.FailedCount++ - delete(operationMap, id) // Remove from operations - } else { - validIDs = append(validIDs, id) - } - } - - if len(validIDs) == 0 { - return fmt.Errorf("no valid injection IDs found") - } - - // Step 2: Collect all unique labels from all operations and create them in batch - allAddLabels := make([]dto.LabelItem, 0) - allRemoveLabels := make([]dto.LabelItem, 0) - labelKeySet := make(map[string]bool) - - for _, op := range operationMap { - for _, label := range op.AddLabels { - key := label.Key + ":" + label.Value - if !labelKeySet[key] { - labelKeySet[key] = true - allAddLabels = append(allAddLabels, label) - } - } - for _, label := range op.RemoveLabels { - key := label.Key + ":" + label.Value - if !labelKeySet[key] { - labelKeySet[key] = true - allRemoveLabels = append(allRemoveLabels, label) - } - } - } - - // Create or update all labels in batch - var labelMap map[string]int // key:value -> label_id - if len(allAddLabels) > 0 { - labels, err := common.CreateOrUpdateLabelsFromItems(tx, allAddLabels, consts.InjectionCategory) - if err != nil { - return fmt.Errorf("failed to create or update labels: %w", err) - } - - labelMap = make(map[string]int) - for _, label := range labels { - key := label.Key + ":" + label.Value - labelMap[key] = label.ID - } - } - - // Get label IDs for removal labels - var removeLabelMap map[string]int // key:value -> label_id - if len(allRemoveLabels) > 0 { - labelConditions := make([]map[string]string, 0, len(allRemoveLabels)) - for _, item := range allRemoveLabels { - labelConditions = append(labelConditions, map[string]string{ - "key": item.Key, - "value": item.Value, - }) - } - - labelIDs, err := repository.ListLabelIDsByConditions(tx, labelConditions, consts.InjectionCategory) - if err != nil { - return fmt.Errorf("failed to find labels to remove: %w", err) - } - - // Map them back for quick lookup - if len(labelIDs) > 0 { - labels, err := repository.ListLabelsByID(tx, labelIDs) - if err != nil { - return fmt.Errorf("failed to list labels by IDs: %w", err) - } - - removeLabelMap = make(map[string]int) - for _, label := range labels { - key := label.Key + ":" + label.Value - removeLabelMap[key] = label.ID - } - } - } - - // Step 3: Process each injection's operations - for _, injectionID := range validIDs { - op := operationMap[injectionID] - - if len(op.AddLabels) > 0 { - labelIDsToAdd := make([]int, 0, len(op.AddLabels)) - for _, label := range op.AddLabels { - key := label.Key + ":" + label.Value - if labelID, exists := labelMap[key]; exists { - labelIDsToAdd = append(labelIDsToAdd, labelID) - } - } - - if len(labelIDsToAdd) > 0 { - if err := repository.AddInjectionLabels(tx, injectionID, labelIDsToAdd); err != nil { - resp.FailedItems = append(resp.FailedItems, fmt.Sprintf("Injection ID %d: failed to add labels - %s", injectionID, err.Error())) - resp.FailedCount++ - delete(foundIDMap, injectionID) - continue - } - } - } - - if len(op.RemoveLabels) > 0 && removeLabelMap != nil { - labelIDsToRemove := make([]int, 0, len(op.RemoveLabels)) - for _, label := range op.RemoveLabels { - key := label.Key + ":" + label.Value - if labelID, exists := removeLabelMap[key]; exists { - labelIDsToRemove = append(labelIDsToRemove, labelID) - } - } - - if len(labelIDsToRemove) > 0 { - if err := repository.ClearInjectionLabels(tx, []int{injectionID}, labelIDsToRemove); err != nil { - resp.FailedItems = append(resp.FailedItems, fmt.Sprintf("Injection ID %d: failed to remove labels - %s", injectionID, err.Error())) - resp.FailedCount++ - delete(foundIDMap, injectionID) - continue - } - } - } - } - - // Step 4: Fetch updated injection data with labels (batch query) - if len(foundIDMap) > 0 { - successIDs := make([]int, 0, len(foundIDMap)) - for id := range foundIDMap { - successIDs = append(successIDs, id) - } - - updatedInjections, err := repository.ListFaultInjectionsByID(tx, successIDs) - if err != nil { - return fmt.Errorf("failed to fetch updated injections: %w", err) - } - - labelsMap, err := repository.ListInjectionLabels(tx, successIDs) - if err != nil { - return fmt.Errorf("failed to list injection labels: %w", err) - } - - for i := range updatedInjections { - injection := &updatedInjections[i] - if labels, exists := labelsMap[injection.ID]; exists { - injection.Labels = labels - } - injectionResp := dto.NewInjectionResp(injection) - resp.SuccessItems = append(resp.SuccessItems, *injectionResp) - resp.SuccessCount++ - } - } - - return nil - }) -} - -// ProduceRestartPedestalTasks produces pedestal restart tasks with support for parallel fault injection -func ProduceRestartPedestalTasks(ctx context.Context, req *dto.SubmitInjectionReq, groupID string, userID int, projectID *int) (*dto.SubmitInjectionResp, error) { - if req == nil { - return nil, fmt.Errorf("submit injection request is nil") - } - - if projectID == nil { - project, err := repository.GetProjectByName(database.DB, req.ProjectName) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("%w: project %s not found", consts.ErrNotFound, req.ProjectName) - } - return nil, fmt.Errorf("failed to get project: %w", err) - } - projectID = &project.ID - } - - pedestalVersionResults, err := common.MapRefsToContainerVersions([]*dto.ContainerRef{&req.Pedestal.ContainerRef}, consts.ContainerTypePedestal, userID) - if err != nil { - return nil, fmt.Errorf("failed to map pedestal container ref to version: %w", err) - } - - pedestalVersion, exists := pedestalVersionResults[&req.Pedestal.ContainerRef] - if !exists { - return nil, fmt.Errorf("pedestal version not found for container: %s (version: %s)", req.Pedestal.Name, req.Pedestal.Version) - } - - helmConfig, err := repository.GetHelmConfigByContainerVersionID(database.DB, pedestalVersion.ID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("%w: helm config not found for pedestal version id %d", consts.ErrNotFound, pedestalVersion.ID) - } - return nil, fmt.Errorf("failed to get helm config: %w", err) - } - - params := flattenYAMLToParameters(req.Pedestal.Payload, "") - helmValues, err := common.ListHelmConfigValues(params, helmConfig) - if err != nil { - return nil, fmt.Errorf("failed to render pedestal helm values: %w", err) - } - - helmConfigItem := dto.NewHelmConfigItem(helmConfig) - helmConfigItem.DynamicValues = helmValues - - pedestalItem := dto.NewContainerVersionItem(&pedestalVersion) - pedestalItem.Extra = helmConfigItem - - benchmarkVersionResults, err := common.MapRefsToContainerVersions([]*dto.ContainerRef{&req.Benchmark.ContainerRef}, consts.ContainerTypeBenchmark, userID) - if err != nil { - return nil, fmt.Errorf("failed to map benchmark container ref to version: %w", err) - } - - benchmarkVersion, exists := benchmarkVersionResults[&req.Benchmark.ContainerRef] - if !exists { - return nil, fmt.Errorf("benchmark version not found for container: %s (version: %s)", req.Benchmark.Name, req.Benchmark.Version) - } - - benchmarkVersionItem := dto.NewContainerVersionItem(&benchmarkVersion) - envVars, err := common.ListContainerVersionEnvVars(req.Benchmark.EnvVars, &benchmarkVersion) - if err != nil { - return nil, fmt.Errorf("failed to list benchmark env vars: %w", err) - } - - benchmarkVersionItem.EnvVars = envVars - - // Use resolved fields (populated by handler-level ResolveSpecs call) instead of raw Specs. - // Exactly one of ResolvedSpecs or ResolvedGuidedConfigs is populated. - legacySpecs := req.ResolvedSpecs - guidedSpecs := req.ResolvedGuidedConfigs - if len(legacySpecs) == 0 && len(guidedSpecs) == 0 { - return nil, fmt.Errorf("no resolved specs available; call ResolveSpecs before ProduceRestartPedestalTasks") - } - - // Parse each batch and collect items — dispatch on which resolved path is populated. - processedItems := make([]injectionProcessItem, 0, max(len(legacySpecs), len(guidedSpecs))) - var parseWarnings []string - if len(guidedSpecs) > 0 { - for i := range guidedSpecs { - item, warning, err := parseBatchGuidedSpecs(ctx, pedestalItem.ContainerName, i, guidedSpecs[i]) - if err != nil { - return nil, fmt.Errorf("failed to parse guided spec batch %d: %w", i, err) - } - if warning != "" { - parseWarnings = append(parseWarnings, warning) - } else { - processedItems = append(processedItems, *item) - } - } - } else { - for i := range legacySpecs { - item, warning, err := parseBatchInjectionSpecs(ctx, pedestalItem.ContainerName, i, legacySpecs[i]) - if err != nil { - return nil, fmt.Errorf("failed to parse injection spec batch %d: %w", i, err) - } - - if warning != "" { - parseWarnings = append(parseWarnings, warning) - } else { - processedItems = append(processedItems, *item) - } - } - } - - // Remove duplicated batches - uniqueItems, duplicatedInRequest, alreadyExisted, err := removeDuplicated(processedItems) - if err != nil { - return nil, fmt.Errorf("failed to remove duplicated batches: %w", err) - } - - // Collect warnings about duplications - var warnings *dto.InjectionWarnings - if len(parseWarnings) > 0 || len(duplicatedInRequest) > 0 || len(alreadyExisted) > 0 { - warnings = &dto.InjectionWarnings{ - DuplicateServicesInBatch: parseWarnings, - DuplicateBatchesInRequest: duplicatedInRequest, - BatchesExistInDatabase: alreadyExisted, - } - } - - if len(req.Algorithms) > 0 { - refs := make([]*dto.ContainerRef, 0, len(req.Algorithms)) - for i := range req.Algorithms { - refs = append(refs, &req.Algorithms[i].ContainerRef) - } - - algorithmVersionsResults, err := common.MapRefsToContainerVersions(refs, consts.ContainerTypeAlgorithm, userID) - if err != nil { - return nil, fmt.Errorf("failed to map container refs to versions: %w", err) - } - - var algorithmVersionItems []dto.ContainerVersionItem - for i := range req.Algorithms { - spec := &req.Algorithms[i] - algorithmVersion, exists := algorithmVersionsResults[&spec.ContainerRef] - if !exists { - return nil, fmt.Errorf("algorithm version not found for %v", spec) - } - - algorithmVersionItem := dto.NewContainerVersionItem(&algorithmVersion) - envVars, err := common.ListContainerVersionEnvVars(spec.EnvVars, &algorithmVersion) - if err != nil { - return nil, fmt.Errorf("failed to list algorithm env vars: %w", err) - } - - algorithmVersionItem.EnvVars = envVars - algorithmVersionItems = append(algorithmVersionItems, algorithmVersionItem) - } - - if len(algorithmVersionItems) > 0 { - if err := client.SetHashField(ctx, consts.InjectionAlgorithmsKey, groupID, algorithmVersionItems); err != nil { - return nil, fmt.Errorf("failed to store injection algorithms: %w", err) - } - } - } - - injectionItems := make([]dto.SubmitInjectionItem, 0, len(uniqueItems)) - for _, item := range uniqueItems { - injectPayload := map[string]any{ - consts.InjectBenchmark: benchmarkVersionItem, - consts.InjectPreDuration: req.PreDuration, - consts.InjectLabels: req.Labels, - consts.InjectSystem: chaos.SystemType(pedestalItem.ContainerName), - } - // Exactly one of nodes / guidedConfigs is populated on item. The - // consumer side (parseInjectionPayload) dispatches on which key is - // present — guided_configs wins if both happen to be set. - if len(item.guidedConfigs) > 0 { - injectPayload[consts.InjectGuidedConfigs] = item.guidedConfigs - } else { - injectPayload[consts.InjectNodes] = item.nodes - } - payload := map[string]any{ - consts.RestartPedestal: pedestalItem, - consts.RestartHelmConfig: helmConfig, - consts.RestartIntarval: req.Interval, - consts.RestartFaultDuration: item.faultDuration, - consts.RestartInjectPayload: injectPayload, - } - - task := &dto.UnifiedTask{ - Type: consts.TaskTypeRestartPedestal, - Immediate: false, - ExecuteTime: item.executeTime.Unix(), - Payload: payload, - GroupID: groupID, - ProjectID: *projectID, - UserID: userID, - State: consts.TaskPending, - Extra: map[consts.TaskExtra]any{ - consts.TaskExtraInjectionAlgorithms: len(req.Algorithms), - }, - } - task.SetGroupCtx(ctx) - - err := common.SubmitTask(ctx, task) - if err != nil { - return nil, fmt.Errorf("failed to submit fault injection task: %w", err) - } - - injectionItems = append(injectionItems, dto.SubmitInjectionItem{ - Index: item.index, - TraceID: task.TraceID, - TaskID: task.TaskID, - }) - } - - sort.Slice(injectionItems, func(i, j int) bool { - return injectionItems[i].Index < injectionItems[j].Index - }) - - return &dto.SubmitInjectionResp{ - GroupID: groupID, - Items: injectionItems, - OriginalCount: len(processedItems), - Warnings: warnings, - }, nil -} - -// ProduceDatapackBuildingTasks produces datapack building tasks into Redis based on the request specifications -func ProduceDatapackBuildingTasks(ctx context.Context, req *dto.SubmitDatapackBuildingReq, groupID string, userID int, projectID *int) (*dto.SubmitDatapackBuildingResp, error) { - if req == nil { - return nil, fmt.Errorf("submit datapack building request is nil") - } - - if projectID == nil { - // Use project name from request - project, err := repository.GetProjectByName(database.DB, req.ProjectName) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("%w: project %s not found", consts.ErrNotFound, req.ProjectName) - } - return nil, fmt.Errorf("failed to get project: %w", err) - } - projectID = &project.ID - } - - refs := make([]*dto.ContainerRef, 0, len(req.Specs)) - for _, spec := range req.Specs { - refs = append(refs, &spec.Benchmark.ContainerRef) - } - - benchmarkVersionResults, err := common.MapRefsToContainerVersions(refs, consts.ContainerTypeBenchmark, userID) - if err != nil { - return nil, fmt.Errorf("failed to map container refs to versions: %w", err) - } - - var allBuildingItems []dto.SubmitBuildingItem - for idx, spec := range req.Specs { - datapacks, datasetVersionID, err := extractDatapacks(database.DB, spec.Datapack, spec.Dataset, userID, consts.TaskTypeBuildDatapack) - if err != nil { - return nil, fmt.Errorf("failed to extract datapacks: %w", err) - } - - benchmarkVersion, exists := benchmarkVersionResults[refs[idx]] - if !exists { - return nil, fmt.Errorf("benchmark version not found for %v", spec.Benchmark) - } - - benchmarkVersionItem := dto.NewContainerVersionItem(&benchmarkVersion) - envVars, err := common.ListContainerVersionEnvVars(spec.Benchmark.EnvVars, &benchmarkVersion) - if err != nil { - return nil, fmt.Errorf("failed to list benchmark env vars: %w", err) - } - - benchmarkVersionItem.EnvVars = envVars - - var buildingItems []dto.SubmitBuildingItem - for _, datapack := range datapacks { - if datapack.StartTime == nil || datapack.EndTime == nil { - return nil, fmt.Errorf("datapack %s does not have valid start_time and end_time", datapack.Name) - } - - payload := map[string]any{ - consts.BuildBenchmark: benchmarkVersionItem, - consts.BuildDatapack: dto.NewInjectionItem(&datapack), - consts.BuildDatasetVersionID: datasetVersionID, - consts.BuildLabels: req.Labels, - } - - task := &dto.UnifiedTask{ - Type: consts.TaskTypeBuildDatapack, - Immediate: true, - Payload: payload, - GroupID: groupID, - ProjectID: *projectID, - UserID: userID, - State: consts.TaskPending, - } - task.SetGroupCtx(ctx) - - err = common.SubmitTask(ctx, task) - if err != nil { - return nil, fmt.Errorf("failed to submit datapack building task: %w", err) - } - - buildingItems = append(buildingItems, dto.SubmitBuildingItem{ - Index: idx, - TraceID: task.TraceID, - TaskID: task.TaskID, - }) - } - - allBuildingItems = append(allBuildingItems, buildingItems...) - } - - resp := &dto.SubmitDatapackBuildingResp{ - GroupID: groupID, - Items: allBuildingItems, - } - return resp, nil -} - -func batchDeleteInjectionsCore(db *gorm.DB, injectionIDs []int) error { - executions, err := repository.ListExecutionsByDatapackIDs(db, injectionIDs) - if err != nil { - return fmt.Errorf("failed to list executions by datapack ids: %w", err) - } - - if len(executions) == 0 { - return fmt.Errorf("no executions found for the given injection ids") - } - - executionIDs := make([]int, 0, len(executions)) - for _, execution := range executions { - executionIDs = append(executionIDs, execution.ID) - } - - if err := batchDeleteExecutionsCore(db, executionIDs); err != nil { - return fmt.Errorf("failed to batch delete executions: %v", err) - } - - if err := repository.ClearInjectionLabels(db, injectionIDs, nil); err != nil { - return fmt.Errorf("failed to clear injection labels: %w", err) - } - - if err := repository.BatchDeleteInjections(db, injectionIDs); err != nil { - return fmt.Errorf("failed to delete injections: %w", err) - } - - return nil -} - -// parseBatchInjectionSpecs parses a single batch of fault injection specifications for parallel execution -// Returns the processed item, a warning message (if any), and an error -// -// Deprecated: this helper is tied to the legacy chaos.Node tree representation. -// Friendly FaultSpec/chaoscli.Spec payloads are transparently converted to Nodes -// via FriendlySpecToNode in ResolveSpecs, so this path still exercises the same -// InjectionConf-based groundtruth pipeline. New callers should rely on the -// handler-level ResolveSpecs hop and, eventually, consume chaoscli.Spec directly. -func parseBatchInjectionSpecs(ctx context.Context, pedestal string, batchIndex int, specs []chaos.Node) (*injectionProcessItem, string, error) { - if len(specs) == 0 { - return nil, "", fmt.Errorf("empty fault injection batch at index %d", batchIndex) - } - - // Extract fault duration - use the maximum duration among all faults in the batch - maxDuration := 0 - nodes := make([]chaos.Node, 0, len(specs)) - - for idx, spec := range specs { - childNode, exists := spec.Children[strconv.Itoa(spec.Value)] - if !exists { - return nil, "", fmt.Errorf("failed to find key %d in the children at index %d", spec.Value, idx) - } - - if len(childNode.Children) < 3 { - return nil, "", fmt.Errorf("no child nodes found for fault spec at index %d", idx) - } - - faultDuration := childNode.Children[consts.DurationNodeKey].Value - if faultDuration > maxDuration { - maxDuration = faultDuration - } - - systemIdx := childNode.Children[consts.SystemNodeKey].Value - system := chaos.GetAllSystemTypes()[systemIdx] - if pedestal != system.String() { - return nil, "", fmt.Errorf("mismatched system type %s for pedestal %s at index %d", system.String(), pedestal, idx) - } - - nodes = append(nodes, spec) - } - - uniqueServices := make(map[string]int, len(nodes)) - var duplicateServiceWarnings []string - for idx, node := range nodes { - conf, err := chaos.NodeToStruct[chaos.InjectionConf](ctx, &node) - if err != nil { - return nil, "", fmt.Errorf("failed to convert node to InjectionConf at index %d: %w", idx, err) - } - - groundtruth, err := conf.GetGroundtruth(ctx) - if err != nil { - return nil, "", fmt.Errorf("failed to get groundtruth from InjectionConf at index %d: %w", idx, err) - } - - for _, service := range groundtruth.Service { - if service != "" { - if oldIdx, exists := uniqueServices[service]; exists { - duplicateServiceWarnings = append(duplicateServiceWarnings, - fmt.Sprintf("service '%s' at positions %d and %d", service, oldIdx, idx)) - continue - } - uniqueServices[service] = idx - } - } - } - - // Sort nodes to ensure consistent ordering - nodes = sortNodes(nodes) - - var warning string - if len(duplicateServiceWarnings) > 0 { - warning = fmt.Sprintf("Batch %d contains duplicate service injections: %s", - batchIndex, strings.Join(duplicateServiceWarnings, "; ")) - } - - return &injectionProcessItem{ - index: batchIndex, - faultDuration: maxDuration, - nodes: nodes, - }, warning, nil -} - -// parseBatchGuidedSpecs parses a single batch of GuidedConfig specs for -// parallel execution. Mirrors parseBatchInjectionSpecs but skips the chaos.Node -// round-trip: each GuidedConfig is resolved to an InjectionConf via -// guidedcli.BuildInjection solely to compute duration, system-type sanity -// check, and groundtruth-service dedup warnings. The returned item carries the -// original GuidedConfigs; the actual BuildInjection call at execute-time lives -// in the consumer so we only pay for it once in the hot path. -func parseBatchGuidedSpecs(ctx context.Context, pedestal string, batchIndex int, configs []guidedcli.GuidedConfig) (*injectionProcessItem, string, error) { - if len(configs) == 0 { - return nil, "", fmt.Errorf("empty guided fault batch at index %d", batchIndex) - } - - maxDuration := 0 - uniqueServices := make(map[string]int, len(configs)) - var duplicateServiceWarnings []string - - for idx, cfg := range configs { - conf, systemType, err := guidedcli.BuildInjection(ctx, cfg) - if err != nil { - return nil, "", fmt.Errorf("failed to build injection from guided config at index %d: %w", idx, err) - } - if pedestal != systemType.String() { - return nil, "", fmt.Errorf("mismatched system type %s for pedestal %s at index %d", systemType.String(), pedestal, idx) - } - - duration := 0 - if cfg.Duration != nil { - duration = *cfg.Duration - } - if duration > maxDuration { - maxDuration = duration - } - - groundtruth, err := conf.GetGroundtruth(ctx) - if err != nil { - return nil, "", fmt.Errorf("failed to get groundtruth from guided config at index %d: %w", idx, err) - } - for _, service := range groundtruth.Service { - if service == "" { - continue - } - if oldIdx, exists := uniqueServices[service]; exists { - duplicateServiceWarnings = append(duplicateServiceWarnings, - fmt.Sprintf("service '%s' at positions %d and %d", service, oldIdx, idx)) - continue - } - uniqueServices[service] = idx - } - } - - var warning string - if len(duplicateServiceWarnings) > 0 { - warning = fmt.Sprintf("Batch %d contains duplicate service injections: %s", - batchIndex, strings.Join(duplicateServiceWarnings, "; ")) - } - - return &injectionProcessItem{ - index: batchIndex, - faultDuration: maxDuration, - guidedConfigs: configs, - }, warning, nil -} - -// flattenYAMLToParameters converts nested YAML map to flat parameter specs -func flattenYAMLToParameters(data map[string]any, prefix string) []dto.ParameterSpec { - var params []dto.ParameterSpec - - for key, value := range data { - fullKey := key - if prefix != "" { - fullKey = prefix + "." + key - } - - switch v := value.(type) { - case map[string]any: - // Recursively flatten nested structures - params = append(params, flattenYAMLToParameters(v, fullKey)...) - case []any: - // Convert array to JSON string - jsonBytes, err := json.Marshal(v) - if err != nil { - logrus.Warnf("Failed to marshal array for key %s: %v", fullKey, err) - continue - } - params = append(params, dto.ParameterSpec{ - Key: fullKey, - Value: string(jsonBytes), - }) - default: - // Primitive values (string, int, bool, etc.) - params = append(params, dto.ParameterSpec{ - Key: fullKey, - Value: v, - }) - } - } - - return params -} - -// removeDuplicated filters out batches that already exist in DB and removes duplicates within the request -func removeDuplicated(items []injectionProcessItem) ([]injectionProcessItem, []int, []int, error) { - engineConfigStrs := make([]string, len(items)) - for i, item := range items { - var payload any - switch { - case len(item.guidedConfigs) > 0: - payload = item.guidedConfigs - case len(item.nodes) > 0: - payload = item.nodes - default: - engineConfigStrs[i] = "" - continue - } - - // Marshal the entire batch as the engine config key - b, err := json.Marshal(payload) - if err != nil { - return nil, nil, nil, fmt.Errorf("failed to marshal engine config at batch index %d: %w", i, err) - } - - engineConfigStrs[i] = string(b) - } - - orderedUniqueIdx := make([]int, 0, len(engineConfigStrs)) - seen := make(map[string]struct{}, len(engineConfigStrs)) - duplicatedInRequest := make([]int, 0) - for i, key := range engineConfigStrs { - if key == "" { - orderedUniqueIdx = append(orderedUniqueIdx, i) - continue - } - if _, ok := seen[key]; ok { - duplicatedInRequest = append(duplicatedInRequest, items[i].index) - continue - } - - seen[key] = struct{}{} - orderedUniqueIdx = append(orderedUniqueIdx, i) - } - - existed := make(map[string]struct{}) - keys := make([]string, 0, len(seen)) - for k := range seen { - if k != "" { - keys = append(keys, k) - } - } - - batchSize := 100 - for start := 0; start < len(keys); start += batchSize { - end := min(start+batchSize, len(keys)) - - batch := keys[start:end] - existing, err := repository.ListExistingEngineConfigs(database.DB, batch) - if err != nil { - return nil, nil, nil, err - } - - for _, v := range existing { - existed[v] = struct{}{} - } - } - - out := make([]injectionProcessItem, 0, len(orderedUniqueIdx)) - alreadyExisted := make([]int, 0) // Track batch indices that already exist in DB - for _, idx := range orderedUniqueIdx { - key := engineConfigStrs[idx] - if key == "" { - out = append(out, items[idx]) - continue - } - if _, ok := existed[key]; ok { - alreadyExisted = append(alreadyExisted, items[idx].index) - continue - } - - items[idx].executeTime = time.Now().Add(time.Duration(idx*2) * time.Second) - out = append(out, items[idx]) - } - - return out, duplicatedInRequest, alreadyExisted, nil -} - -// sortNodes sorts chaos nodes by their Value field and then by their JSON representation for consistency -func sortNodes(nodes []chaos.Node) []chaos.Node { - if len(nodes) <= 1 { - return nodes - } - - // Create a copy to avoid modifying the original slice - sortedNodes := make([]chaos.Node, len(nodes)) - copy(sortedNodes, nodes) - - // Sort nodes by their Value field first, then by serialized representation for consistency - // Using a stable sort to maintain relative order for equal elements - for i := 0; i < len(sortedNodes)-1; i++ { - for j := i + 1; j < len(sortedNodes); j++ { - // Primary sort: by Value field - if sortedNodes[i].Value > sortedNodes[j].Value { - sortedNodes[i], sortedNodes[j] = sortedNodes[j], sortedNodes[i] - continue - } - - // Secondary sort: if Values are equal, sort by JSON representation for consistency - if sortedNodes[i].Value == sortedNodes[j].Value { - iJSON, _ := json.Marshal(sortedNodes[i]) - jJSON, _ := json.Marshal(sortedNodes[j]) - if string(iJSON) > string(jJSON) { - sortedNodes[i], sortedNodes[j] = sortedNodes[j], sortedNodes[i] - } - } - } - } - - return sortedNodes -} - -// buildFileTree recursively builds a tree structure of files and directories -func buildFileTree(workDir, relPath string, baseURL string, datapackID int, resp *dto.DatapackFilesResp) ([]dto.DatapackFileItem, error) { - currentPath := filepath.Join(workDir, relPath) - entries, err := os.ReadDir(currentPath) - if err != nil { - return nil, err - } - - var items []dto.DatapackFileItem - for _, entry := range entries { - itemRelPath := filepath.Join(relPath, entry.Name()) - - fileInfo, err := entry.Info() - if err != nil { - return nil, err - } - - item := dto.DatapackFileItem{ - Name: entry.Name(), - Path: filepath.ToSlash(itemRelPath), - } - - if entry.IsDir() { - children, err := buildFileTree(workDir, itemRelPath, baseURL, datapackID, resp) - if err != nil { - return nil, err - } - item.Children = children - - // Count direct subfolders and files - subFolderCount := 0 - fileCount := 0 - for _, child := range children { - if len(child.Children) > 0 { - subFolderCount++ - } else { - fileCount++ - } - } - - item.Size = fmt.Sprintf("%d subfolders, %d files", subFolderCount, fileCount) - resp.DirCount++ - } else { - fileSize := fileInfo.Size() - item.Size = formatFileSize(fileSize) - modTime := fileInfo.ModTime() - item.ModTime = &modTime - resp.FileCount++ - } - - items = append(items, item) - } - - return items, nil -} - -// formatFileSize formats bytes to human readable format (KB or MB) with one decimal place. -func formatFileSize(bytes int64) string { - const ( - KB = 1024 - MB = 1024 * 1024 - ) - - if bytes < MB { - return fmt.Sprintf("%.1fKB", float64(bytes)/float64(KB)) - } - return fmt.Sprintf("%.1fMB", float64(bytes)/float64(MB)) -} - -func getFileFullPath(datapackID int, filePath string) (string, error) { - datapack, err := repository.GetInjectionByID(database.DB, datapackID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return "", fmt.Errorf("%w: datapack id: %d", consts.ErrNotFound, datapackID) - } - return "", fmt.Errorf("failed to get datapack: %w", err) - } - - if datapack.State < consts.DatapackBuildSuccess { - return "", fmt.Errorf("datapack %d is not ready for download", datapackID) - } - - workDir := filepath.Join(config.GetString("jfs.dataset_path"), datapack.Name) - if !utils.IsAllowedPath(workDir) { - return "", fmt.Errorf("invalid path access to %s", workDir) - } - - cleanPath := filepath.Clean(filePath) - fullPath := filepath.Join(workDir, cleanPath) - - if !strings.HasPrefix(fullPath, workDir) { - return "", fmt.Errorf("invalid file path: path traversal detected") - } - if !utils.IsAllowedPath(fullPath) { - return "", fmt.Errorf("invalid file path access") - } - - fileInfo, err := os.Stat(fullPath) - if err != nil { - if os.IsNotExist(err) { - return "", fmt.Errorf("%w: file not found: %s", consts.ErrNotFound, cleanPath) - } - return "", fmt.Errorf("failed to stat file: %w", err) - } - - if fileInfo.IsDir() { - return "", fmt.Errorf("path is a directory, not a file: %s", cleanPath) - } - - return fullPath, nil -} diff --git a/src/service/producer/label.go b/src/service/producer/label.go deleted file mode 100644 index 0758280f..00000000 --- a/src/service/producer/label.go +++ /dev/null @@ -1,352 +0,0 @@ -package producer - -import ( - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "errors" - "fmt" - - "gorm.io/gorm" -) - -// BatchDeleteLabels deletes multiple labels and their associations in a transaction -func BatchDeleteLabels(labelIDs []int) error { - if len(labelIDs) == 0 { - return nil - } - - return database.DB.Transaction(func(tx *gorm.DB) error { - labels, err := repository.ListLabelsByID(tx, labelIDs) - if err != nil { - return fmt.Errorf("failed to list labels by IDs: %w", err) - } - - if len(labels) == 0 { - return fmt.Errorf("no labels found for the provided IDs") - } - if len(labels) != len(labelIDs) { - return fmt.Errorf("some labels not found for the provided IDs") - } - - labelMap := make(map[int]*database.Label, len(labels)) - for _, label := range labels { - labelMap[label.ID] = &label - } - - containerCountMap, err := removeContainersFromLabels(tx, labelIDs) - if err != nil { - return fmt.Errorf("failed to delete container-label associations: %v", err) - } - - datasetCountMap, err := removeDatasetsFromLabels(tx, labelIDs) - if err != nil { - return fmt.Errorf("failed to delete dataset-label associations: %v", err) - } - - projectCountMap, err := removeProjectsFromLabels(tx, labelIDs) - if err != nil { - return fmt.Errorf("failed to delete project-label associations: %v", err) - } - - injectionCountMap, err := removeInjectionsFromLabels(tx, labelIDs) - if err != nil { - return fmt.Errorf("failed to delete injection-label associations: %v", err) - } - - executionCountMap, err := removeExecutionsFromLabels(tx, labelIDs) - if err != nil { - return fmt.Errorf("failed to delete execution-label associations: %v", err) - } - - toUpdatedLabels := make([]database.Label, 0, len(labelIDs)) - for labelID, label := range labelMap { - totalDecrement := int64(0) - - if count, exists := containerCountMap[labelID]; exists { - totalDecrement += count - } - if count, exists := datasetCountMap[labelID]; exists { - totalDecrement += count - } - if count, exists := projectCountMap[labelID]; exists { - totalDecrement += count - } - if count, exists := injectionCountMap[labelID]; exists { - totalDecrement += count - } - if count, exists := executionCountMap[labelID]; exists { - totalDecrement += count - } - - label.Usage = max(label.Usage-int(totalDecrement), 0) - toUpdatedLabels = append(toUpdatedLabels, *label) - } - - if err := repository.BatchUpdateLabels(tx, toUpdatedLabels); err != nil { - return fmt.Errorf("failed to update label usages: %v", err) - } - - if err := repository.BatchDeleteLabels(tx, labelIDs); err != nil { - return fmt.Errorf("failed to batch delete labels: %v", err) - } - - return nil - }) -} - -// CreateLabel creates a new label or reactivates an existing deleted one -func CreateLabel(req *dto.CreateLabelReq) (*dto.LabelResp, error) { - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("label validation failed: %w", err) - } - - label := req.ConvertToLabel() - - var createdLabel *database.Label - err := database.DB.Transaction(func(tx *gorm.DB) error { - label, err := CreateLabelCore(tx, label) - if err != nil { - return fmt.Errorf("failed to create label: %w", err) - } - - createdLabel = label - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewLabelResp(createdLabel), nil -} - -// CreateLabelCore performs the core logic of creating a label within a transaction -func CreateLabelCore(db *gorm.DB, label *database.Label) (*database.Label, error) { - existingLabel, err := repository.GetLabelByKeyAndValue(db, label.Key, label.Value) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("failed to check existing label: %w", err) - } - - if existingLabel == nil { - if err := repository.CreateLabel(db, label); err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return nil, fmt.Errorf("%w: label with key %s and value %s already exists", consts.ErrAlreadyExists, label.Key, label.Value) - } - return nil, fmt.Errorf("failed to create label: %w", err) - } - - return label, nil - } - - existingLabel.Category = label.Category - existingLabel.Description = label.Description - existingLabel.Color = label.Color - existingLabel.Status = consts.CommonEnabled - - if err := repository.UpdateLabel(db, existingLabel); err != nil { - return nil, fmt.Errorf("failed to update existing label: %w", err) - } - - return existingLabel, nil -} - -// DeleteLabel deletes a label by its ID -func DeleteLabel(labelID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - label, err := repository.GetLabelByID(tx, labelID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: label with id %d not found", consts.ErrNotFound, labelID) - } - return fmt.Errorf("failed to get label: %v", err) - } - - // Delete all related associations - containerRows, err := repository.RemoveContainersFromLabel(tx, label.ID) - if err != nil { - return fmt.Errorf("failed to delete container-label associations: %v", err) - } - - datasetRows, err := repository.RemoveDatasetsFromLabel(tx, label.ID) - if err != nil { - return fmt.Errorf("failed to delete dataset-label associations: %v", err) - } - - projectRows, err := repository.RemoveProjectsFromLabel(tx, label.ID) - if err != nil { - return fmt.Errorf("failed to delete project-label associations: %v", err) - } - - injectionRows, err := repository.RemoveInjectionsFromLabel(tx, label.ID) - if err != nil { - return fmt.Errorf("failed to delete injection-label associations: %v", err) - } - - executionRows, err := repository.RemoveExecutionsFromLabel(tx, label.ID) - if err != nil { - return fmt.Errorf("failed to delete execution-label associations: %v", err) - } - - totalRows := int(containerRows + datasetRows + projectRows + injectionRows + executionRows) - if err := repository.BatchDecreaseLabelUsages(tx, []int{label.ID}, totalRows); err != nil { - return fmt.Errorf("failed to decrease label usage: %v", err) - } - - // Delete the label itself - rows, err := repository.DeleteLabel(tx, labelID) - if err != nil { - return fmt.Errorf("failed to delete label: %w", err) - } - if rows == 0 { - return fmt.Errorf("%w: label id %d not found", consts.ErrNotFound, labelID) - } - - return nil - }) -} - -// GetLabelDetail retrieves detailed information about a label by its ID -func GetLabelDetail(labelID int) (*dto.LabelDetailResp, error) { - label, err := repository.GetLabelByID(database.DB, labelID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("%w: label with ID %d not found", consts.ErrNotFound, labelID) - } - return nil, fmt.Errorf("failed to get label: %w", err) - } - - return dto.NewLabelDetailResp(label), nil -} - -// ListLabels lists labels based on the provided filters -func ListLabels(req *dto.ListLabelReq) (*dto.ListResp[dto.LabelResp], error) { - limit, offset := req.ToGormParams() - fitlerOptions := req.ToFilterOptions() - - labels, total, err := repository.ListLabels(database.DB, limit, offset, fitlerOptions) - if err != nil { - return nil, fmt.Errorf("failed to list labels: %w", err) - } - - labelResps := make([]dto.LabelResp, 0, len(labels)) - for i := range labels { - labelResps = append(labelResps, *dto.NewLabelResp(&labels[i])) - } - - resp := dto.ListResp[dto.LabelResp]{ - Items: labelResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// UpdateLabel updates an existing label's details -func UpdateLabel(req *dto.UpdateLabelReq, labelID int) (*dto.LabelResp, error) { - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - - var updatedLabel *database.Label - - err := database.DB.Transaction(func(tx *gorm.DB) error { - existingLabel, err := repository.GetLabelByID(tx, labelID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("%w: label with ID %d not found", consts.ErrNotFound, labelID) - } - return fmt.Errorf("failed to get label: %w", err) - } - - req.PatchLabelModel(existingLabel) - - if err := repository.UpdateLabel(tx, existingLabel); err != nil { - return fmt.Errorf("failed to update label: %w", err) - } - - updatedLabel = existingLabel - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewLabelResp(updatedLabel), nil -} - -// labelRemovalOps defines the operations needed to remove associations for a specific entity type -type labelRemovalOps struct { - countFunc func(*gorm.DB, []int) (map[int]int64, error) - removeFunc func(*gorm.DB, []int) (int64, error) - entityName string -} - -// removeAssociationsFromLabels is a generic function to remove entity associations from labels -func removeAssociationsFromLabels(db *gorm.DB, labelIDs []int, ops labelRemovalOps) (map[int]int64, error) { - if len(labelIDs) == 0 { - return nil, nil - } - - countsMap, err := ops.countFunc(db, labelIDs) - if err != nil { - return nil, fmt.Errorf("failed to get %s-label counts: %w", ops.entityName, err) - } - if len(countsMap) == 0 { - return nil, nil - } - - rows, err := ops.removeFunc(db, labelIDs) - if err != nil { - return nil, fmt.Errorf("failed to remove %ss from labels: %w", ops.entityName, err) - } - if rows == 0 { - return nil, nil - } - - return countsMap, nil -} - -// removeContainersFromLabels removes container associations from multiple labels and returns the total usage count removed -func removeContainersFromLabels(db *gorm.DB, labelIDs []int) (map[int]int64, error) { - return removeAssociationsFromLabels(db, labelIDs, labelRemovalOps{ - countFunc: repository.ListContainerLabelCounts, - removeFunc: repository.RemoveContainersFromLabels, - entityName: "container", - }) -} - -// removeDatasetsFromLabels removes dataset associations from multiple labels and returns the count map -func removeDatasetsFromLabels(db *gorm.DB, labelIDs []int) (map[int]int64, error) { - return removeAssociationsFromLabels(db, labelIDs, labelRemovalOps{ - countFunc: repository.ListDatasetLabelCounts, - removeFunc: repository.RemoveDatasetsFromLabels, - entityName: "dataset", - }) -} - -// removeProjectsFromLabels removes project associations from multiple labels and returns the count map -func removeProjectsFromLabels(db *gorm.DB, labelIDs []int) (map[int]int64, error) { - return removeAssociationsFromLabels(db, labelIDs, labelRemovalOps{ - countFunc: repository.ListProjectLabelCounts, - removeFunc: repository.RemoveProjectsFromLabels, - entityName: "project", - }) -} - -// removeInjectionsFromLabels removes injection associations from multiple labels and returns the count map -func removeInjectionsFromLabels(db *gorm.DB, labelIDs []int) (map[int]int64, error) { - return removeAssociationsFromLabels(db, labelIDs, labelRemovalOps{ - countFunc: repository.ListInjectionLabelCounts, - removeFunc: repository.RemoveInjectionsFromLabels, - entityName: "injection", - }) -} - -// removeExecutionsFromLabels removes execution associations from multiple labels and returns the count map -func removeExecutionsFromLabels(db *gorm.DB, labelIDs []int) (map[int]int64, error) { - return removeAssociationsFromLabels(db, labelIDs, labelRemovalOps{ - countFunc: repository.ListExecutionLabelCounts, - removeFunc: repository.RemoveExecutionsFromLabels, - entityName: "execution", - }) -} diff --git a/src/service/producer/notification.go b/src/service/producer/notification.go deleted file mode 100644 index ee4cee7e..00000000 --- a/src/service/producer/notification.go +++ /dev/null @@ -1,23 +0,0 @@ -package producer - -import ( - "aegis/client" - "context" - "fmt" - "time" - - "github.com/redis/go-redis/v9" -) - -// ReadNotificationStreamMessages reads messages from the notification stream -func ReadNotificationStreamMessages(ctx context.Context, streamKey, lastID string, count int64, block time.Duration) ([]redis.XStream, error) { - if lastID == "" { - lastID = "0" - } - - messages, err := client.RedisXRead(ctx, []string{streamKey, lastID}, count, block) - if err != nil { - return nil, fmt.Errorf("failed to read notification stream messages: %w", err) - } - return messages, nil -} diff --git a/src/service/producer/permission.go b/src/service/producer/permission.go deleted file mode 100644 index 1434b3e3..00000000 --- a/src/service/producer/permission.go +++ /dev/null @@ -1,114 +0,0 @@ -package producer - -import ( - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "errors" - "fmt" - - "gorm.io/gorm" -) - -// CheckUserPermission checks if user has specific permission using a params struct -func CheckUserPermission(params *dto.CheckPermissionParams) (bool, error) { - if err := params.Validate(); err != nil { - return false, fmt.Errorf("invalid request: %w", err) - } - - permission, err := repository.GetPermissionByActionAndResource(database.DB, params.Action, params.Scope, params.ResourceName) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return false, fmt.Errorf("failed to find target permission: %w", err) - } - - return repository.CheckUserHasPermission(database.DB, params, permission.ID) -} - -// GetPermissionDetail retrieves detailed information about a permission by its ID -func GetPermissionDetail(permissionID int) (*dto.PermissionDetailResp, error) { - permission, err := repository.GetPermissionByID(database.DB, permissionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: permission not found", consts.ErrNotFound) - } - return nil, fmt.Errorf("failed to get permission: %w", err) - } - - return dto.NewPermissionDetailResp(permission), nil -} - -// ListPermissions lists permissions based on the provided request parameters -func ListPermissions(req *dto.ListPermissionReq) (*dto.ListResp[dto.PermissionResp], error) { - limit, offset := req.ToGormParams() - - permissions, total, err := repository.ListPermissions(database.DB, limit, offset, req.Action, req.IsSystem, req.Status) - if err != nil { - return nil, fmt.Errorf("failed to list roles: %w", err) - } - - permissionResps := make([]dto.PermissionResp, len(permissions)) - for i, permission := range permissions { - permissionResps[i] = *dto.NewPermissionResp(&permission) - } - - resp := dto.ListResp[dto.PermissionResp]{ - Items: permissionResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -func ListRolesFromPermission(permissionID int) ([]dto.RoleResp, error) { - permission, err := repository.GetPermissionByID(database.DB, permissionID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: permission not found", consts.ErrNotFound) - } - return nil, fmt.Errorf("failed to get permission: %w", err) - } - - roles, err := repository.ListRolesByPermissionID(database.DB, permission.ID) - if err != nil { - return nil, fmt.Errorf("failed to get permission roles: %w", err) - } - - var roleResps []dto.RoleResp - for _, role := range roles { - roleResps = append(roleResps, *dto.NewRoleResp(&role)) - } - - return roleResps, nil -} - -// fetchPermissionsMapByIDBatch fetches permissions by their IDs and returns a map of permission ID to Permission -func fetchPermissionsMapByIDBatch(db *gorm.DB, permissionIDs []int) (map[int]database.Permission, error) { - if len(permissionIDs) == 0 { - return make(map[int]database.Permission), nil - } - - uniqueIDs := make(map[int]struct{}) - for _, id := range permissionIDs { - uniqueIDs[id] = struct{}{} - } - - deduplicatedIDs := make([]int, 0, len(uniqueIDs)) - for id := range uniqueIDs { - deduplicatedIDs = append(deduplicatedIDs, id) - } - - permissions, err := repository.ListPermissionsByID(db, deduplicatedIDs) - if err != nil { - return nil, fmt.Errorf("failed to list permissions by IDs: %w", err) - } - - permissionMap := make(map[int]database.Permission, len(permissions)) - for _, perm := range permissions { - permissionMap[perm.ID] = perm - } - - return permissionMap, nil -} diff --git a/src/service/producer/project.go b/src/service/producer/project.go deleted file mode 100644 index d051e2cc..00000000 --- a/src/service/producer/project.go +++ /dev/null @@ -1,399 +0,0 @@ -package producer - -import ( - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "aegis/service/common" - "aegis/utils" - "errors" - "fmt" - - "gorm.io/gorm" -) - -// CreateProject handles the business logic for creating a new project -func CreateProject(req *dto.CreateProjectReq, userID int) (*dto.ProjectResp, error) { - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - - project := req.ConvertToProject() - - var createdProject *database.Project - err := database.DB.Transaction(func(tx *gorm.DB) error { - role, err := repository.GetRoleByName(tx, consts.RoleProjectAdmin.String()) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: role %v not found", err, consts.RoleProjectAdmin) - } - return fmt.Errorf("failed to get project owner role: %w", err) - } - - if err := repository.CreateProject(tx, project); err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return fmt.Errorf("%w: project with name %s already exists", consts.ErrAlreadyExists, project.Name) - } - return err - } - - if err := repository.CreateUserProject(tx, &database.UserProject{ - UserID: userID, - ProjectID: project.ID, - RoleID: role.ID, - Status: consts.CommonEnabled, - }); err != nil { - return fmt.Errorf("failed to assign project owner: %w", err) - } - - createdProject = project - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewProjectResp(createdProject, nil), nil -} - -// DeleteProject deletes an existing project by marking its status as deleted -func DeleteProject(projectID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - if _, err := repository.RemoveUsersFromProject(tx, projectID); err != nil { - return fmt.Errorf("failed to remove users from project: %w", err) - } - - rows, err := repository.DeleteProject(tx, projectID) - if err != nil { - return fmt.Errorf("failed to delete project: %w", err) - } - if rows == 0 { - return fmt.Errorf("%w: project id %d not found", consts.ErrNotFound, projectID) - } - - return nil - }) -} - -// GetProjectDetail retrieves detailed information about a project by its ID -func GetProjectDetail(projectID int) (*dto.ProjectDetailResp, error) { - project, err := repository.GetProjectByID(database.DB, projectID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: project with ID %d not found", consts.ErrNotFound, projectID) - } - return nil, fmt.Errorf("failed to get project: %w", err) - } - - // Get project statistics - statsMap, err := repository.BatchGetProjectStatistics(database.DB, []int{project.ID}) - if err != nil { - return nil, fmt.Errorf("failed to get project statistics: %w", err) - } - - stats := statsMap[project.ID] - resp := dto.NewProjectDetailResp(project, stats) - - userCount, err := repository.GetProjectUserCount(database.DB, project.ID) - if err != nil { - return nil, fmt.Errorf("failed to get project user count: %w", err) - } - resp.UserCount = userCount - - // TODO add more project details if needed (container, dataset, etc.) - - return resp, nil -} - -// ListProjects lists projects based on the provided filters -func ListProjects(req *dto.ListProjectReq) (*dto.ListResp[dto.ProjectResp], error) { - if req == nil { - return nil, fmt.Errorf("list project request is nil") - } - - limit, offset := req.ToGormParams() - - projects, total, err := repository.ListProjects(database.DB, limit, offset, req.IsPublic, req.Status) - if err != nil { - return nil, fmt.Errorf("failed to list projects: %w", err) - } - - projectIDs := make([]int, 0, len(projects)) - for _, p := range projects { - projectIDs = append(projectIDs, p.ID) - } - - labelsMap, err := repository.ListProjectLabels(database.DB, projectIDs) - if err != nil { - return nil, fmt.Errorf("failed to list project labels: %w", err) - } - - // Batch get statistics for all projects - statsMap, err := repository.BatchGetProjectStatistics(database.DB, projectIDs) - if err != nil { - return nil, fmt.Errorf("failed to batch get project statistics: %w", err) - } - - projectResps := make([]dto.ProjectResp, 0, len(projects)) - for i := range projects { - // Convert repository stats to dto stats - var stats *dto.ProjectStatistics - if repoStats, exists := statsMap[projects[i].ID]; exists { - stats = &dto.ProjectStatistics{ - InjectionCount: repoStats.InjectionCount, - ExecutionCount: repoStats.ExecutionCount, - LastInjectionAt: repoStats.LastInjectionAt, - LastExecutionAt: repoStats.LastExecutionAt, - } - } - - if labels, exists := labelsMap[projects[i].ID]; exists { - projects[i].Labels = labels - } - projectResps = append(projectResps, *dto.NewProjectResp(&projects[i], stats)) - } - - resp := dto.ListResp[dto.ProjectResp]{ - Items: projectResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// UpdateProject updates an existing project's details -func UpdateProject(req *dto.UpdateProjectReq, projectID int) (*dto.ProjectResp, error) { - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - - var updatedProject *database.Project - - err := database.DB.Transaction(func(tx *gorm.DB) error { - existingProject, err := repository.GetProjectByID(tx, projectID) - if err != nil { - return fmt.Errorf("failed to get project: %w", err) - } - - req.PatchProjectModel(existingProject) - - if err := repository.UpdateProject(tx, existingProject); err != nil { - return fmt.Errorf("failed to update project: %w", err) - } - - updatedProject = existingProject - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewProjectResp(updatedProject, nil), nil -} - -// ===================== Project-Label ===================== - -// ManageProjectLabels manages project labels (key-value pairs) -func ManageProjectLabels(req *dto.ManageProjectLabelReq, projectID int) (*dto.ProjectResp, error) { - if req == nil { - return nil, fmt.Errorf("manage project labels request is nil") - } - - var managedProject *database.Project - err := database.DB.Transaction(func(tx *gorm.DB) error { - project, err := repository.GetProjectByID(tx, projectID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: project id: %d", consts.ErrNotFound, projectID) - } - return fmt.Errorf("failed to get project: %w", err) - } - - if len(req.AddLabels) > 0 { - labels, err := common.CreateOrUpdateLabelsFromItems(tx, req.AddLabels, consts.ProjectCategory) - if err != nil { - return fmt.Errorf("failed to create or update labels: %w", err) - } - - projectLabels := make([]database.ProjectLabel, 0, len(labels)) - for _, label := range labels { - projectLabels = append(projectLabels, database.ProjectLabel{ - ProjectID: projectID, - LabelID: label.ID, - }) - } - - if err := repository.AddProjectLabels(tx, projectLabels); err != nil { - return fmt.Errorf("failed to add project labels: %w", err) - } - } - - if len(req.RemoveLabels) > 0 { - labelIDs, err := repository.ListLabelIDsByKeyAndProjectID(tx, projectID, req.RemoveLabels) - if err != nil { - return fmt.Errorf("failed to find label ids by keys: %w", err) - } - - if len(labelIDs) == 0 { - if err := repository.ClearProjectLabels(tx, []int{projectID}, labelIDs); err != nil { - return fmt.Errorf("failed to clear project labels: %w", err) - } - - if err := repository.BatchDecreaseLabelUsages(tx, labelIDs, 1); err != nil { - return fmt.Errorf("failed to decrease label usage counts: %w", err) - } - } - } - - labels, err := repository.ListLabelsByProjectID(database.DB, project.ID) - if err != nil { - return fmt.Errorf("failed to get project labels: %w", err) - } - - project.Labels = labels - managedProject = project - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewProjectResp(managedProject, nil), nil -} - -func fetchProjectsMapByIDBatch(db *gorm.DB, projectIDs []int) (map[int]database.Project, error) { - if len(projectIDs) == 0 { - return make(map[int]database.Project), nil - } - - projects, err := repository.ListProjectsByID(db, utils.ToUniqueSlice(projectIDs)) - if err != nil { - return nil, fmt.Errorf("failed to list projects by IDs: %w", err) - } - - projectMap := make(map[int]database.Project, len(projectIDs)) - for _, p := range projects { - projectMap[p.ID] = p - } - - return projectMap, nil -} - -// ===================== Project-Injection ===================== - -// ListProjectInjections lists all fault injections for a specific project -func ListProjectInjections(req *dto.ListInjectionReq, projectID int) (*dto.ListResp[dto.InjectionResp], error) { - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - - // Verify project exists - if _, err := repository.GetProjectByID(database.DB, projectID); err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: project id %d not found", consts.ErrNotFound, projectID) - } - return nil, fmt.Errorf("failed to get project: %w", err) - } - - limit, offset := req.ToGormParams() - - injections, total, err := repository.ListInjectionsByProjectID(database.DB, projectID, limit, offset) - if err != nil { - return nil, fmt.Errorf("failed to list injections for project %d: %w", projectID, err) - } - - injectionResps := make([]dto.InjectionResp, 0, len(injections)) - for _, injection := range injections { - injectionResps = append(injectionResps, *dto.NewInjectionResp(&injection)) - } - - resp := dto.ListResp[dto.InjectionResp]{ - Items: injectionResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// ===================== Project-Execution ===================== - -// ListProjectExecutions lists all algorithm executions for a specific project -func ListProjectExecutions(req *dto.ListExecutionReq, projectID int) (*dto.ListResp[dto.ExecutionResp], error) { - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - - // Verify project exists - if _, err := repository.GetProjectByID(database.DB, projectID); err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: project id %d not found", consts.ErrNotFound, projectID) - } - return nil, fmt.Errorf("failed to get project: %w", err) - } - - limit, offset := req.ToGormParams() - - executions, total, err := repository.ListExecutionsByProjectID(database.DB, projectID, limit, offset) - if err != nil { - return nil, fmt.Errorf("failed to list executions for project %d: %w", projectID, err) - } - - executionResps := make([]dto.ExecutionResp, 0, len(executions)) - for _, execution := range executions { - executionResps = append(executionResps, *dto.NewExecutionResp(&execution, nil)) - } - - resp := dto.ListResp[dto.ExecutionResp]{ - Items: executionResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// ============================================================================ -// Project Permission Check Helper Functions (exported for middleware) -// ============================================================================ - -// IsUserInProject checks if a user is a member of a project -func IsUserInProject(userID int, projectID int) (bool, error) { - up, err := repository.GetUserProjectRole(database.DB, userID, projectID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return false, err - } - return up != nil, nil -} - -// IsUserProjectAdmin checks if a user has project admin role in a specific project -func IsUserProjectAdmin(userID int, projectID int) (bool, error) { - up, err := repository.GetUserProjectRole(database.DB, userID, projectID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return false, err - } - return up != nil && up.Role != nil && up.Role.Name == consts.RoleProjectAdmin.String(), nil -} - -// IsProjectPublic checks if a project is publicly accessible -func IsProjectPublic(projectID int) (bool, error) { - project, err := repository.GetProjectByID(database.DB, projectID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return false, err - } - return project.IsPublic, nil -} - -// GetProjectTeamID gets the team ID for a project -func GetProjectTeamID(projectID int) (int, error) { - teamID, err := repository.GetProjectTeamID(database.DB, projectID) - if err != nil { - return 0, err - } - return teamID, nil -} diff --git a/src/service/producer/query_datapack_noarrow.go b/src/service/producer/query_datapack_noarrow.go deleted file mode 100644 index b7378a62..00000000 --- a/src/service/producer/query_datapack_noarrow.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build !duckdb_arrow - -package producer - -import ( - "context" - "fmt" - "io" -) - -// QueryDatapackFileContent requires the duckdb_arrow build tag because duckdb's Arrow API -// is compiled behind that tag in github.com/duckdb/duckdb-go/v2. -func QueryDatapackFileContent(ctx context.Context, datapackID int, filePath string) (string, int64, io.ReadCloser, error) { - return "", 0, nil, fmt.Errorf("QueryDatapackFileContent requires building with -tags duckdb_arrow") -} diff --git a/src/service/producer/rate_limiter_test.go b/src/service/producer/rate_limiter_test.go deleted file mode 100644 index 375efe6f..00000000 --- a/src/service/producer/rate_limiter_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package producer - -import ( - "context" - "testing" - "time" - - "aegis/consts" - "aegis/database" - - "github.com/alicebob/miniredis/v2" - "github.com/redis/go-redis/v9" - "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -func newTestDB(t *testing.T) *gorm.DB { - t.Helper() - db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&database.Task{})) - return db -} - -func newTestRedis(t *testing.T) *redis.Client { - t.Helper() - mr, err := miniredis.Run() - require.NoError(t, err) - t.Cleanup(mr.Close) - return redis.NewClient(&redis.Options{Addr: mr.Addr()}) -} - -// Regression per OperationsPAI/aegis#21: a bucket with 2 holders, one -// terminal, must end with exactly 1 holder after GC. -func TestGCRateLimiters_ReleasesTerminalHolders(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - db := newTestDB(t) - rdb := newTestRedis(t) - - require.NoError(t, db.Create(&database.Task{ID: "running-task-1", State: consts.TaskRunning}).Error) - require.NoError(t, db.Create(&database.Task{ID: "done-task-1", State: consts.TaskCompleted}).Error) - - bucket := consts.RestartPedestalTokenBucket - _, err := rdb.SAdd(ctx, bucket, "running-task-1", "done-task-1").Result() - require.NoError(t, err) - require.Equal(t, int64(2), rdb.SCard(ctx, bucket).Val()) - - released, touched, err := gcRateLimitersWith(ctx, rdb, db, map[string]int{ - bucket: consts.MaxConcurrentRestartPedestal, - }) - require.NoError(t, err) - require.Equal(t, 1, released) - require.Equal(t, 1, touched) - - members, err := rdb.SMembers(ctx, bucket).Result() - require.NoError(t, err) - require.Equal(t, []string{"running-task-1"}, members) -} - -// Regression: the rate-limiter's task-done path must release the token. -// Exercises the same SRem call used by TokenBucketRateLimiter.ReleaseToken -// in service/consumer/rate_limiter.go:120. -func TestRateLimiterReleaseToken_RegressionGuard(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - rdb := newTestRedis(t) - bucket := consts.RestartPedestalTokenBucket - - _, err := rdb.SAdd(ctx, bucket, "task-42").Result() - require.NoError(t, err) - require.Equal(t, int64(1), rdb.SCard(ctx, bucket).Val()) - - n, err := rdb.SRem(ctx, bucket, "task-42").Result() - require.NoError(t, err) - require.Equal(t, int64(1), n) - require.Equal(t, int64(0), rdb.SCard(ctx, bucket).Val()) -} - -func TestGCRateLimiters_ReleasesMissingTasks(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - db := newTestDB(t) - rdb := newTestRedis(t) - bucket := consts.RestartPedestalTokenBucket - _, err := rdb.SAdd(ctx, bucket, "ghost-task").Result() - require.NoError(t, err) - released, touched, err := gcRateLimitersWith(ctx, rdb, db, map[string]int{ - bucket: consts.MaxConcurrentRestartPedestal, - }) - require.NoError(t, err) - require.Equal(t, 1, released) - require.Equal(t, 1, touched) -} - -func TestGCRateLimiters_NoLeaks(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - db := newTestDB(t) - rdb := newTestRedis(t) - require.NoError(t, db.Create(&database.Task{ID: "running-task-1", State: consts.TaskRunning}).Error) - require.NoError(t, db.Create(&database.Task{ID: "pending-task-1", State: consts.TaskPending}).Error) - bucket := consts.BuildContainerTokenBucket - _, err := rdb.SAdd(ctx, bucket, "running-task-1", "pending-task-1").Result() - require.NoError(t, err) - released, touched, err := gcRateLimitersWith(ctx, rdb, db, map[string]int{ - bucket: consts.MaxConcurrentBuildContainer, - }) - require.NoError(t, err) - require.Equal(t, 0, released) - require.Equal(t, 0, touched) -} - -func TestIsTerminalState(t *testing.T) { - require.True(t, isTerminalState(consts.TaskCompleted)) - require.True(t, isTerminalState(consts.TaskError)) - require.True(t, isTerminalState(consts.TaskCancelled)) - require.False(t, isTerminalState(consts.TaskRunning)) - require.False(t, isTerminalState(consts.TaskPending)) - require.False(t, isTerminalState(consts.TaskRescheduled)) -} diff --git a/src/service/producer/relation.go b/src/service/producer/relation.go deleted file mode 100644 index d436e517..00000000 --- a/src/service/producer/relation.go +++ /dev/null @@ -1,515 +0,0 @@ -package producer - -import ( - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "errors" - "fmt" - - "gorm.io/gorm" -) - -// ===================== User-Role ===================== - -// AssignRoleToUser assigns a role to a user -func AssignRoleToUser(userID, roleID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - user, err := repository.GetUserByID(tx, userID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: user not found", consts.ErrNotFound) - } - return err - } - - role, err := repository.GetRoleByID(tx, roleID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: role not found", consts.ErrNotFound) - } - return err - } - - // Assign role to user - if err := repository.CreateUserRole(tx, &database.UserRole{ - UserID: user.ID, - RoleID: role.ID, - }); err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return fmt.Errorf("%w: user already has this role", consts.ErrAlreadyExists) - } - return err - } - - return nil - }) -} - -// RemoveRoleFromUser removes a role from a user -func RemoveRoleFromUser(userID, roleID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - user, err := repository.GetUserByID(tx, userID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: user not found", consts.ErrNotFound) - } - return err - } - - role, err := repository.GetRoleByID(tx, roleID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: role not found", consts.ErrNotFound) - } - return err - } - - if err := repository.DeleteUserRole(tx, user.ID, role.ID); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("%w: failed to delete user role association (%d, %d)", err, userID, roleID) - } - return err - } - return nil - }) -} - -// ===================== User-Permission ===================== - -// BatchAssignUserPermissions assigns multiple permissions to a user -func BatchAssignUserPermissions(req *dto.AssignUserPermissionReq, userID int) error { - permissionIDs := make([]int, len(req.Items)) - for i, up := range req.Items { - permissionIDs[i] = up.PermissionID - } - - containerIDs := make([]int, 0, len(req.Items)) - datasetIDs := make([]int, 0, len(req.Items)) - projectIDs := make([]int, 0, len(req.Items)) - for _, item := range req.Items { - if item.ContainerID != nil { - containerIDs = append(containerIDs, *item.ContainerID) - } - if item.DatasetID != nil { - datasetIDs = append(datasetIDs, *item.DatasetID) - } - if item.ProjectID != nil { - projectIDs = append(projectIDs, *item.ProjectID) - } - } - - return database.DB.Transaction(func(tx *gorm.DB) error { - user, err := repository.GetUserByID(tx, userID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: user not found", consts.ErrNotFound) - } - return err - } - - permissionResults, err := fetchPermissionsMapByIDBatch(tx, permissionIDs) - if err != nil { - return fmt.Errorf("failed to fetch permissions: %w", err) - } - - containerResults, err := fetchContainersMapByIDBatch(tx, containerIDs) - if err != nil { - return fmt.Errorf("failed to fetch containers: %w", err) - } - - datasetResults, err := fetchDatasetsMapByIDBatch(tx, datasetIDs) - if err != nil { - return fmt.Errorf("failed to fetch datasets: %w", err) - } - - projectResults, err := fetchProjectsMapByIDBatch(tx, projectIDs) - if err != nil { - return fmt.Errorf("failed to fetch projects: %w", err) - } - - var userPermissons []database.UserPermission - for _, item := range req.Items { - if _, exists := permissionResults[item.PermissionID]; !exists { - return fmt.Errorf("%w: permission id %d not found", consts.ErrNotFound, item.PermissionID) - } - - if item.ContainerID != nil { - if _, exists := containerResults[*item.ContainerID]; !exists { - return fmt.Errorf("%w: container id %d not found", consts.ErrNotFound, *item.ContainerID) - } - } - - if item.DatasetID != nil { - if _, exists := datasetResults[*item.DatasetID]; !exists { - return fmt.Errorf("%w: dataset id %d not found", consts.ErrNotFound, *item.DatasetID) - } - } - - if item.ProjectID != nil { - if _, exists := projectResults[*item.ProjectID]; !exists { - return fmt.Errorf("%w: project id %d not found", consts.ErrNotFound, *item.ProjectID) - } - } - - userPermisson := item.ConvertToUserPermission() - userPermisson.UserID = user.ID - userPermissons = append(userPermissons, *userPermisson) - } - - if err := repository.BatchCreateUserPermissions(tx, userPermissons); err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return fmt.Errorf("%w: user already has one or more of these permissions", consts.ErrAlreadyExists) - } - return fmt.Errorf("failed to assgin permissions to user: %w", err) - } - - return nil - }) -} - -// BatchRemoveUserPermissions removes multiple permissions from a user -func BatchRemoveUserPermissions(req *dto.RemoveUserPermissionReq, userID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - user, err := repository.GetUserByID(tx, userID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: user not found", consts.ErrNotFound) - } - return err - } - - permissionResults, err := fetchPermissionsMapByIDBatch(tx, req.PermissionIDs) - if err != nil { - return fmt.Errorf("failed to fetch permissions: %w", err) - } - - for _, permissionID := range req.PermissionIDs { - if _, exists := permissionResults[permissionID]; !exists { - return fmt.Errorf("%w: permission id %d not found", consts.ErrNotFound, permissionID) - } - } - - if err := repository.BatchDeleteUserPermisssions(tx, user.ID, req.PermissionIDs); err != nil { - return fmt.Errorf("") - } - return nil - }) -} - -// ===================== Role-Permission ===================== - -// AssginPermissionsToRole assigns multiple permissions to a role -func BatchAssignRolePermissions(permissionIDs []int, roleID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - role, err := repository.GetRoleByID(tx, roleID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: role not found", consts.ErrNotFound) - } - return err - } - - if role.IsSystem { - return fmt.Errorf("%w: cannot assign permissions to system role", consts.ErrPermissionDenied) - } - - permissionResults, err := fetchPermissionsMapByIDBatch(tx, permissionIDs) - if err != nil { - return fmt.Errorf("failed to fetch permissions: %w", err) - } - - var rolePermissions []database.RolePermission - for _, permissionID := range permissionIDs { - if _, exists := permissionResults[permissionID]; !exists { - return fmt.Errorf("%w: permission id %d not found", consts.ErrNotFound, permissionID) - } - - rolePermissions = append(rolePermissions, database.RolePermission{ - RoleID: role.ID, - PermissionID: permissionID, - }) - } - - if err := repository.BatchCreateRolePermissions(tx, rolePermissions); err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return fmt.Errorf("%w: role already has one or more of these permissions", consts.ErrAlreadyExists) - } - return fmt.Errorf("failed to assign permissions to role: %w", err) - } - - return nil - }) -} - -// RemovePermissionsFromRole removes permissions from a role -func RemovePermissionsFromRole(permissionIDs []int, roleID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - role, err := repository.GetRoleByID(tx, roleID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: role not found", consts.ErrNotFound) - } - return err - } - - if role.IsSystem { - return fmt.Errorf("%w: cannot remove permissions of system role", consts.ErrPermissionDenied) - } - - if err := repository.BatchDeleteRolePermisssions(tx, roleID, permissionIDs); err != nil { - return fmt.Errorf("") - } - - return nil - }) -} - -// ListUsersFromRole lists users assigned to a specific role -func ListUsersFromRole(roleID int) ([]dto.UserResp, error) { - role, err := repository.GetRoleByID(database.DB, roleID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: role not found", consts.ErrNotFound) - } - return nil, err - } - - users, err := repository.ListUsersByRoleID(database.DB, role.ID) - if err != nil { - return nil, fmt.Errorf("failed to get role users: %w", err) - } - - var userResps []dto.UserResp - for _, user := range users { - userResps = append(userResps, *dto.NewUserResp(&user)) - } - - return userResps, nil -} - -// ===================== User-Container ===================== - -// AssignContainerToUser assigns a user to a container with a specific role -func AssignContainerToUser(userID, containerID, roleID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - user, err := repository.GetUserByID(tx, userID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: user not found", consts.ErrNotFound) - } - return err - } - - container, err := repository.GetContainerByID(tx, containerID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: container not found", consts.ErrNotFound) - } - return err - } - - role, err := repository.GetRoleByID(tx, roleID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: role not found", consts.ErrNotFound) - } - return err - } - - if err := repository.CreateUserContainer(tx, &database.UserContainer{ - UserID: user.ID, - ContainerID: container.ID, - RoleID: role.ID, - }); err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return fmt.Errorf("%w: user already assigned to this container", consts.ErrAlreadyExists) - } - return err - } - - return nil - }) -} - -// RemoveContainerFromUser removes a user from a container -func RemoveContainerFromUser(userID, containerID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - user, err := repository.GetUserByID(tx, userID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: user not found", consts.ErrNotFound) - } - return err - } - - container, err := repository.GetContainerByID(tx, containerID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: container not found", consts.ErrNotFound) - } - return err - } - - row, err := repository.DeleteUserContainer(tx, user.ID, container.ID) - if err != nil { - return fmt.Errorf("failed to remove user from container: %w", err) - } - if row == 0 { - return fmt.Errorf("%w: user is not assigned to this container", consts.ErrNotFound) - } - - return nil - }) -} - -// ===================== User-Dataset ===================== - -// AssignDatasetToUser assigns a user to a dataset with a specific role -func AssignDatasetToUser(userID, datasetID, roleID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - user, err := repository.GetUserByID(tx, userID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: user not found", consts.ErrNotFound) - } - return err - } - - dataset, err := repository.GetDatasetByID(tx, datasetID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: dataset not found", consts.ErrNotFound) - } - return err - } - - role, err := repository.GetRoleByID(tx, roleID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: role not found", consts.ErrNotFound) - } - return err - } - - if err := repository.CreateUserDataset(tx, &database.UserDataset{ - UserID: user.ID, - DatasetID: dataset.ID, - RoleID: role.ID, - }); err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return fmt.Errorf("%w: user already assigned to this dataset", consts.ErrAlreadyExists) - } - return err - } - - return nil - }) -} - -// RemoveDatasetFromUser removes a user from a dataset -func RemoveDatasetFromUser(userID, datasetID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - user, err := repository.GetUserByID(tx, userID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: user not found", consts.ErrNotFound) - } - return err - } - - dataset, err := repository.GetDatasetByID(tx, datasetID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: dataset not found", consts.ErrNotFound) - } - return err - } - - row, err := repository.DeleteUserDataset(tx, user.ID, dataset.ID) - if err != nil { - return fmt.Errorf("failed to remove user from dataset: %w", err) - } - if row == 0 { - return fmt.Errorf("%w: user is not assigned to this dataset", consts.ErrNotFound) - } - - return nil - }) -} - -// ===================== User-Project ===================== - -// AssignProjectToUser assigns a user to a project with a specific role -func AssignProjectToUser(userID, projectID, roleID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - user, err := repository.GetUserByID(tx, userID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: user not found", consts.ErrNotFound) - } - return err - } - - project, err := repository.GetProjectByID(tx, projectID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: project not found", consts.ErrNotFound) - } - return err - } - - role, err := repository.GetRoleByID(tx, roleID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: role not found", consts.ErrNotFound) - } - return err - } - - if err := repository.CreateUserProject(tx, &database.UserProject{ - UserID: user.ID, - ProjectID: project.ID, - RoleID: role.ID, - }); err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return fmt.Errorf("%w: user already assigned to this project", consts.ErrAlreadyExists) - } - return err - } - - return nil - }) -} - -// RemoveProjectFromUser removes a user from a project -func RemoveProjectFromUser(userID, projectID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - user, err := repository.GetUserByID(tx, userID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: user not found", consts.ErrNotFound) - } - return err - } - - project, err := repository.GetProjectByID(tx, projectID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: project not found", consts.ErrNotFound) - } - return err - } - - row, err := repository.DeleteUserProject(tx, project.ID, user.ID) - if err != nil { - return fmt.Errorf("failed to remove user from project: %w", err) - } - if row == 0 { - return fmt.Errorf("%w: user is not assigned to this project", consts.ErrNotFound) - } - - return nil - }) -} diff --git a/src/service/producer/resource.go b/src/service/producer/resource.go deleted file mode 100644 index d240d39e..00000000 --- a/src/service/producer/resource.go +++ /dev/null @@ -1,69 +0,0 @@ -package producer - -import ( - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "errors" - "fmt" - - "gorm.io/gorm" -) - -// GetResourceDetail retrieves detailed information about a resource by its ID -func GetResourceDetail(resourceID int) (*dto.ResourceResp, error) { - resource, err := repository.GetResourceByID(database.DB, resourceID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("%w: resource with ID %d not found", consts.ErrNotFound, resourceID) - } - return nil, fmt.Errorf("failed to get resource: %w", err) - } - - return dto.NewResourceResp(resource), nil -} - -// ListResources lists resources based on the provided filters -func ListResources(req *dto.ListResourceReq) (*dto.ListResp[dto.ResourceResp], error) { - limit, offset := req.ToGormParams() - - resources, total, err := repository.ListResources(database.DB, limit, offset, req.Type, req.Category) - if err != nil { - return nil, fmt.Errorf("failed to list resources: %w", err) - } - - resourceResps := make([]dto.ResourceResp, 0, len(resources)) - for i := range resources { - resourceResps = append(resourceResps, *dto.NewResourceResp(&resources[i])) - } - - resp := dto.ListResp[dto.ResourceResp]{ - Items: resourceResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// ListResourcePermissions lists permissions associated with a specific resource -func ListResourcePermissions(resourceID int) ([]dto.PermissionResp, error) { - resource, err := repository.GetResourceByID(database.DB, resourceID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: resource with ID %d not found", consts.ErrNotFound, resourceID) - } - return nil, err - } - - permissions, err := repository.GetPermissionsByResource(database.DB, resource.ID) - if err != nil { - return nil, err - } - - var permissionResps []dto.PermissionResp - for _, permission := range permissions { - permissionResps = append(permissionResps, *dto.NewPermissionResp(&permission)) - } - - return permissionResps, nil -} diff --git a/src/service/producer/role.go b/src/service/producer/role.go deleted file mode 100644 index d613ef02..00000000 --- a/src/service/producer/role.go +++ /dev/null @@ -1,164 +0,0 @@ -package producer - -import ( - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "errors" - "fmt" - - "gorm.io/gorm" -) - -// CreateRole handles the business logic for creating a new role -func CreateRole(req *dto.CreateRoleReq) (*dto.RoleResp, error) { - role := req.ConvertToRole() - - var createdRole *database.Role - err := database.DB.Transaction(func(tx *gorm.DB) error { - if err := repository.CreateRole(tx, role); err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return fmt.Errorf("%w: role with name %s already exists", consts.ErrAlreadyExists, role.Name) - } - return err - } - - createdRole = role - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewRoleResp(createdRole), nil -} - -// DeleteRole deletes an existing role by marking its status as deleted -func DeleteRole(roleID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - role, err := repository.GetRoleByID(tx, roleID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: role not found", consts.ErrNotFound) - } - return fmt.Errorf("failed to get role: %w", err) - } - - if role.IsSystem { - return fmt.Errorf("%w: cannot delete system role", consts.ErrPermissionDenied) - } - - if _, err := repository.RemoveContainersFromRole(tx, role.ID); err != nil { - return fmt.Errorf("failed to remove containers with role: %w", err) - } - if _, err := repository.RemoveDatasetsFromRole(tx, role.ID); err != nil { - return fmt.Errorf("failed to remove datasets with role: %w", err) - } - if _, err := repository.RemoveProjectsFromRole(tx, role.ID); err != nil { - return fmt.Errorf("failed to remove projects with role: %w", err) - } - - if err := repository.RemovePermissionsFromRole(tx, role.ID); err != nil { - return fmt.Errorf("failed to remove permissions with role: %w", err) - } - if err := repository.RemoveUsersFromRole(tx, role.ID); err != nil { - return fmt.Errorf("failed to remove users with role: %w", err) - } - - row, err := repository.DeleteRole(tx, role.ID) - if err != nil { - return fmt.Errorf("failed to delete role: %w", err) - } - if row == 0 { - return fmt.Errorf("%w: role id %d not found", consts.ErrNotFound, roleID) - } - - return nil - }) -} - -// GetRoleDetail retrieves detailed information about a role by its ID -func GetRoleDetail(roleID int) (*dto.RoleDetailResp, error) { - role, err := repository.GetRoleByID(database.DB, roleID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: role with ID %d not found", consts.ErrNotFound, roleID) - } - return nil, fmt.Errorf("failed to get role: %w", err) - } - - resp := dto.NewRoleDetailResp(role) - - userCount, err := repository.GetRoleUserCount(database.DB, role.ID) - if err != nil { - return nil, fmt.Errorf("failed to get role user count: %w", err) - } - resp.UserCount = userCount - - permissions, err := repository.GetRolePermissions(database.DB, role.ID) - if err != nil { - return nil, fmt.Errorf("failed to get role permissions: %w", err) - } - - resp.Permissions = make([]dto.PermissionResp, len(permissions)) - for _, permission := range permissions { - resp.Permissions = append(resp.Permissions, *dto.NewPermissionResp(&permission)) - } - - return resp, nil -} - -// ListRoles lists roles based on the provided filters -func ListRoles(req *dto.ListRoleReq) (*dto.ListResp[dto.RoleResp], error) { - limit, offset := req.ToGormParams() - - roles, total, err := repository.ListRoles(database.DB, limit, offset, req.IsSystem, req.Status) - if err != nil { - return nil, fmt.Errorf("failed to list roles: %w", err) - } - - roleResps := make([]dto.RoleResp, len(roles)) - for i, role := range roles { - roleResps[i] = *dto.NewRoleResp(&role) - } - - resp := dto.ListResp[dto.RoleResp]{ - Items: roleResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// UpdateRole updates an existing role -func UpdateRole(req *dto.UpdateRoleReq, roleID int) (*dto.RoleResp, error) { - var updatedRole *database.Role - - err := database.DB.Transaction(func(tx *gorm.DB) error { - existingRole, err := repository.GetRoleByID(tx, roleID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: role not found", consts.ErrNotFound) - } - return fmt.Errorf("failed to get role: %w", err) - } - - if existingRole.IsSystem { - return fmt.Errorf("%w: cannot update system role", consts.ErrPermissionDenied) - } - - req.PatchRoleModel(existingRole) - - if err := repository.UpdateRole(tx, existingRole); err != nil { - return fmt.Errorf("failed to update role: %w", err) - } - - updatedRole = existingRole - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewRoleResp(updatedRole), nil -} diff --git a/src/service/producer/sdk_evaluation.go b/src/service/producer/sdk_evaluation.go deleted file mode 100644 index de77db09..00000000 --- a/src/service/producer/sdk_evaluation.go +++ /dev/null @@ -1,57 +0,0 @@ -package producer - -import ( - "fmt" - - "aegis/database" - "aegis/dto" - "aegis/repository" -) - -// ListSDKEvaluations lists SDK evaluation samples with pagination and filtering. -func ListSDKEvaluations(req *dto.ListSDKEvaluationReq) (*dto.ListResp[database.SDKEvaluationSample], error) { - limit, offset := req.ToGormParams() - - items, total, err := repository.ListSDKEvaluations(database.DB, req.ExpID, req.Stage, limit, offset) - if err != nil { - return nil, fmt.Errorf("failed to list SDK evaluations: %w", err) - } - - return &dto.ListResp[database.SDKEvaluationSample]{ - Items: items, - Pagination: req.ConvertToPaginationInfo(total), - }, nil -} - -// GetSDKEvaluation retrieves a single SDK evaluation sample by ID. -func GetSDKEvaluation(id int) (*database.SDKEvaluationSample, error) { - item, err := repository.GetSDKEvaluationByID(database.DB, id) - if err != nil { - return nil, err - } - return item, nil -} - -// ListSDKExperiments returns all distinct experiment IDs. -func ListSDKExperiments() (*dto.SDKExperimentListResp, error) { - expIDs, err := repository.ListSDKExperiments(database.DB) - if err != nil { - return nil, fmt.Errorf("failed to list SDK experiments: %w", err) - } - return &dto.SDKExperimentListResp{Experiments: expIDs}, nil -} - -// ListSDKDatasetSamples lists SDK dataset samples with pagination and filtering. -func ListSDKDatasetSamples(req *dto.ListSDKDatasetSampleReq) (*dto.ListResp[database.SDKDatasetSample], error) { - limit, offset := req.ToGormParams() - - items, total, err := repository.ListSDKDatasetSamples(database.DB, req.Dataset, limit, offset) - if err != nil { - return nil, fmt.Errorf("failed to list SDK dataset samples: %w", err) - } - - return &dto.ListResp[database.SDKDatasetSample]{ - Items: items, - Pagination: req.ConvertToPaginationInfo(total), - }, nil -} diff --git a/src/service/producer/system.go b/src/service/producer/system.go deleted file mode 100644 index 45219462..00000000 --- a/src/service/producer/system.go +++ /dev/null @@ -1,283 +0,0 @@ -package producer - -import ( - "aegis/client" - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "context" - "encoding/json" - "errors" - "fmt" - "runtime" - "strconv" - "time" - - "github.com/redis/go-redis/v9" - "github.com/shirou/gopsutil/v3/cpu" - "github.com/shirou/gopsutil/v3/disk" - "github.com/shirou/gopsutil/v3/mem" -) - -// InspectLock retrieves the current lock status of all namespaces -func InspectLock(ctx context.Context) (*dto.ListNamespaceLockResp, error) { - redisClient := client.GetRedisClient() - - // Get all namespaces - namespaces, err := redisClient.SMembers(ctx, consts.NamespacesKey).Result() - if err != nil { - return nil, fmt.Errorf("failed to get namespaces from Redis: %v", err) - } - - nsMap := make(map[string]dto.NsMonitorItem, len(namespaces)) - - // Get data for each namespace - for _, ns := range namespaces { - nsKey := fmt.Sprintf(consts.NamespaceKeyPattern, ns) - values, err := redisClient.HGetAll(ctx, nsKey).Result() - if err != nil { - return nil, fmt.Errorf("failed to get data for namespace %s: %v", ns, err) - } - - endTimeUnix, err := strconv.ParseInt(values["end_time"], 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid end_time format for namespace %s: %v", ns, err) - } - - // Get status, default to enabled for backward compatibility - status := consts.CommonEnabled - if statusStr, ok := values["status"]; ok { - statusInt, err := strconv.Atoi(statusStr) - if err == nil { - status = consts.StatusType(statusInt) - } - } - - nsMap[ns] = dto.NsMonitorItem{ - LockedBy: values["trace_id"], - EndTime: time.Unix(endTimeUnix, 0), - Status: consts.GetStatusTypeName(status), - } - } - - resp := &dto.ListNamespaceLockResp{ - Items: nsMap, - } - return resp, nil -} - -// ListQueuedTasks lists tasks currently in the ready and delayed queues -func ListQueuedTasks(ctx context.Context) (*dto.QueuedTasksResp, error) { - readyTaskDatas, err := repository.ListReadyTasks(ctx) - if err != nil { - if errors.Is(err, redis.Nil) { - return nil, fmt.Errorf("%w: no ready tasks found", consts.ErrNotFound) - } - return nil, err - } - - readyTask := make([]dto.TaskResp, 0, len(readyTaskDatas)) - for _, taskData := range readyTaskDatas { - var task database.Task - if err := json.Unmarshal([]byte(taskData), &task); err != nil { - return nil, err - } - - readyTask = append(readyTask, *dto.NewTaskResp(&task)) - } - - delayedTaskDatas, err := repository.ListDelayedTasks(ctx, 1000) - if err != nil { - if errors.Is(err, redis.Nil) { - return nil, fmt.Errorf("%w: no delayed tasks found", consts.ErrNotFound) - } - return nil, err - } - - delayedTask := make([]dto.TaskResp, 0, len(delayedTaskDatas)) - for _, taskData := range delayedTaskDatas { - var task database.Task - if err := json.Unmarshal([]byte(taskData), &task); err != nil { - return nil, err - } - - delayedTask = append(delayedTask, *dto.NewTaskResp(&task)) - } - - resp := &dto.QueuedTasksResp{ - ReadyTasks: readyTask, - DelayedTasks: delayedTask, - } - return resp, nil -} - -// GetSystemMetrics retrieves current system metrics -func GetSystemMetrics(ctx context.Context) (*dto.SystemMetricsResp, error) { - now := time.Now() - - // Get CPU usage - cpuPercent, err := cpu.PercentWithContext(ctx, time.Second, false) - if err != nil { - return nil, fmt.Errorf("failed to get CPU usage: %v", err) - } - cpuUsage := 0.0 - if len(cpuPercent) > 0 { - cpuUsage = cpuPercent[0] - } - - // Get memory usage - memInfo, err := mem.VirtualMemoryWithContext(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get memory usage: %v", err) - } - - // Get disk usage - diskInfo, err := disk.UsageWithContext(ctx, "/") - if err != nil { - return nil, fmt.Errorf("failed to get disk usage: %v", err) - } - - resp := &dto.SystemMetricsResp{ - CPU: dto.MetricValue{ - Value: cpuUsage, - Timestamp: now, - Unit: "%", - }, - Memory: dto.MetricValue{ - Value: memInfo.UsedPercent, - Timestamp: now, - Unit: "%", - }, - Disk: dto.MetricValue{ - Value: diskInfo.UsedPercent, - Timestamp: now, - Unit: "%", - }, - } - - return resp, nil -} - -// GetSystemMetricsHistory retrieves historical system metrics (24 hours) -func GetSystemMetricsHistory(ctx context.Context) (*dto.SystemMetricsHistoryResp, error) { - redisClient := client.GetRedisClient() - now := time.Now() - - // Get last 24 hours of metrics from Redis - startTime := now.Add(-24 * time.Hour).Unix() - endTime := now.Unix() - - cpuKey := "system:metrics:cpu" - memKey := "system:metrics:memory" - - // Get CPU history - cpuData, err := redisClient.ZRangeByScore(ctx, cpuKey, &redis.ZRangeBy{ - Min: fmt.Sprintf("%d", startTime), - Max: fmt.Sprintf("%d", endTime), - }).Result() - if err != nil && !errors.Is(err, redis.Nil) { - return nil, fmt.Errorf("failed to get CPU history: %v", err) - } - - // Get memory history - memData, err := redisClient.ZRangeByScore(ctx, memKey, &redis.ZRangeBy{ - Min: fmt.Sprintf("%d", startTime), - Max: fmt.Sprintf("%d", endTime), - }).Result() - if err != nil && !errors.Is(err, redis.Nil) { - return nil, fmt.Errorf("failed to get memory history: %v", err) - } - - // Parse CPU data - cpuMetrics := make([]dto.MetricValue, 0, len(cpuData)) - for _, data := range cpuData { - var metric dto.MetricValue - if err := json.Unmarshal([]byte(data), &metric); err == nil { - cpuMetrics = append(cpuMetrics, metric) - } - } - - // Parse memory data - memMetrics := make([]dto.MetricValue, 0, len(memData)) - for _, data := range memData { - var metric dto.MetricValue - if err := json.Unmarshal([]byte(data), &metric); err == nil { - memMetrics = append(memMetrics, metric) - } - } - - // If no historical data, generate current metrics - if len(cpuMetrics) == 0 || len(memMetrics) == 0 { - current, err := GetSystemMetrics(ctx) - if err != nil { - return nil, err - } - - if len(cpuMetrics) == 0 { - cpuMetrics = []dto.MetricValue{current.CPU} - } - if len(memMetrics) == 0 { - memMetrics = []dto.MetricValue{current.Memory} - } - } - - resp := &dto.SystemMetricsHistoryResp{ - CPU: cpuMetrics, - Memory: memMetrics, - } - - return resp, nil -} - -// StoreSystemMetrics stores current system metrics in Redis for historical tracking -func StoreSystemMetrics(ctx context.Context) error { - metrics, err := GetSystemMetrics(ctx) - if err != nil { - return err - } - - redisClient := client.GetRedisClient() - now := time.Now().Unix() - - // Store CPU metric - cpuData, _ := json.Marshal(metrics.CPU) - if err := redisClient.ZAdd(ctx, "system:metrics:cpu", redis.Z{ - Score: float64(now), - Member: cpuData, - }).Err(); err != nil { - return fmt.Errorf("failed to store CPU metric: %v", err) - } - - // Store memory metric - memData, _ := json.Marshal(metrics.Memory) - if err := redisClient.ZAdd(ctx, "system:metrics:memory", redis.Z{ - Score: float64(now), - Member: memData, - }).Err(); err != nil { - return fmt.Errorf("failed to store memory metric: %v", err) - } - - // Clean up old metrics (older than 24 hours) - oldTime := time.Now().Add(-24 * time.Hour).Unix() - redisClient.ZRemRangeByScore(ctx, "system:metrics:cpu", "0", fmt.Sprintf("%d", oldTime)) - redisClient.ZRemRangeByScore(ctx, "system:metrics:memory", "0", fmt.Sprintf("%d", oldTime)) - - return nil -} - -func init() { - // Start background goroutine to collect metrics every minute - go func() { - ticker := time.NewTicker(1 * time.Minute) - defer ticker.Stop() - - for range ticker.C { - ctx := context.Background() - if err := StoreSystemMetrics(ctx); err != nil { - // Log error but don't crash - runtime.Gosched() - } - } - }() -} diff --git a/src/service/producer/task.go b/src/service/producer/task.go deleted file mode 100644 index 5f8c4dc7..00000000 --- a/src/service/producer/task.go +++ /dev/null @@ -1,443 +0,0 @@ -package producer - -import ( - "aegis/client" - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "context" - "encoding/json" - "errors" - "fmt" - "sync" - "time" - - "github.com/gorilla/websocket" - "github.com/redis/go-redis/v9" - "github.com/sirupsen/logrus" - "gorm.io/gorm" -) - -const ( - // WebSocket timing configuration - writeWait = 10 * time.Second - pongWait = 60 * time.Second - pingPeriod = 54 * time.Second // Must be less than pongWait - maxMsgSize = 512 // Max size of incoming messages (control frames) - - // Task polling interval for completion detection - taskPollInterval = 5 * time.Second - - // Flush delay after task completion to catch remaining logs - completionFlushDelay = 5 * time.Second -) - -// TaskLogStreamer manages WebSocket-based real-time log streaming for a task. -type TaskLogStreamer struct { - conn *websocket.Conn - mu sync.Mutex - log *logrus.Entry - taskID string -} - -// NewTaskLogStreamer creates a new TaskLogStreamer for the given WebSocket connection and task. -func NewTaskLogStreamer(conn *websocket.Conn, taskID string) *TaskLogStreamer { - return &TaskLogStreamer{ - conn: conn, - taskID: taskID, - log: logrus.WithField("task_id", taskID), - } -} - -// ExpediteTask moves a Pending task's execute_time to "now" in both the -// MySQL tasks table and the Redis delayed queue. -// -// Contract: -// - If the task does not exist: returns wrapped consts.ErrNotFound. -// - If the task state is not Pending: returns consts.ErrBadRequest with -// the message "state=, cannot expedite". -// - If the task is already due (execute_time <= now) the call is a no-op -// and returns nil (idempotent). -// -// The DB row is updated first; the Redis rescore is attempted as a -// follow-up. If the Redis entry cannot be found (e.g. the scheduler has -// already moved it to the ready queue) the call still succeeds, matching -// the idempotent semantic. -func ExpediteTask(taskID string) (*dto.TaskResp, error) { - ctx := context.Background() - - task, err := repository.GetTaskByID(database.DB, taskID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: task id: %s", consts.ErrNotFound, taskID) - } - return nil, fmt.Errorf("failed to load task: %w", err) - } - - if task.State != consts.TaskPending { - return nil, fmt.Errorf("%w: state=%s, cannot expedite", - consts.ErrBadRequest, consts.GetTaskStateName(task.State)) - } - - now := time.Now().Unix() - if task.ExecuteTime <= now { - // Already due — nothing to do (idempotent). - return dto.NewTaskResp(task), nil - } - - if err := repository.UpdateTaskExecuteTime(database.DB, ctx, taskID, now); err != nil { - return nil, fmt.Errorf("failed to update execute_time: %w", err) - } - - if _, err := repository.ExpediteDelayedTask(ctx, taskID, now); err != nil { - logrus.WithField("task_id", taskID). - Warnf("DB updated but Redis rescore failed: %v", err) - } - - // Emit a task.scheduled trace event documenting the manual expedite. - emitExpediteScheduledEvent(ctx, task, now) - - task.ExecuteTime = now - return dto.NewTaskResp(task), nil -} - -// emitExpediteScheduledEvent publishes a task.scheduled event for a manually -// expedited task. The event is best-effort — failures are logged only. -func emitExpediteScheduledEvent(ctx context.Context, task *database.Task, executeTime int64) { - if task == nil || task.TraceID == "" { - return - } - event := dto.TraceStreamEvent{ - TaskID: task.ID, - TaskType: task.Type, - EventName: consts.EventTaskScheduled, - Payload: dto.TaskScheduledPayload{ - ExecuteTime: executeTime, - Reason: dto.TaskScheduledReasonExpedite, - }, - } - stream := fmt.Sprintf(consts.StreamTraceLogKey, task.TraceID) - if err := client.RedisXAdd(ctx, stream, event.ToRedisStream()); err != nil { - logrus.WithField("task_id", task.ID). - Warnf("failed to emit expedite task.scheduled event: %v", err) - } -} - -// BatchDeleteTasks deletes multiple tasks by their IDs -func BatchDeleteTasks(taskIDs []string) error { - if len(taskIDs) == 0 { - return nil - } - - if err := repository.BatchDeleteTasks(database.DB, taskIDs); err != nil { - return err - } - return nil -} - -// GetTaskDetail retrieves detailed information about a specific task, including historical logs from Loki -func GetTaskDetail(taskID string) (*dto.TaskDetailResp, error) { - task, err := repository.GetTaskByID(database.DB, taskID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: task id: %s", consts.ErrNotFound, taskID) - } - return nil, fmt.Errorf("failed to get task: %w", err) - } - - // Query historical logs from Loki - var logs []string - lokiCtx, lokiCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer lokiCancel() - - lokiClient := client.NewLokiClient() - queryOpts := client.QueryOpts{ - Start: task.CreatedAt, - Direction: "forward", - } - - logEntries, lokiErr := lokiClient.QueryJobLogs(lokiCtx, taskID, queryOpts) - if lokiErr != nil { - logrus.Warnf("Failed to query Loki for task %s logs: %v", taskID, lokiErr) - } else { - logs = make([]string, 0, len(logEntries)) - for _, entry := range logEntries { - logs = append(logs, entry.Line) - } - } - - if logs == nil { - logs = []string{} - } - - resp := dto.NewTaskDetailResp(task, logs) - return resp, nil -} - -// ListTasks lists tasks based on filter options and pagination -func ListTasks(req *dto.ListTaskReq) (*dto.ListResp[dto.TaskResp], error) { - if req == nil { - return nil, fmt.Errorf("list tasks request is nil") - } - - limit, offset := req.ToGormParams() - fitlerOptions := req.ToFilterOptions() - - tasks, total, err := repository.ListTasks(database.DB, limit, offset, fitlerOptions) - if err != nil { - return nil, fmt.Errorf("failed to list tasks: %w", err) - } - - taskResps := make([]dto.TaskResp, 0, len(tasks)) - for _, task := range tasks { - taskResps = append(taskResps, *dto.NewTaskResp(&task)) - } - - resp := dto.ListResp[dto.TaskResp]{ - Items: taskResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// StreamLogs sets up the WebSocket lifecycle, queries Loki for historical logs, subscribes to Redis Pub/Sub for real-time logs, -// and polls for task completion. It blocks until the context is cancelled or the task completes. -func (s *TaskLogStreamer) StreamLogs(ctx context.Context, task *database.Task) { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - // Setup WebSocket connection parameters - s.conn.SetReadLimit(maxMsgSize) - _ = s.conn.SetReadDeadline(time.Now().Add(pongWait)) - s.conn.SetPongHandler(func(string) error { - _ = s.conn.SetReadDeadline(time.Now().Add(pongWait)) - return nil - }) - - // Read pump — handles client messages and detects disconnection - go func() { - defer cancel() - for { - _, _, err := s.conn.ReadMessage() - if err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { - s.log.Warnf("WebSocket unexpected close: %v", err) - } - return - } - } - }() - - // Ping ticker for keepalive - go s.runPingLoop(ctx, cancel) - - // Step 1: Subscribe to Redis Pub/Sub first (before querying Loki to avoid gaps) - pubsubChannel := "joblogs:" + s.taskID - pubsub := client.GetRedisClient().Subscribe(ctx, pubsubChannel) - defer func() { _ = pubsub.Close() }() - - if _, err := pubsub.Receive(ctx); err != nil { - s.log.Errorf("Failed to subscribe to Redis Pub/Sub channel %s: %v", pubsubChannel, err) - s.WriteMessage(dto.WSLogMessage{ - Type: consts.WSLogTypeError, - Message: "failed to subscribe to log stream", - }) - return - } - s.log.Info("Subscribed to Redis Pub/Sub for real-time logs") - - // Step 2: Query Loki for historical logs - lastHistoricalTime := s.sendHistoricalLogs(task) - - // Step 3: Check if task is already completed - if isTaskTerminal(task.State) { - s.WriteMessage(dto.WSLogMessage{ - Type: consts.WSLogTypeEnd, - Message: "task already completed", - }) - s.closeNormal("task completed") - return - } - - // Step 4: Forward real-time logs from Redis Pub/Sub - s.streamRealtime(ctx, pubsub.Channel(), lastHistoricalTime) -} - -// WriteMessage sends a WSLogMessage to the WebSocket connection with thread-safe locking. -func (s *TaskLogStreamer) WriteMessage(msg dto.WSLogMessage) { - s.mu.Lock() - defer s.mu.Unlock() - - _ = s.conn.SetWriteDeadline(time.Now().Add(writeWait)) - if err := s.conn.WriteJSON(msg); err != nil { - s.log.Warnf("WebSocket write error: %v", err) - } -} - -// ForwardRedisLog parses a Redis Pub/Sub payload and forwards it as a realtime log entry. -// It deduplicates against lastHistoricalTime to avoid sending overlapping logs. -func (s *TaskLogStreamer) ForwardRedisLog(payload string, lastHistoricalTime time.Time) { - var entry dto.LogEntry - if err := json.Unmarshal([]byte(payload), &entry); err != nil { - s.log.Warnf("Failed to unmarshal Redis log message: %v", err) - return - } - - // Deduplicate: skip entries that are before or equal to the last historical log - if !lastHistoricalTime.IsZero() && !entry.Timestamp.After(lastHistoricalTime) { - return - } - - s.WriteMessage(dto.WSLogMessage{ - Type: consts.WSLogTypeRealtime, - Logs: []dto.LogEntry{entry}, - }) -} - -// runPingLoop sends periodic WebSocket ping messages to keep the connection alive. -func (s *TaskLogStreamer) runPingLoop(ctx context.Context, cancel context.CancelFunc) { - ticker := time.NewTicker(pingPeriod) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - s.mu.Lock() - _ = s.conn.SetWriteDeadline(time.Now().Add(writeWait)) - err := s.conn.WriteMessage(websocket.PingMessage, nil) - s.mu.Unlock() - if err != nil { - cancel() - return - } - } - } -} - -// sendHistoricalLogs queries Loki for historical logs and sends them to the client. -// Returns the timestamp of the last historical entry for deduplication. -func (s *TaskLogStreamer) sendHistoricalLogs(task *database.Task) time.Time { - // Use a dedicated context with timeout for the Loki query. - // The parent ctx is tied to the WebSocket lifecycle (Hijack'd connection), - // which may get cancelled prematurely and abort the HTTP request. - lokiCtx, lokiCancel := context.WithTimeout(context.Background(), 15*time.Second) - defer lokiCancel() - - lokiClient := client.NewLokiClient() - queryOpts := client.QueryOpts{ - Start: task.CreatedAt, - Direction: "forward", - } - - historicalLogs, err := lokiClient.QueryJobLogs(lokiCtx, s.taskID, queryOpts) - if err != nil { - s.log.Warnf("Failed to query Loki for historical logs: %v", err) - return time.Time{} - } - - if len(historicalLogs) > 0 { - s.WriteMessage(dto.WSLogMessage{ - Type: consts.WSLogTypeHistory, - Logs: historicalLogs, - Total: len(historicalLogs), - }) - s.log.Infof("Sent %d historical log entries", len(historicalLogs)) - return historicalLogs[len(historicalLogs)-1].Timestamp - } - - return time.Time{} -} - -// streamRealtime forwards real-time logs from Redis Pub/Sub and polls for task completion. -func (s *TaskLogStreamer) streamRealtime(ctx context.Context, redisCh <-chan *redis.Message, lastHistoricalTime time.Time) { - // Task completion polling - taskDoneCh := make(chan struct{}) - go func() { - ticker := time.NewTicker(taskPollInterval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - t, err := repository.GetTaskByID(database.DB, s.taskID) - if err != nil { - s.log.Warnf("Failed to poll task state: %v", err) - continue - } - if isTaskTerminal(t.State) { - s.log.Info("Task detected as terminal, initiating close") - close(taskDoneCh) - return - } - } - } - }() - - for { - select { - case <-ctx.Done(): - s.log.Info("Context cancelled, closing WebSocket") - return - - case <-taskDoneCh: - s.flushAndClose(redisCh, lastHistoricalTime) - return - - case msg, ok := <-redisCh: - if !ok { - s.log.Warn("Redis Pub/Sub channel closed") - s.WriteMessage(dto.WSLogMessage{ - Type: consts.WSLogTypeError, - Message: "log stream interrupted", - }) - return - } - s.ForwardRedisLog(msg.Payload, lastHistoricalTime) - } - } -} - -// flushAndClose drains remaining Redis messages after task completion, then closes. -func (s *TaskLogStreamer) flushAndClose(redisCh <-chan *redis.Message, lastHistoricalTime time.Time) { - s.log.Info("Task completed, flushing remaining logs...") - flushTimer := time.NewTimer(completionFlushDelay) - -flushLoop: - for { - select { - case msg, ok := <-redisCh: - if !ok { - break flushLoop - } - s.ForwardRedisLog(msg.Payload, lastHistoricalTime) - case <-flushTimer.C: - break flushLoop - } - } - flushTimer.Stop() - - s.WriteMessage(dto.WSLogMessage{ - Type: consts.WSLogTypeEnd, - Message: "task completed", - }) - s.closeNormal("task completed") -} - -// closeNormal sends a WebSocket close frame with NormalClosure status. -func (s *TaskLogStreamer) closeNormal(reason string) { - s.mu.Lock() - defer s.mu.Unlock() - - _ = s.conn.SetWriteDeadline(time.Now().Add(writeWait)) - _ = s.conn.WriteMessage(websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, reason)) -} - -// isTaskTerminal checks if a task state represents a terminal (completed/error/cancelled) state. -func isTaskTerminal(state consts.TaskState) bool { - return state == consts.TaskCompleted || state == consts.TaskError || state == consts.TaskCancelled -} diff --git a/src/service/producer/team.go b/src/service/producer/team.go deleted file mode 100644 index c3ea4846..00000000 --- a/src/service/producer/team.go +++ /dev/null @@ -1,381 +0,0 @@ -package producer - -import ( - "errors" - "fmt" - - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - - "gorm.io/gorm" -) - -// CreateTeam creates a new team -func CreateTeam(req *dto.CreateTeamReq, userID int) (*dto.TeamResp, error) { - team := req.ConvertToTeam() - - // Get super_admin role - superAdminRole, err := repository.GetRoleByName(database.DB, consts.RoleSuperAdmin.String()) - if err != nil { - return nil, fmt.Errorf("failed to get super_admin role: %w", err) - } - - err = database.DB.Transaction(func(tx *gorm.DB) error { - if err := repository.CreateTeam(tx, team); err != nil { - if errors.Is(err, consts.ErrAlreadyExists) { - return consts.ErrAlreadyExists - } - return fmt.Errorf("failed to create team: %w", err) - } - - // Add creator as team admin - userTeam := &database.UserTeam{ - UserID: userID, - TeamID: team.ID, - RoleID: superAdminRole.ID, - Status: consts.CommonEnabled, - } - if err := repository.CreateUserTeam(tx, userTeam); err != nil { - return fmt.Errorf("failed to add creator to team: %w", err) - } - - return nil - }) - - if err != nil { - return nil, err - } - - return dto.NewTeamResp(team), nil -} - -// DeleteTeam soft deletes a team -func DeleteTeam(teamID int) error { - rowsAffected, err := repository.DeleteTeam(database.DB, teamID) - if err != nil { - return err - } - if rowsAffected == 0 { - return consts.ErrNotFound - } - return nil -} - -// GetTeamDetail retrieves detailed team information -func GetTeamDetail(teamID int) (*dto.TeamDetailResp, error) { - team, err := repository.GetTeamByID(database.DB, teamID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, consts.ErrNotFound - } - return nil, err - } - - resp := dto.NewTeamDetailResp(team) - - // Get user count - userCount, err := repository.GetTeamUserCount(database.DB, teamID) - if err != nil { - return nil, fmt.Errorf("failed to get team user count: %w", err) - } - resp.UserCount = userCount - - // Get project count - projectCount, err := repository.GetTeamProjectCount(database.DB, teamID) - if err != nil { - return nil, fmt.Errorf("failed to get team project count: %w", err) - } - resp.ProjectCount = projectCount - - return resp, nil -} - -// ListTeams lists teams with pagination and filtering -func ListTeams(req *dto.ListTeamReq, userID int, isAdmin bool) (*dto.ListResp[dto.TeamResp], error) { - var teamIDs []int - if !isAdmin { - userTeams, err := repository.ListUserTeamsByUserID(database.DB, userID, consts.CommonEnabled) - if err != nil { - return nil, fmt.Errorf("failed to get user teams: %w", err) - } - for _, ut := range userTeams { - teamIDs = append(teamIDs, ut.TeamID) - } - } - - limit, offset := req.ToGormParams() - teams, total, err := repository.ListTeams(database.DB, limit, offset, req.IsPublic, req.Status, teamIDs) - if err != nil { - return nil, err - } - - items := make([]dto.TeamResp, len(teams)) - for i, team := range teams { - items[i] = *dto.NewTeamResp(&team) - } - - resp := dto.ListResp[dto.TeamResp]{ - Items: items, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// UpdateTeam updates team information -func UpdateTeam(req *dto.UpdateTeamReq, teamID int) (*dto.TeamResp, error) { - team, err := repository.GetTeamByID(database.DB, teamID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, consts.ErrNotFound - } - return nil, err - } - - req.PatchTeamModel(team) - - if err := repository.UpdateTeam(database.DB, team); err != nil { - return nil, err - } - - return dto.NewTeamResp(team), nil -} - -// ListTeamProjects lists all projects belonging to a team -func ListTeamProjects(req *dto.ListProjectReq, teamID int) (*dto.ListResp[dto.ProjectResp], error) { - // Get paginated projects - limit, offset := req.ToGormParams() - projects, total, err := repository.ListProjectsByTeamID(database.DB, teamID, limit, offset, req.IsPublic, req.Status) - if err != nil { - return nil, err - } - - projectIDs := make([]int, 0, len(projects)) - for _, p := range projects { - projectIDs = append(projectIDs, p.ID) - } - - statsMap, err := repository.BatchGetProjectStatistics(database.DB, projectIDs) - if err != nil { - return nil, fmt.Errorf("failed to batch get project statistics: %w", err) - } - - projectResps := make([]dto.ProjectResp, 0, len(projects)) - for i := range projects { - stats := statsMap[projects[i].ID] - projectResps = append(projectResps, *dto.NewProjectResp(&projects[i], stats)) - } - - resp := dto.ListResp[dto.ProjectResp]{ - Items: projectResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// AddTeamMember adds a user to team -func AddTeamMember(req *dto.AddTeamMemberReq, teamID int) error { - // Verify team exists - _, err := repository.GetTeamByID(database.DB, teamID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return consts.ErrNotFound - } - return err - } - - // Get user by username - user, err := repository.GetUserByUsername(database.DB, req.Username) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("user not found: %s", req.Username) - } - return err - } - - // Verify role exists - _, err = repository.GetRoleByID(database.DB, req.RoleID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("role not found") - } - return err - } - - userTeam := &database.UserTeam{ - UserID: user.ID, - TeamID: teamID, - RoleID: req.RoleID, - Status: consts.CommonEnabled, - } - - if err := repository.CreateUserTeam(database.DB, userTeam); err != nil { - if errors.Is(err, consts.ErrAlreadyExists) { - return consts.ErrAlreadyExists - } - return err - } - - return nil -} - -// RemoveTeamMember removes a user from team (only admin can remove others, cannot remove self) -func RemoveTeamMember(teamID, currentUserID, targetUserID int) error { - // Cannot remove self - if targetUserID == currentUserID { - return fmt.Errorf("cannot remove yourself from the team") - } - - // Verify team exists - _, err := repository.GetTeamByID(database.DB, teamID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return consts.ErrNotFound - } - return err - } - - // Remove user from team - rowsAffected, err := repository.DeleteUserTeam(database.DB, targetUserID, teamID) - if err != nil { - return err - } - if rowsAffected == 0 { - return fmt.Errorf("user is not a member of this team") - } - - return nil -} - -// UpdateTeamMemberRole updates a team member's role (only admin can do this) -func UpdateTeamMemberRole(req *dto.UpdateTeamMemberRoleReq, teamID, targetUserID, currentUserID int) error { - // Verify team exists - _, err := repository.GetTeamByID(database.DB, teamID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return consts.ErrNotFound - } - return err - } - - // Verify new role exists - _, err = repository.GetRoleByID(database.DB, req.RoleID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("role not found") - } - return err - } - - // Get existing user-team association - userTeams, err := repository.ListUserTeamsByUserID(database.DB, targetUserID) - if err != nil { - return err - } - - var targetUserTeam *database.UserTeam - for i := range userTeams { - if userTeams[i].TeamID == teamID { - targetUserTeam = &userTeams[i] - break - } - } - - if targetUserTeam == nil { - return fmt.Errorf("user is not a member of this team") - } - - // Update role - targetUserTeam.RoleID = req.RoleID - if err := database.DB.Save(targetUserTeam).Error; err != nil { - return fmt.Errorf("failed to update team member role: %w", err) - } - - return nil -} - -// ListTeamMembers lists all members of a team with pagination -func ListTeamMembers(req *dto.ListTeamMemberReq, teamID int) (*dto.ListResp[dto.TeamMemberResp], error) { - // Get paginated team members - limit, offset := req.ToGormParams() - users, total, err := repository.ListUsersByTeamID(database.DB, teamID, limit, offset) - if err != nil { - return nil, err - } - - // Build response - members := make([]dto.TeamMemberResp, 0, len(users)) - for _, user := range users { - userTeams, err := repository.ListUserTeamsByUserID(database.DB, user.ID) - if err != nil { - return nil, err - } - - for _, ut := range userTeams { - if ut.TeamID == teamID && ut.Status == consts.CommonEnabled { - member := dto.TeamMemberResp{ - UserID: user.ID, - Username: user.Username, - FullName: user.FullName, - Email: user.Email, - RoleID: ut.RoleID, - JoinedAt: ut.CreatedAt, - } - - if ut.Role != nil { - member.RoleName = ut.Role.DisplayName - } - - members = append(members, member) - break - } - } - } - - resp := dto.ListResp[dto.TeamMemberResp]{ - Items: members, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// ============================================================================ -// Team Permission Check Helper Functions (exported for middleware) -// ============================================================================ - -// IsUserInTeam checks if a user is a member of a team -func IsUserInTeam(userID, teamID int) (bool, error) { - ut, err := repository.GetUserTeamRole(database.DB, userID, teamID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return false, err - } - return ut != nil, nil -} - -// IsUserTeamAdmin checks if a user has team admin role in a specific team -func IsUserTeamAdmin(userID, teamID int) (bool, error) { - ut, err := repository.GetUserTeamRole(database.DB, userID, teamID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return false, err - } - return ut != nil && ut.Role != nil && ut.Role.Name == consts.RoleTeamAdmin.String(), nil -} - -// IsTeamPublic checks if a team is publicly accessible -func IsTeamPublic(teamID int) (bool, error) { - team, err := repository.GetTeamByID(database.DB, teamID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return false, err - } - return team.IsPublic, nil -} diff --git a/src/service/producer/trace.go b/src/service/producer/trace.go deleted file mode 100644 index 5408de7e..00000000 --- a/src/service/producer/trace.go +++ /dev/null @@ -1,283 +0,0 @@ -package producer - -import ( - "aegis/client" - "aegis/config" - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "context" - "encoding/json" - "errors" - "fmt" - "reflect" - "strconv" - "strings" - "time" - - "github.com/redis/go-redis/v9" - "gorm.io/gorm" -) - -var payloadTypeRegistry = map[consts.EventType]reflect.Type{ - // Algorithm execution events - consts.EventAlgoRunStarted: reflect.TypeFor[dto.ExecutionInfo](), - consts.EventAlgoRunSucceed: reflect.TypeFor[dto.ExecutionResult](), - consts.EventAlgoRunFailed: reflect.TypeFor[dto.ExecutionResult](), - - // Dataset Build events - consts.EventDatapackBuildStarted: reflect.TypeFor[dto.DatapackInfo](), - consts.EventDatapackBuildSucceed: reflect.TypeFor[dto.DatapackResult](), - consts.EventDatapackBuildFailed: reflect.TypeFor[dto.DatapackResult](), - - // K8s Job events - consts.EventJobSucceed: reflect.TypeFor[dto.JobMessage](), - consts.EventJobFailed: reflect.TypeFor[dto.JobMessage](), -} - -// ===================== Trace Service ===================== - -// GetTraceDetail retrieves detailed information about a specific trace -func GetTraceDetail(traceID string) (*dto.TraceDetailResp, error) { - trace, err := repository.GetTraceByID(database.DB, traceID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("%w: trace id: %s", consts.ErrNotFound, traceID) - } - return nil, fmt.Errorf("failed to get trace: %w", err) - } - - resp := dto.NewTraceDetailResp(trace) - return resp, nil -} - -// ListTraces lists traces based on filter options and pagination -func ListTraces(req *dto.ListTraceReq) (*dto.ListResp[dto.TraceResp], error) { - if req == nil { - return nil, fmt.Errorf("list traces request is nil") - } - - limit, offset := req.ToGormParams() - filterOptions := req.ToFilterOptions() - - traces, total, err := repository.ListTraces(database.DB, limit, offset, filterOptions) - if err != nil { - return nil, fmt.Errorf("failed to list traces: %w", err) - } - - traceResps := make([]dto.TraceResp, 0, len(traces)) - for i := range traces { - traceResps = append(traceResps, *dto.NewTraceResp(&traces[i])) - } - - resp := dto.ListResp[dto.TraceResp]{ - Items: traceResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// ===================== Trace Stream Service ===================== - -type StreamProcessor struct { - isCompleted bool - algorithmMap map[string]struct{} - finishedCount int -} - -func NewStreamProcessor(algorithms []dto.ContainerVersionItem) *StreamProcessor { - algorithmMap := make(map[string]struct{}, len(algorithms)) - for _, algorithm := range algorithms { - algorithmMap[algorithm.ContainerName] = struct{}{} - } - - return &StreamProcessor{ - isCompleted: false, - algorithmMap: algorithmMap, - finishedCount: 0, - } -} - -func (sp *StreamProcessor) IsCompleted() bool { - return sp.isCompleted -} - -func (sp *StreamProcessor) ProcessMessageForSSE(msg redis.XMessage) (string, *dto.TraceStreamEvent, error) { - streamEvent, err := parseStreamEvent(msg.ID, msg.Values) - if err != nil { - return "", nil, fmt.Errorf("failed to parse stream message value: %v", err) - } - - switch streamEvent.EventName { - case consts.EventImageBuildSucceed: - sp.isCompleted = true - - case consts.EventRestartPedestalFailed, consts.EventFaultInjectionFailed, consts.EventDatapackBuildFailed: - sp.isCompleted = true - - case consts.EventDatapackNoAnomaly, consts.EventDatapackNoDetectorData: - sp.isCompleted = true - - case consts.EventDatapackResultCollection: - sp.isCompleted = len(sp.algorithmMap) == 0 - - case consts.EventAlgoResultCollection, consts.EventAlgoRunFailed: - payload, ok := streamEvent.Payload.(*dto.ExecutionResult) - if !ok { - return "", nil, fmt.Errorf("invalid payload type for task status update event: %T", streamEvent.Payload) - } - - if payload.Algorithm != config.GetDetectorName() { - if _, exists := sp.algorithmMap[payload.Algorithm]; exists { - sp.finishedCount++ - if sp.finishedCount >= len(sp.algorithmMap) { - sp.isCompleted = true - } - } - } else { - sp.isCompleted = true - } - } - - return msg.ID, streamEvent, nil -} - -// GetTraceStreamProcessor creates and initializes a stream processor for the given trace -func GetTraceStreamProcessor(ctx context.Context, traceID string) (*StreamProcessor, error) { - trace, err := repository.GetTraceByID(database.DB, traceID) - if err != nil { - return nil, fmt.Errorf("failed to fetch trace: %w", err) - } - - var algorithms []dto.ContainerVersionItem - if trace.Type == consts.TraceTypeFullPipeline { - if client.CheckCachedField(ctx, consts.InjectionAlgorithmsKey, trace.GroupID) { - err = client.GetHashField(ctx, consts.InjectionAlgorithmsKey, trace.GroupID, &algorithms) - if err != nil { - return nil, fmt.Errorf("failed to get algorithms from Redis: %w", err) - } - } - } - - return NewStreamProcessor(algorithms), nil -} - -// ReadTraceStreamMessages reads messages from the trace stream -func ReadTraceStreamMessages(ctx context.Context, streamKey, lastID string, count int64, block time.Duration) ([]redis.XStream, error) { - if lastID == "" { - lastID = "0" - } - - messages, err := client.RedisXRead(ctx, []string{streamKey, lastID}, count, block) - if err != nil { - return nil, fmt.Errorf("failed to read stream messages: %w", err) - } - return messages, err -} - -// parseStreamEvent parses a Redis stream message values into a StreamEvent -func parseStreamEvent(id string, values map[string]any) (*dto.TraceStreamEvent, error) { - message := "missing or invalid key %s in redis stream message values" - - taskID, ok := values[consts.RdbEventTaskID].(string) - if !ok || taskID == "" { - return nil, fmt.Errorf(message, consts.RdbEventTaskID) - } - - timeStamp, err := strconv.Atoi(strings.Split(id, "-")[0]) - if err != nil { - return nil, err - } - - event := &dto.TraceStreamEvent{ - TimeStamp: timeStamp, - TaskID: taskID, - } - - if _, exists := values[consts.RdbEventTaskType]; exists { - taskTypeStr, ok := values[consts.RdbEventTaskType].(string) - if !ok { - return nil, fmt.Errorf(message, consts.RdbEventTaskType) - } - - taskTypePtr := consts.GetTaskTypeByName(taskTypeStr) - if taskTypePtr == nil { - return nil, fmt.Errorf("unknown task type name: %s", taskTypeStr) - } - - event.TaskType = *taskTypePtr - } - - if _, exists := values[consts.RdbEventFn]; exists { - fnName, ok := values[consts.RdbEventFn].(string) - if !ok { - return nil, fmt.Errorf(message, consts.RdbEventFn) - } - event.FnName = fnName - } - - if _, exists := values[consts.RdbEventFileName]; exists { - fileName, ok := values[consts.RdbEventFileName].(string) - if !ok { - return nil, fmt.Errorf(message, consts.RdbEventTaskID) - } - - event.FileName = fileName - } - - if _, exists := values[consts.RdbEventLine]; exists { - lineInt64, ok := values[consts.RdbEventLine].(string) - if !ok { - return nil, fmt.Errorf(message, consts.RdbEventLine) - } - - line, err := strconv.Atoi(lineInt64) - if err != nil { - return nil, fmt.Errorf("invalid line number: %w", err) - } - event.Line = line - } - - if _, exists := values[consts.RdbEventName]; exists { - eventName, ok := values[consts.RdbEventName].(string) - if !ok { - return nil, fmt.Errorf(message, consts.RdbEventName) - } - event.EventName = consts.EventType(eventName) - } - - if _, exists := values[consts.RdbEventPayload]; exists { - if values[consts.RdbEventPayload] != nil { - payloadStr, ok := values[consts.RdbEventPayload].(string) - if !ok { - return nil, fmt.Errorf(message, consts.RdbEventPayload) - } - - payload, err := parsePayloadByEventType(event.EventName, payloadStr) - if err != nil { - return nil, fmt.Errorf(message, consts.RdbEventPayload) - } - event.Payload = payload - } - } - - return event, nil -} - -// parsePayloadByEventType dynamically parses payload based on event type and -// returns the parsed payload as any, caller should do type assertion -func parsePayloadByEventType(eventType consts.EventType, payloadStr string) (any, error) { - payloadType, exists := payloadTypeRegistry[eventType] - if !exists { - return nil, nil - } - - valuePtr := reflect.New(payloadType) - - if err := json.Unmarshal([]byte(payloadStr), valuePtr.Interface()); err != nil { - return nil, fmt.Errorf("failed to unmarshal payload for event %s: %w", eventType, err) - } - - return valuePtr.Interface(), nil -} diff --git a/src/service/producer/upload.go b/src/service/producer/upload.go deleted file mode 100644 index ca814489..00000000 --- a/src/service/producer/upload.go +++ /dev/null @@ -1,262 +0,0 @@ -package producer - -import ( - "archive/zip" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "aegis/config" - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - - chaos "github.com/OperationsPAI/chaos-experiment/handler" - "github.com/sirupsen/logrus" -) - -// validParquetFiles is the set of recognized parquet files in a datapack archive -var validParquetFiles = map[string]bool{ - "abnormal_traces.parquet": true, - "abnormal_metrics.parquet": true, - "abnormal_logs.parquet": true, - "normal_traces.parquet": true, - "normal_metrics.parquet": true, - "normal_logs.parquet": true, -} - -// UploadDatapack handles the business logic for uploading a manual datapack -func UploadDatapack(req *dto.UploadDatapackReq, file io.Reader, fileSize int64) (*dto.UploadDatapackResp, error) { - // Parse labels and groundtruths from request - labels, err := req.ParseLabels() - if err != nil { - return nil, fmt.Errorf("%w: %s", consts.ErrBadRequest, err.Error()) - } - - groundtruths, err := req.ParseGroundtruths() - if err != nil { - return nil, fmt.Errorf("%w: %s", consts.ErrBadRequest, err.Error()) - } - - // Check name uniqueness - existing, _ := repository.GetInjectionByName(database.DB, req.Name, false) - if existing != nil { - return nil, fmt.Errorf("%w: injection with name %s already exists", consts.ErrAlreadyExists, req.Name) - } - - // Save uploaded file to temp location - tmpFile, err := os.CreateTemp("", "datapack-upload-*.zip") - if err != nil { - return nil, fmt.Errorf("failed to create temp file: %w", err) - } - tmpPath := tmpFile.Name() - defer func() { _ = os.Remove(tmpPath) }() - - if _, err := io.Copy(tmpFile, file); err != nil { - _ = tmpFile.Close() - return nil, fmt.Errorf("failed to save uploaded file: %w", err) - } - _ = tmpFile.Close() - - // Validate archive contents - if err := validateDatapackArchive(tmpPath); err != nil { - return nil, fmt.Errorf("%w: %s", consts.ErrBadRequest, err.Error()) - } - - // Get target directory - datasetPath := config.GetString("jfs.dataset_path") - if datasetPath == "" { - return nil, fmt.Errorf("dataset path not configured") - } - targetDir := filepath.Join(datasetPath, req.Name) - - // Ensure target directory does not already exist - if _, err := os.Stat(targetDir); err == nil { - return nil, fmt.Errorf("%w: directory %s already exists", consts.ErrAlreadyExists, req.Name) - } - - // Extract zip to target directory - if err := extractZipToDir(tmpPath, targetDir); err != nil { - // Clean up on failure - _ = os.RemoveAll(targetDir) - return nil, fmt.Errorf("failed to extract archive: %w", err) - } - - // Determine ground truth source - groundtruthSource := "" - if len(groundtruths) > 0 { - // Ground truth was provided in the request - groundtruthSource = consts.GroundtruthSourceManual - } else { - // Try to extract ground truth from injection.json if not provided in request - groundtruths = extractGroundtruthFromInjectionJSON(targetDir) - if len(groundtruths) > 0 { - groundtruthSource = consts.GroundtruthSourceImported - } - } - - // Create FaultInjection record - category := chaos.SystemType("") - if req.Category != "" { - category = chaos.SystemType(req.Category) - } - - injection := &database.FaultInjection{ - Name: req.Name, - Source: consts.DatapackSourceManual, - FaultType: chaos.ChaosType(0), - Category: category, - Description: req.Description, - EngineConfig: "", - Groundtruths: groundtruths, - GroundtruthSource: groundtruthSource, - PreDuration: 0, - BenchmarkID: nil, - PedestalID: nil, - State: consts.DatapackBuildSuccess, - Status: consts.CommonEnabled, - } - - if err := CreateInjection(injection, labels); err != nil { - // Clean up extracted files on DB failure - _ = os.RemoveAll(targetDir) - return nil, err - } - - return &dto.UploadDatapackResp{ - ID: injection.ID, - Name: injection.Name, - }, nil -} - -// validateDatapackArchive checks that the zip archive contains at least one recognized parquet file -func validateDatapackArchive(zipPath string) error { - r, err := zip.OpenReader(zipPath) - if err != nil { - return fmt.Errorf("failed to open zip archive: %w", err) - } - defer func() { _ = r.Close() }() - - for _, f := range r.File { - name := filepath.Base(f.Name) - if validParquetFiles[name] { - return nil - } - } - - return fmt.Errorf("archive must contain at least one parquet file from: abnormal_traces.parquet, abnormal_metrics.parquet, abnormal_logs.parquet, normal_traces.parquet, normal_metrics.parquet, normal_logs.parquet") -} - -// extractZipToDir extracts a zip archive to the target directory with path traversal protection -func extractZipToDir(zipPath, targetDir string) error { - r, err := zip.OpenReader(zipPath) - if err != nil { - return fmt.Errorf("failed to open zip archive: %w", err) - } - defer func() { _ = r.Close() }() - - // Create target directory - if err := os.MkdirAll(targetDir, 0755); err != nil { - return fmt.Errorf("failed to create target directory: %w", err) - } - - for _, f := range r.File { - // Path traversal protection - destPath := filepath.Join(targetDir, f.Name) - if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(targetDir)+string(os.PathSeparator)) && - filepath.Clean(destPath) != filepath.Clean(targetDir) { - return fmt.Errorf("illegal file path in archive: %s", f.Name) - } - - if f.FileInfo().IsDir() { - if err := os.MkdirAll(destPath, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", f.Name, err) - } - continue - } - - // Ensure parent directory exists - if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { - return fmt.Errorf("failed to create parent directory for %s: %w", f.Name, err) - } - - outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) - if err != nil { - return fmt.Errorf("failed to create file %s: %w", f.Name, err) - } - - rc, err := f.Open() - if err != nil { - _ = outFile.Close() - return fmt.Errorf("failed to open file in archive %s: %w", f.Name, err) - } - - _, err = io.Copy(outFile, rc) - _ = rc.Close() - _ = outFile.Close() - if err != nil { - return fmt.Errorf("failed to extract file %s: %w", f.Name, err) - } - } - - return nil -} - -// injectionJSONGroundtruth represents the ground truth structure in injection.json -type injectionJSONGroundtruth struct { - Service []string `json:"service,omitempty"` - Pod []string `json:"pod,omitempty"` - Container []string `json:"container,omitempty"` - Metric []string `json:"metric,omitempty"` - Function []string `json:"function,omitempty"` - Span []string `json:"span,omitempty"` -} - -type injectionJSONFile struct { - Groundtruths []injectionJSONGroundtruth `json:"ground_truths"` - GroundTruth []injectionJSONGroundtruth `json:"ground_truth"` -} - -// extractGroundtruthFromInjectionJSON tries to read ground truth from injection.json in the directory -func extractGroundtruthFromInjectionJSON(dir string) []database.Groundtruth { - jsonPath := filepath.Join(dir, "injection.json") - data, err := os.ReadFile(jsonPath) - if err != nil { - logrus.Debugf("No injection.json found in %s: %v", dir, err) - return nil - } - - var parsed injectionJSONFile - if err := json.Unmarshal(data, &parsed); err != nil { - logrus.Warnf("Failed to parse injection.json in %s: %v", dir, err) - return nil - } - - // Try ground_truths first, then ground_truth - rawGTs := parsed.Groundtruths - if len(rawGTs) == 0 { - rawGTs = parsed.GroundTruth - } - - if len(rawGTs) == 0 { - return nil - } - - result := make([]database.Groundtruth, 0, len(rawGTs)) - for _, gt := range rawGTs { - result = append(result, database.Groundtruth{ - Service: gt.Service, - Pod: gt.Pod, - Container: gt.Container, - Metric: gt.Metric, - Function: gt.Function, - Span: gt.Span, - }) - } - - return result -} diff --git a/src/service/producer/user.go b/src/service/producer/user.go deleted file mode 100644 index a57f3cd8..00000000 --- a/src/service/producer/user.go +++ /dev/null @@ -1,213 +0,0 @@ -package producer - -import ( - "aegis/consts" - "aegis/database" - "aegis/dto" - "aegis/repository" - "errors" - "fmt" - - "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" -) - -// CreateUser handles the business logic for creating a new user -func CreateUser(req *dto.CreateUserReq) (*dto.UserResp, error) { - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - return nil, fmt.Errorf("failed to hash password: %w", err) - } - - user := &database.User{ - Username: req.Username, - Email: req.Email, - Password: string(hashedPassword), - FullName: req.FullName, - Phone: req.Phone, - Avatar: req.Avatar, - Status: consts.CommonEnabled, - IsActive: true, - } - - var createdUser *database.User - err = database.DB.Transaction(func(tx *gorm.DB) error { - if _, err := repository.GetUserByUsername(tx, user.Username); err == nil { - return fmt.Errorf("%w: username %s already exists", consts.ErrAlreadyExists, user.Username) - } - - if _, err := repository.GetUserByEmail(tx, user.Email); err == nil { - return fmt.Errorf("%w: email %s already exists", consts.ErrAlreadyExists, user.Email) - } - - if err := repository.CreateUser(tx, user); err != nil { - return err - } - - createdUser = user - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewUserResp(createdUser), nil -} - -// DeleteUser deletes an existing user by marking their status as deleted -func DeleteUser(userID int) error { - return database.DB.Transaction(func(tx *gorm.DB) error { - user, err := repository.GetUserByID(tx, userID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: user not found", consts.ErrNotFound) - } - return fmt.Errorf("failed to get user: %w", err) - } - - // Remove all associations with containers, datasets, and projects - if _, err := repository.RemoveContainersFromUser(tx, user.ID); err != nil { - return fmt.Errorf("failed to remove containers from user: %w", err) - } - if _, err = repository.RemoveDatasetsFromUser(tx, user.ID); err != nil { - return fmt.Errorf("failed to remove datasets from user: %w", err) - } - if _, err = repository.RemoveProjectsFromUser(tx, user.ID); err != nil { - return fmt.Errorf("failed to remove projects from user: %w", err) - } - - // Remove associated permissions and roles - if err := repository.RemovePermissionsFromUser(tx, user.ID); err != nil { - return fmt.Errorf("failed to remove projects from user: %w", err) - } - if err := repository.RemoveRolesFromUser(tx, user.ID); err != nil { - return fmt.Errorf("failed to remove roles from user: %w", err) - } - - rows, err := repository.DeleteUser(tx, userID) - if err != nil { - return fmt.Errorf("failed to delete user: %w", err) - } - if rows == 0 { - return fmt.Errorf("%w: user id %d not found", consts.ErrNotFound, userID) - } - - return nil - }) -} - -// GetUserDetail retrieves detailed information about a user by their ID -func GetUserDetail(userID int) (*dto.UserDetailResp, error) { - user, err := repository.GetUserByID(database.DB, userID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return nil, fmt.Errorf("%w: user with ID %d not found", consts.ErrNotFound, userID) - } - return nil, fmt.Errorf("failed to get user: %w", err) - } - - resp := dto.NewUserDetailResp(user) - - globalRoles, err := repository.ListRolesByUserID(database.DB, user.ID) - if err != nil { - return nil, fmt.Errorf("failed to get user global roles: %w", err) - } - - resp.GlobalRoles = make([]dto.RoleResp, len(globalRoles)) - for i, role := range globalRoles { - roleResp := *dto.NewRoleResp(&role) - resp.GlobalRoles[i] = roleResp - } - - permissions, err := repository.ListPermissionsByUserID(database.DB, user.ID) - if err != nil { - return nil, fmt.Errorf("failed to get user permissions: %w", err) - } - - resp.Permissions = make([]dto.PermissionResp, len(permissions)) - for i, permission := range permissions { - resp.Permissions[i] = *dto.NewPermissionResp(&permission) - } - - userContainers, userDatasets, userProjects, err := getAllUserResourceRoles(userID) - if err != nil { - return nil, fmt.Errorf("failed to get user resource roles: %w", err) - } - - resp.ContainerRoles = userContainers - resp.DatasetRoles = userDatasets - resp.ProjectRoles = userProjects - - return resp, nil -} - -// ListUsers lists users based on the provided filters -func ListUsers(req *dto.ListUserReq) (*dto.ListResp[dto.UserResp], error) { - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - - limit, offset := req.ToGormParams() - - users, total, err := repository.ListUsers(database.DB, limit, offset, req.IsActive, req.Status) - if err != nil { - return nil, fmt.Errorf("failed to list users: %w", err) - } - - userResps := make([]dto.UserResp, len(users)) - for i, u := range users { - userResps[i] = *dto.NewUserResp(&u) - } - - resp := dto.ListResp[dto.UserResp]{ - Items: userResps, - Pagination: req.ConvertToPaginationInfo(total), - } - return &resp, nil -} - -// UpdateUser updates an existing user's details -func UpdateUser(req *dto.UpdateUserReq, userID int) (*dto.UserResp, error) { - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - - var updatedUser *database.User - - err := database.DB.Transaction(func(tx *gorm.DB) error { - existingUser, err := repository.GetUserByID(tx, userID) - if err != nil { - if errors.Is(err, consts.ErrNotFound) { - return fmt.Errorf("%w: user not found", consts.ErrNotFound) - } - return fmt.Errorf("failed to get user: %w", err) - } - - req.PatchUserModel(existingUser) - - if err := repository.UpdateUser(tx, existingUser); err != nil { - return fmt.Errorf("failed to update user: %w", err) - } - - updatedUser = existingUser - return nil - }) - if err != nil { - return nil, err - } - - return dto.NewUserResp(updatedUser), nil -} - -func SearchUsers(req *dto.SearchReq[string]) (*dto.SearchResp[dto.UserResp], error) { - return nil, nil -} - -// IsUserSystemAdmin checks if a user has system admin role -func IsUserSystemAdmin(userID int) (bool, error) { - return repository.IsSystemAdmin(database.DB, userID) -} diff --git a/src/testutil/redisstub.go b/src/testutil/redisstub.go new file mode 100644 index 00000000..0020e9b6 --- /dev/null +++ b/src/testutil/redisstub.go @@ -0,0 +1,134 @@ +package testutil + +import ( + "bufio" + "fmt" + "io" + "net" + "strconv" + "strings" + "testing" +) + +func StartRedisStub(tb testing.TB) (string, func()) { + tb.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + tb.Fatalf("listen redis stub: %v", err) + } + + done := make(chan struct{}) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-done: + return + default: + return + } + } + + go handleRedisStubConn(conn) + } + }() + + cleanup := func() { + close(done) + _ = ln.Close() + } + + return ln.Addr().String(), cleanup +} + +func handleRedisStubConn(conn net.Conn) { + defer func() { + _ = conn.Close() + }() + + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + + for { + cmd, err := readRESPArray(reader) + if err != nil { + if err == io.EOF { + return + } + _, _ = writer.WriteString("-ERR invalid request\r\n") + _ = writer.Flush() + return + } + if len(cmd) == 0 { + continue + } + + switch strings.ToUpper(cmd[0]) { + case "PING": + _, _ = writer.WriteString("+PONG\r\n") + case "HELLO": + _, _ = writer.WriteString("%7\r\n+server\r\n+redis\r\n+version\r\n+7.0.0\r\n+proto\r\n:3\r\n+id\r\n:1\r\n+mode\r\n+standalone\r\n+role\r\n+master\r\n+modules\r\n*0\r\n") + case "CLIENT", "AUTH", "SELECT", "QUIT": + _, _ = writer.WriteString("+OK\r\n") + case "COMMAND": + _, _ = writer.WriteString("*0\r\n") + case "LPUSH", "HSET", "ZADD": + _, _ = writer.WriteString(":1\r\n") + default: + _, _ = writer.WriteString("+OK\r\n") + } + + if err := writer.Flush(); err != nil { + return + } + } +} + +func readRESPArray(reader *bufio.Reader) ([]string, error) { + prefix, err := reader.ReadByte() + if err != nil { + return nil, err + } + if prefix != '*' { + return nil, fmt.Errorf("unexpected prefix %q", prefix) + } + + countLine, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + count, err := strconv.Atoi(strings.TrimSpace(countLine)) + if err != nil { + return nil, err + } + + items := make([]string, 0, count) + for i := 0; i < count; i++ { + bulkPrefix, err := reader.ReadByte() + if err != nil { + return nil, err + } + if bulkPrefix != '$' { + return nil, fmt.Errorf("unexpected bulk prefix %q", bulkPrefix) + } + + sizeLine, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + size, err := strconv.Atoi(strings.TrimSpace(sizeLine)) + if err != nil { + return nil, err + } + + buf := make([]byte, size+2) + if _, err := io.ReadFull(reader, buf); err != nil { + return nil, err + } + items = append(items, string(buf[:size])) + } + + return items, nil +} diff --git a/src/utils/access_key_crypto.go b/src/utils/access_key_crypto.go new file mode 100644 index 00000000..7f5ec00f --- /dev/null +++ b/src/utils/access_key_crypto.go @@ -0,0 +1,83 @@ +package utils + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" +) + +func EncryptAPIKeySecret(secret string) (string, error) { + block, err := aes.NewCipher(apiKeyCryptoKey()) + if err != nil { + return "", fmt.Errorf("failed to initialize cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to initialize GCM: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(secret), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func DecryptAPIKeySecret(ciphertext string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf("failed to decode ciphertext: %w", err) + } + + block, err := aes.NewCipher(apiKeyCryptoKey()) + if err != nil { + return "", fmt.Errorf("failed to initialize cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to initialize GCM: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(raw) < nonceSize { + return "", fmt.Errorf("ciphertext is too short") + } + + nonce, encrypted := raw[:nonceSize], raw[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, encrypted, nil) + if err != nil { + return "", fmt.Errorf("failed to decrypt ciphertext: %w", err) + } + + return string(plaintext), nil +} + +func SignAPIKeyRequest(secret, payload string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(payload)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func VerifyAPIKeyRequestSignature(secret, payload, signature string) bool { + expected := SignAPIKeyRequest(secret, payload) + return hmac.Equal([]byte(expected), []byte(signature)) +} + +func SHA256Hex(payload []byte) string { + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]) +} + +func apiKeyCryptoKey() []byte { + sum := sha256.Sum256([]byte(JWTSecret)) + return sum[:] +} diff --git a/src/utils/fault_translate_test.go b/src/utils/fault_translate_test.go index b298c794..29ce66a6 100644 --- a/src/utils/fault_translate_test.go +++ b/src/utils/fault_translate_test.go @@ -148,7 +148,7 @@ func TestExtractFieldDescriptions(t *testing.T) { fieldByName[f.Name] = f } - expectedFields := []string{"Duration", "Namespace", "CPULoad", "CPUWorker"} + expectedFields := []string{"Duration", "System", "CPULoad", "CPUWorker"} for _, name := range expectedFields { _, exists := fieldByName[name] assert.True(t, exists, "CPUStress should contain field %q", name) diff --git a/src/utils/jwt.go b/src/utils/jwt.go index 490ddf6a..0d49d615 100644 --- a/src/utils/jwt.go +++ b/src/utils/jwt.go @@ -21,12 +21,15 @@ const ( // Claims represents JWT claims structure type Claims struct { - UserID int `json:"user_id"` - Username string `json:"username"` - Email string `json:"email"` - IsActive bool `json:"is_active"` - IsAdmin bool `json:"is_admin"` // System admin flag (super_admin or admin) - Roles []string `json:"roles"` // Global role names + UserID int `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + IsActive bool `json:"is_active"` + IsAdmin bool `json:"is_admin"` // System admin flag (super_admin or admin) + Roles []string `json:"roles"` // Global role names + AuthType string `json:"auth_type,omitempty"` + APIKeyID int `json:"api_key_id,omitempty"` + APIKeyScopes []string `json:"api_key_scopes,omitempty"` jwt.RegisteredClaims } @@ -45,17 +48,28 @@ type ServiceClaims struct { // GenerateToken generates a new JWT token for the given user func GenerateToken(userID int, username, email string, isActive, isAdmin bool, roles []string) (string, time.Time, error) { + return generateUserToken(userID, username, email, isActive, isAdmin, roles, "user", 0, nil) +} + +func GenerateAPIKeyToken(userID int, username, email string, isActive, isAdmin bool, roles []string, apiKeyID int, apiKeyScopes []string) (string, time.Time, error) { + return generateUserToken(userID, username, email, isActive, isAdmin, roles, "api_key", apiKeyID, apiKeyScopes) +} + +func generateUserToken(userID int, username, email string, isActive, isAdmin bool, roles []string, authType string, apiKeyID int, apiKeyScopes []string) (string, time.Time, error) { expirationTime := time.Now().Add(TokenExpiration) claims := &Claims{ - UserID: userID, - Username: username, - Email: email, - IsActive: isActive, - IsAdmin: isAdmin, - Roles: roles, + UserID: userID, + Username: username, + Email: email, + IsActive: isActive, + IsAdmin: isAdmin, + Roles: roles, + AuthType: authType, + APIKeyID: apiKeyID, + APIKeyScopes: append([]string(nil), apiKeyScopes...), RegisteredClaims: jwt.RegisteredClaims{ - ID: fmt.Sprintf("jwt_%d_%d", userID, time.Now().Unix()), // JWT ID (jti) + ID: fmt.Sprintf("jwt_%s_%d_%d", authType, userID, time.Now().Unix()), ExpiresAt: jwt.NewNumericDate(expirationTime), IssuedAt: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()),