From c2334a2271f1171e5f944600497955f198b63e90 Mon Sep 17 00:00:00 2001 From: ianchen0119 Date: Mon, 19 Jan 2026 13:03:27 +0000 Subject: [PATCH 1/5] chore: update instruction --- .github/copilot-instructions.md | 175 ++++++++++++++------------------ 1 file changed, 78 insertions(+), 97 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 63c3092..3070ed7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,116 +1,97 @@ -# Copilot Instructions for BSS Metrics API Server +# Gthulhu API Server - Copilot Instructions -## Project Overview -This is a Go-based API server that bridges Linux kernel scheduler metrics (BSS) with Kubernetes orchestration. The core purpose is to collect scheduling metrics from eBPF programs and provide intelligent scheduling strategies back to the kernel based on Kubernetes pod labels and process patterns. +## Architecture Overview -## Architecture & Key Components +This is a **dual-mode Go API server** that bridges Linux kernel scheduling (sched_ext) with Kubernetes: -### Core Data Flow -1. **Metrics Collection**: eBPF programs send BSS scheduler metrics to `/api/v1/metrics` -2. **Process Discovery**: Server scans `/proc` filesystem to map processes to Kubernetes pods via cgroup parsing -3. **Strategy Generation**: Combines user-defined strategies with live pod label data to generate scheduling decisions -4. **Strategy Delivery**: Returns concrete PID-based scheduling strategies to the kernel scheduler +- **Manager** (`manager/`): Central management service (port 8081) - handles users, RBAC, strategies, and distributes scheduling intents +- **Decision Maker** (`decisionmaker/`): DaemonSet per-node (port 8080) - receives intents, scans `/proc` for PIDs, and interfaces with eBPF scheduler -### Critical File Structure -- `main.go`: Single-file monolith containing all HTTP handlers and core logic -- `kubernetes.go`: K8s client with caching layer for pod metadata -- `config.go`: Configuration with default scheduling strategies -- `options.go`: CLI argument parsing with dual-mode support (in-cluster vs external) +Both modes share the same binary, selected via `main.go manager` or `main.go decisionmaker` subcommands. -## Development Patterns +## Project Structure & Layered Architecture + +Each service follows **Clean Architecture** with strict layer separation: -### Data Structure Conventions -All API responses follow this pattern: -```go -type Response struct { - Success bool `json:"success"` - Message string `json:"message"` - Timestamp string `json:"timestamp"` - // ... specific data fields -} ``` +{manager,decisionmaker}/ +├── app/ # Fx modules for DI wiring +├── cmd/ # Cobra command definitions +├── domain/ # Interfaces, entities, DTOs (Repository, Service, K8SAdapter) +├── rest/ # Echo handlers, routes, middleware +├── service/ # Business logic implementations +└── repository/ # MongoDB persistence (manager only) +``` + +Key interfaces in `manager/domain/interface.go`: +- `Repository` - data persistence +- `Service` - business logic +- `K8SAdapter` - Kubernetes Pod queries via Informer +- `DecisionMakerAdapter` - sends intents to DM nodes + +## Development Commands + +```bash +# Local infrastructure +make local-infra-up # Start MongoDB via docker-compose +make local-run-manager # Run Manager locally + +# Testing +make test-all # Run all tests sequentially (required for integration tests) +go test -v ./manager/rest/... # Run specific package tests -### Scheduling Strategy Resolution -The system uses a two-phase approach: -1. **Template Strategies**: Defined with label selectors and regex patterns -2. **Concrete Strategies**: Resolved to specific PIDs for kernel consumption +# Mocks & Docs +make gen-mock # Generate mocks via mockery (from domain interfaces) +make gen-manager-swagger # Generate Swagger docs -Example template to concrete transformation: +# Kind cluster +make local-kind-setup # Setup local Kind cluster +make local-kind-teardown # Teardown Kind cluster +``` + +## Testing Patterns + +Integration tests use **testcontainers** pattern (`pkg/container/`): +- `HandlerTestSuite` in `manager/rest/handler_test.go` spins up a real MongoDB container +- Use `app.TestRepoModule()` for container setup with Fx +- Mock K8S/DM adapters with mockery-generated mocks from `domain/mock_domain.go` +- Each test cleans DB via `util.MongoCleanup()` in `SetupTest()` + +Example test structure: ```go -// Template (from config/API) -{ - "selectors": [{"key": "nf", "value": "upf"}], - "command_regex": "nr-gnb|ping", - "execution_time": 20000000 +func (suite *HandlerTestSuite) TestSomething() { + suite.MockK8SAdapter.EXPECT().QueryPods(...).Return(...) + // Call handler, assert response } - -// Becomes multiple concrete strategies -[ - {"pid": 12345, "execution_time": 20000000}, - {"pid": 12346, "execution_time": 20000000} -] ``` -### Kubernetes Integration Patterns -- **Dual Mode Support**: Always handle both in-cluster (`--in-cluster=true`) and external kubeconfig modes -- **Graceful Degradation**: If K8s client fails, continue with empty pod labels rather than crashing -- **Caching Strategy**: 30-second TTL on pod label lookups to reduce API pressure -- **RBAC Requirements**: Needs `pods` and `namespaces` read access (see `k8s/deployment.yaml`) +## Key Conventions -### Error Handling Approach -- Return structured JSON errors, never plain text -- Log errors but don't expose internal details in API responses -- Use `log.Printf()` for all logging (no structured logging framework) +### Dependency Injection +- Use **Uber Fx** for DI - see `manager/app/module.go` for module composition +- Service constructors take `Params struct` with `fx.In` tag -## Key Development Workflows +### Configuration +- TOML configs in `config/` with `_config.go` parsers using Viper +- Sensitive values use `SecretValue` type (masked in logs) +- Test config: `manager_config.test.toml` -### Running & Testing -```bash -# Local development with external K8s -make run -# or with specific kubeconfig -go run main.go --kubeconfig=/path/to/config +### REST API +- **Echo** framework with custom handler wrapper: `h.echoHandler(h.MethodName)` +- Auth middleware: `h.GetAuthMiddleware(domain.PermissionKey)` +- Routes in `rest/routes.go`, all versioned under `/api/v1` -# Testing strategy APIs -make test-strategies +### Error Handling +- Domain errors in `manager/domain/errors.go` and `manager/errs/errors.go` +- Use `pkg/errors` for wrapping -# Container deployment -make docker-build && make k8s-deploy -``` +### Database +- MongoDB v2 driver (`go.mongodb.org/mongo-driver/v2`) +- Migrations in `manager/migration/` (JSON format, run via golang-migrate) +- Collections: `users`, `roles`, `permissions`, `schedule_strategies`, `schedule_intents` + +## Important Entities -### Adding New Scheduling Logic -1. Extend `SchedulingStrategy` struct in `main.go` -2. Update `findPIDsByStrategy()` function for new matching logic -3. Modify template-to-concrete resolution in `GetSchedulingStrategiesHandler` -4. Update default config in `config.go` - -### Process-to-Pod Mapping Logic -The system parses `/proc//cgroup` looking for kubepods patterns: -- Format: `/kubepods/burstable/pod/` -- Extracts pod UID, then queries K8s API for labels -- Critical for linking kernel processes to pod scheduling policies - -## Integration Points - -### External Dependencies -- **Gorilla Mux**: HTTP routing (`github.com/gorilla/mux`) -- **Kubernetes Client**: Official Go client for pod metadata -- **Linux /proc filesystem**: Direct parsing for process discovery - -### API Contract with eBPF Clients -- BSS metrics use specific field names (e.g., `nr_queued`, `usersched_last_run_at`) -- Scheduling strategies return nanosecond `execution_time` values -- All timestamps in RFC3339 format - -### Deployment Considerations -- Requires privileged access to `/proc` filesystem -- Needs K8s RBAC permissions for pod/namespace reads -- Typically deployed in `kube-system` namespace -- Health checks on `/health` endpoint - -## Common Gotchas -- Pod UIDs from cgroup paths need underscore-to-dash conversion -- Strategy resolution happens on every GET request (no caching) -- Kubernetes client initialization is lazy and retried -- Configuration file is optional; defaults are comprehensive -- All scheduling times are in nanoseconds, not milliseconds +- `ScheduleStrategy` - Pod label selectors + scheduling params (priority, execution time) +- `ScheduleIntent` - Concrete intent for a specific Pod, distributed to DM nodes +- `User`, `Role`, `Permission` - RBAC model with JWT (RSA) authentication From bed5d6de737164efae4a408700e2c26aaaf0a200 Mon Sep 17 00:00:00 2001 From: ianchen0119 Date: Mon, 19 Jan 2026 13:33:37 +0000 Subject: [PATCH 2/5] feat(api): add delete APIs for strategies and intents Manager: - Add DELETE /api/v1/strategies to delete schedule strategy with cascade delete of associated intents - Add DELETE /api/v1/intents to batch delete schedule intents - Add new permissions: schedule_strategy.delete, schedule_intent.delete - Add Repository methods: DeleteStrategy, DeleteIntents, DeleteIntentsByStrategyID - Add Service methods: DeleteScheduleStrategy, DeleteScheduleIntents - Add integration tests for delete operations Decision Maker: - Add DELETE /api/v1/intents to delete scheduling intents - Support delete by PodID, by PodID+PID, or delete all - Add Service methods: DeleteIntentByPodID, DeleteIntentByPID, DeleteAllIntents --- decisionmaker/rest/handler.go | 1 + decisionmaker/rest/intent_handler.go | 44 +++++++++++++ decisionmaker/service/service.go | 36 ++++++++++ manager/domain/enums.go | 2 + manager/domain/interface.go | 5 ++ manager/repository/strategy_repo.go | 18 +++++ manager/rest/routes.go | 2 + manager/rest/strategy_hdl.go | 98 ++++++++++++++++++++++++++++ manager/rest/strategy_hdl_test.go | 83 +++++++++++++++++++++++ manager/service/strategy_svc.go | 82 +++++++++++++++++++++++ 10 files changed, 371 insertions(+) diff --git a/decisionmaker/rest/handler.go b/decisionmaker/rest/handler.go index 8f0cead..32ddacf 100644 --- a/decisionmaker/rest/handler.go +++ b/decisionmaker/rest/handler.go @@ -162,6 +162,7 @@ func (h *Handler) SetupRoutes(engine *echo.Echo) error { apiV1 := api.Group("/v1") // auth routes apiV1.POST("/intents", h.echoHandler(h.HandleIntents), echo.WrapMiddleware(authMiddleware)) + apiV1.DELETE("/intents", h.echoHandler(h.DeleteIntent), echo.WrapMiddleware(authMiddleware)) apiV1.GET("/scheduling/strategies", h.echoHandler(h.ListIntents), echo.WrapMiddleware(authMiddleware)) apiV1.POST("/metrics", h.echoHandler(h.UpdateMetrics), echo.WrapMiddleware(authMiddleware)) // token routes diff --git a/decisionmaker/rest/intent_handler.go b/decisionmaker/rest/intent_handler.go index 179134a..232c024 100644 --- a/decisionmaker/rest/intent_handler.go +++ b/decisionmaker/rest/intent_handler.go @@ -111,3 +111,47 @@ func convertMapToLabelSelectors(selectorMap []domain.LabelSelector) []LabelSelec } return labelSelectors } + +type DeleteIntentRequest struct { + PodID string `json:"podId,omitempty"` // If provided, deletes all intents for this pod + PID *int `json:"pid,omitempty"` // If provided with PodID, deletes specific intent + All bool `json:"all,omitempty"` // If true, deletes all intents +} + +func (h *Handler) DeleteIntent(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var req DeleteIntentRequest + err := h.JSONBind(r, &req) + if err != nil { + h.ErrorResponse(ctx, w, http.StatusBadRequest, "Invalid request payload", err) + return + } + + if req.All { + err = h.Service.DeleteAllIntents(ctx) + if err != nil { + h.ErrorResponse(ctx, w, http.StatusInternalServerError, "Failed to delete all intents", err) + return + } + h.JSONResponse(ctx, w, http.StatusOK, NewSuccessResponse[EmptyResponse](nil)) + return + } + + if req.PodID == "" { + h.ErrorResponse(ctx, w, http.StatusBadRequest, "PodID is required when 'all' is false", nil) + return + } + + if req.PID != nil { + err = h.Service.DeleteIntentByPID(ctx, req.PodID, *req.PID) + } else { + err = h.Service.DeleteIntentByPodID(ctx, req.PodID) + } + + if err != nil { + h.ErrorResponse(ctx, w, http.StatusInternalServerError, "Failed to delete intent", err) + return + } + + h.JSONResponse(ctx, w, http.StatusOK, NewSuccessResponse[EmptyResponse](nil)) +} diff --git a/decisionmaker/service/service.go b/decisionmaker/service/service.go index 841eee8..53c51c2 100644 --- a/decisionmaker/service/service.go +++ b/decisionmaker/service/service.go @@ -244,3 +244,39 @@ func (svc *Service) getProcessInfo(rootDir string, pid int) (domain.PodProcess, func (svc *Service) UpdateMetrics(ctx context.Context, newMetricSet *domain.MetricSet) { svc.metricCollector.UpdateMetrics(newMetricSet) } + +// DeleteIntentByPodID deletes all scheduling intents for a specific pod ID +func (svc *Service) DeleteIntentByPodID(ctx context.Context, podID string) error { + keysToDelete := []string{} + svc.schedulingIntentsMap.Range(func(key string, value []*domain.SchedulingIntents) bool { + if strings.HasPrefix(key, podID+"-") { + keysToDelete = append(keysToDelete, key) + } + return true + }) + for _, key := range keysToDelete { + svc.schedulingIntentsMap.Delete(key) + } + logger.Logger(ctx).Info().Msgf("Deleted %d scheduling intents for pod ID: %s", len(keysToDelete), podID) + return nil +} + +// DeleteIntentByPID deletes a specific scheduling intent by pod ID and PID +func (svc *Service) DeleteIntentByPID(ctx context.Context, podID string, pid int) error { + key := fmt.Sprintf("%s-%d", podID, pid) + svc.schedulingIntentsMap.Delete(key) + logger.Logger(ctx).Info().Msgf("Deleted scheduling intent for key: %s", key) + return nil +} + +// DeleteAllIntents clears all scheduling intents +func (svc *Service) DeleteAllIntents(ctx context.Context) error { + count := 0 + svc.schedulingIntentsMap.Range(func(key string, value []*domain.SchedulingIntents) bool { + svc.schedulingIntentsMap.Delete(key) + count++ + return true + }) + logger.Logger(ctx).Info().Msgf("Deleted all %d scheduling intents", count) + return nil +} diff --git a/manager/domain/enums.go b/manager/domain/enums.go index be957be..a9655b0 100644 --- a/manager/domain/enums.go +++ b/manager/domain/enums.go @@ -14,7 +14,9 @@ const ( PermissionRead PermissionKey = "permission.read" ScheduleStrategyCreate PermissionKey = "schedule_strategy.create" ScheduleStrategyRead PermissionKey = "schedule_strategy.read" + ScheduleStrategyDelete PermissionKey = "schedule_strategy.delete" ScheduleIntentRead PermissionKey = "schedule_intent.read" + ScheduleIntentDelete PermissionKey = "schedule_intent.delete" ) const ( diff --git a/manager/domain/interface.go b/manager/domain/interface.go index 57dac91..8869ed1 100644 --- a/manager/domain/interface.go +++ b/manager/domain/interface.go @@ -66,6 +66,9 @@ type Repository interface { BatchUpdateIntentsState(ctx context.Context, intentIDs []bson.ObjectID, newState IntentState) error QueryStrategies(ctx context.Context, opt *QueryStrategyOptions) error QueryIntents(ctx context.Context, opt *QueryIntentOptions) error + DeleteStrategy(ctx context.Context, strategyID bson.ObjectID) error + DeleteIntents(ctx context.Context, intentIDs []bson.ObjectID) error + DeleteIntentsByStrategyID(ctx context.Context, strategyID bson.ObjectID) error } type Service interface { @@ -87,6 +90,8 @@ type Service interface { CreateScheduleStrategy(ctx context.Context, operator *Claims, strategy *ScheduleStrategy) error ListScheduleStrategies(ctx context.Context, filterOpts *QueryStrategyOptions) error ListScheduleIntents(ctx context.Context, filterOpts *QueryIntentOptions) error + DeleteScheduleStrategy(ctx context.Context, operator *Claims, strategyID string) error + DeleteScheduleIntents(ctx context.Context, operator *Claims, intentIDs []string) error } type QueryPodsOptions struct { diff --git a/manager/repository/strategy_repo.go b/manager/repository/strategy_repo.go index 1bbd768..896a7e1 100644 --- a/manager/repository/strategy_repo.go +++ b/manager/repository/strategy_repo.go @@ -126,3 +126,21 @@ func (r *repo) QueryIntents(ctx context.Context, opt *domain.QueryIntentOptions) } return cursor.Err() } + +func (r *repo) DeleteStrategy(ctx context.Context, strategyID bson.ObjectID) error { + _, err := r.db.Collection(scheduleStrategyCollection).DeleteOne(ctx, bson.M{"_id": strategyID}) + return err +} + +func (r *repo) DeleteIntents(ctx context.Context, intentIDs []bson.ObjectID) error { + if len(intentIDs) == 0 { + return nil + } + _, err := r.db.Collection(scheduleIntentCollection).DeleteMany(ctx, bson.M{"_id": bson.M{"$in": intentIDs}}) + return err +} + +func (r *repo) DeleteIntentsByStrategyID(ctx context.Context, strategyID bson.ObjectID) error { + _, err := r.db.Collection(scheduleIntentCollection).DeleteMany(ctx, bson.M{"strategyID": strategyID}) + return err +} diff --git a/manager/rest/routes.go b/manager/rest/routes.go index 82f1fc5..fd16e76 100644 --- a/manager/rest/routes.go +++ b/manager/rest/routes.go @@ -40,7 +40,9 @@ func (h *Handler) SetupRoutes(engine *echo.Echo) { // strategy routes apiV1.POST("/strategies", h.echoHandler(h.CreateScheduleStrategy), echo.WrapMiddleware(h.GetAuthMiddleware(domain.ScheduleStrategyCreate))) apiV1.GET("/strategies/self", h.echoHandler(h.ListSelfScheduleStrategies), echo.WrapMiddleware(h.GetAuthMiddleware(domain.ScheduleStrategyRead))) + apiV1.DELETE("/strategies", h.echoHandler(h.DeleteScheduleStrategy), echo.WrapMiddleware(h.GetAuthMiddleware(domain.ScheduleStrategyDelete))) apiV1.GET("/intents/self", h.echoHandler(h.ListSelfScheduleIntents), echo.WrapMiddleware(h.GetAuthMiddleware(domain.ScheduleIntentRead))) + apiV1.DELETE("/intents", h.echoHandler(h.DeleteScheduleIntents), echo.WrapMiddleware(h.GetAuthMiddleware(domain.ScheduleIntentDelete))) } } diff --git a/manager/rest/strategy_hdl.go b/manager/rest/strategy_hdl.go index 07886a9..2fc9c7e 100644 --- a/manager/rest/strategy_hdl.go +++ b/manager/rest/strategy_hdl.go @@ -235,3 +235,101 @@ func (h *Handler) convertDomainIntentToResponseIntent(domainIntent *domain.Sched State: domainIntent.State, } } + +type DeleteScheduleStrategyRequest struct { + StrategyID string `json:"strategyId"` +} + +// DeleteScheduleStrategy godoc +// @Summary Delete schedule strategy +// @Description Delete a schedule strategy and its associated intents. +// @Tags Strategies +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body DeleteScheduleStrategyRequest true "Strategy ID to delete" +// @Success 200 {object} SuccessResponse[EmptyResponse] +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/strategies [delete] +func (h *Handler) DeleteScheduleStrategy(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var req DeleteScheduleStrategyRequest + err := h.JSONBind(r, &req) + if err != nil { + h.ErrorResponse(ctx, w, http.StatusBadRequest, "Invalid request body", err) + return + } + + if req.StrategyID == "" { + h.ErrorResponse(ctx, w, http.StatusBadRequest, "Strategy ID is required", nil) + return + } + + claims, ok := h.GetClaimsFromContext(ctx) + if !ok { + h.ErrorResponse(ctx, w, http.StatusUnauthorized, "Unauthorized", nil) + return + } + + err = h.Svc.DeleteScheduleStrategy(ctx, &claims, req.StrategyID) + if err != nil { + h.HandleError(ctx, w, err) + return + } + + response := NewSuccessResponse[string](nil) + h.JSONResponse(ctx, w, http.StatusOK, response) +} + +type DeleteScheduleIntentsRequest struct { + IntentIDs []string `json:"intentIds"` +} + +// DeleteScheduleIntents godoc +// @Summary Delete schedule intents +// @Description Delete one or more schedule intents. +// @Tags Strategies +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body DeleteScheduleIntentsRequest true "Intent IDs to delete" +// @Success 200 {object} SuccessResponse[EmptyResponse] +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/intents [delete] +func (h *Handler) DeleteScheduleIntents(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var req DeleteScheduleIntentsRequest + err := h.JSONBind(r, &req) + if err != nil { + h.ErrorResponse(ctx, w, http.StatusBadRequest, "Invalid request body", err) + return + } + + if len(req.IntentIDs) == 0 { + h.ErrorResponse(ctx, w, http.StatusBadRequest, "At least one intent ID is required", nil) + return + } + + claims, ok := h.GetClaimsFromContext(ctx) + if !ok { + h.ErrorResponse(ctx, w, http.StatusUnauthorized, "Unauthorized", nil) + return + } + + err = h.Svc.DeleteScheduleIntents(ctx, &claims, req.IntentIDs) + if err != nil { + h.HandleError(ctx, w, err) + return + } + + response := NewSuccessResponse[string](nil) + h.JSONResponse(ctx, w, http.StatusOK, response) +} diff --git a/manager/rest/strategy_hdl_test.go b/manager/rest/strategy_hdl_test.go index ef1ee2b..2613b9d 100644 --- a/manager/rest/strategy_hdl_test.go +++ b/manager/rest/strategy_hdl_test.go @@ -44,6 +44,71 @@ func (suite *HandlerTestSuite) TestIntegrationStrategyHandler() { suite.Require().Equal(strategyReq.ExecutionTime, intents.Intents[0].ExecutionTime, "ExecutionTime mismatch") } +func (suite *HandlerTestSuite) TestIntegrationDeleteStrategyHandler() { + adminUser, adminPwd := config.GetManagerConfig().Account.AdminEmail, config.GetManagerConfig().Account.AdminPassword + adminToken := suite.login(adminUser, adminPwd.Value(), http.StatusOK) + + strategyReq := rest.CreateScheduleStrategyRequest{ + LabelSelectors: []rest.LabelSelector{ + { + Key: "test", Value: "test", + }, + }, + Priority: 100, + ExecutionTime: 100, + } + + // Create strategy + suite.MockK8SAdapter.EXPECT().QueryPods(mock.Anything, mock.Anything).Return([]*domain.Pod{{PodID: "Test", Labels: map[string]string{"test": "test"}, NodeID: "test"}}, nil).Once() + suite.MockK8SAdapter.EXPECT().QueryDecisionMakerPods(mock.Anything, mock.Anything).Return([]*domain.DecisionMakerPod{{Host: "dm-host", NodeID: "test", Port: 8080}}, nil).Once() + suite.MockDMAdapter.EXPECT().SendSchedulingIntent(mock.Anything, mock.Anything, mock.Anything).Return(nil).Times(1) + suite.createStrategy(adminToken, &strategyReq, http.StatusOK) + + strategies := suite.listSelfStrategies(adminToken, http.StatusOK) + suite.Require().Len(strategies.Strategies, 1, "Expected one strategy") + + // Delete the strategy + suite.deleteStrategy(adminToken, strategies.Strategies[0].ID.Hex(), http.StatusOK) + + // Verify strategy and intents are deleted + strategies = suite.listSelfStrategies(adminToken, http.StatusOK) + suite.Require().Len(strategies.Strategies, 0, "Expected no strategies after deletion") + + intents := suite.listSelfIntents(adminToken, http.StatusOK) + suite.Require().Len(intents.Intents, 0, "Expected no intents after strategy deletion") +} + +func (suite *HandlerTestSuite) TestIntegrationDeleteIntentsHandler() { + adminUser, adminPwd := config.GetManagerConfig().Account.AdminEmail, config.GetManagerConfig().Account.AdminPassword + adminToken := suite.login(adminUser, adminPwd.Value(), http.StatusOK) + + strategyReq := rest.CreateScheduleStrategyRequest{ + LabelSelectors: []rest.LabelSelector{ + { + Key: "test", Value: "test", + }, + }, + Priority: 100, + ExecutionTime: 100, + } + + // Create strategy + suite.MockK8SAdapter.EXPECT().QueryPods(mock.Anything, mock.Anything).Return([]*domain.Pod{{PodID: "Test1", Labels: map[string]string{"test": "test"}, NodeID: "test"}, {PodID: "Test2", Labels: map[string]string{"test": "test"}, NodeID: "test"}}, nil).Once() + suite.MockK8SAdapter.EXPECT().QueryDecisionMakerPods(mock.Anything, mock.Anything).Return([]*domain.DecisionMakerPod{{Host: "dm-host", NodeID: "test", Port: 8080}}, nil).Once() + suite.MockDMAdapter.EXPECT().SendSchedulingIntent(mock.Anything, mock.Anything, mock.Anything).Return(nil).Times(1) + suite.createStrategy(adminToken, &strategyReq, http.StatusOK) + + intents := suite.listSelfIntents(adminToken, http.StatusOK) + suite.Require().Len(intents.Intents, 2, "Expected two intents") + + // Delete one intent + suite.deleteIntents(adminToken, []string{intents.Intents[0].ID.Hex()}, http.StatusOK) + + // Verify only one intent remains + intents = suite.listSelfIntents(adminToken, http.StatusOK) + suite.Require().Len(intents.Intents, 1, "Expected one intent after deletion") +} + func (suite *HandlerTestSuite) createStrategy(token string, strategyReq *rest.CreateScheduleStrategyRequest, expectedStatus int) { createStrategyResp := rest.SuccessResponse[string]{} _, resp := suite.sendV1Request("POST", "/strategies", strategyReq, &createStrategyResp, token) @@ -63,3 +128,21 @@ func (suite *HandlerTestSuite) listSelfIntents(token string, expectedStatus int) suite.Require().Equal(expectedStatus, resp.Code, "Unexpected status code on create strategy") return listStrategiesResp.Data } + +func (suite *HandlerTestSuite) deleteStrategy(token string, strategyID string, expectedStatus int) { + deleteReq := rest.DeleteScheduleStrategyRequest{ + StrategyID: strategyID, + } + deleteResp := rest.SuccessResponse[string]{} + _, resp := suite.sendV1Request("DELETE", "/strategies", deleteReq, &deleteResp, token) + suite.Require().Equal(expectedStatus, resp.Code, "Unexpected status code on delete strategy") +} + +func (suite *HandlerTestSuite) deleteIntents(token string, intentIDs []string, expectedStatus int) { + deleteReq := rest.DeleteScheduleIntentsRequest{ + IntentIDs: intentIDs, + } + deleteResp := rest.SuccessResponse[string]{} + _, resp := suite.sendV1Request("DELETE", "/intents", deleteReq, &deleteResp, token) + suite.Require().Equal(expectedStatus, resp.Code, "Unexpected status code on delete intents") +} diff --git a/manager/service/strategy_svc.go b/manager/service/strategy_svc.go index 32cfa1d..b34c4e8 100644 --- a/manager/service/strategy_svc.go +++ b/manager/service/strategy_svc.go @@ -105,3 +105,85 @@ func (svc *Service) ListScheduleStrategies(ctx context.Context, filterOpts *doma func (svc *Service) ListScheduleIntents(ctx context.Context, filterOpts *domain.QueryIntentOptions) error { return svc.Repo.QueryIntents(ctx, filterOpts) } + +func (svc *Service) DeleteScheduleStrategy(ctx context.Context, operator *domain.Claims, strategyID string) error { + strategyObjID, err := bson.ObjectIDFromHex(strategyID) + if err != nil { + return errors.WithMessagef(err, "invalid strategy ID %s", strategyID) + } + + operatorID, err := operator.GetBsonObjectUID() + if err != nil { + return errors.WithMessagef(err, "invalid operator ID %s", operator.UID) + } + + // Check if strategy exists and belongs to the operator + queryOpt := &domain.QueryStrategyOptions{ + IDs: []bson.ObjectID{strategyObjID}, + CreatorIDs: []bson.ObjectID{operatorID}, + } + err = svc.Repo.QueryStrategies(ctx, queryOpt) + if err != nil { + return err + } + if len(queryOpt.Result) == 0 { + return errs.NewHTTPStatusError(http.StatusNotFound, "strategy not found or you don't have permission to delete it", nil) + } + + // Delete associated intents first + err = svc.Repo.DeleteIntentsByStrategyID(ctx, strategyObjID) + if err != nil { + return fmt.Errorf("delete intents by strategy ID: %w", err) + } + + // Delete the strategy + err = svc.Repo.DeleteStrategy(ctx, strategyObjID) + if err != nil { + return fmt.Errorf("delete strategy: %w", err) + } + + logger.Logger(ctx).Info().Msgf("deleted strategy %s and its associated intents", strategyID) + return nil +} + +func (svc *Service) DeleteScheduleIntents(ctx context.Context, operator *domain.Claims, intentIDs []string) error { + if len(intentIDs) == 0 { + return nil + } + + operatorID, err := operator.GetBsonObjectUID() + if err != nil { + return errors.WithMessagef(err, "invalid operator ID %s", operator.UID) + } + + intentObjIDs := make([]bson.ObjectID, 0, len(intentIDs)) + for _, id := range intentIDs { + objID, err := bson.ObjectIDFromHex(id) + if err != nil { + return errors.WithMessagef(err, "invalid intent ID %s", id) + } + intentObjIDs = append(intentObjIDs, objID) + } + + // Check if intents exist and belong to the operator + queryOpt := &domain.QueryIntentOptions{ + IDs: intentObjIDs, + CreatorIDs: []bson.ObjectID{operatorID}, + } + err = svc.Repo.QueryIntents(ctx, queryOpt) + if err != nil { + return err + } + if len(queryOpt.Result) != len(intentIDs) { + return errs.NewHTTPStatusError(http.StatusNotFound, "one or more intents not found or you don't have permission to delete them", nil) + } + + // Delete the intents + err = svc.Repo.DeleteIntents(ctx, intentObjIDs) + if err != nil { + return fmt.Errorf("delete intents: %w", err) + } + + logger.Logger(ctx).Info().Msgf("deleted %d intents", len(intentIDs)) + return nil +} From f04926d16777f15167fba674d7432da8c6c6638f Mon Sep 17 00:00:00 2001 From: ianchen0119 Date: Tue, 20 Jan 2026 07:30:06 +0000 Subject: [PATCH 3/5] fix: address authorization bypass and sync.Map concurrency issues - Add CreatorIDs filter support in QueryStrategies and QueryIntents repository methods - Improve ownership verification in DeleteScheduleIntents service method - Fix sync.Map concurrent delete issue in DecisionMaker's DeleteAllIntents - Use EmptyResponse instead of nil in delete handlers - Add DeleteSchedulingIntents to notify DecisionMakers when deleting strategies/intents --- .github/workflows/ci.yml | 2 +- decisionmaker/service/service.go | 12 ++- go.mod | 4 +- manager/client/deicison_maker.go | 64 +++++++++++++++ manager/domain/interface.go | 6 ++ manager/domain/mock_domain.go | 63 +++++++++++++++ manager/repository/strategy_repo.go | 6 ++ manager/rest/strategy_hdl.go | 4 +- manager/service/strategy_svc.go | 119 +++++++++++++++++++++++++++- 9 files changed, 269 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27255dc..590a3fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: '1.24.5' - name: Download dependencies run: go mod download diff --git a/decisionmaker/service/service.go b/decisionmaker/service/service.go index 53c51c2..d02f443 100644 --- a/decisionmaker/service/service.go +++ b/decisionmaker/service/service.go @@ -271,12 +271,16 @@ func (svc *Service) DeleteIntentByPID(ctx context.Context, podID string, pid int // DeleteAllIntents clears all scheduling intents func (svc *Service) DeleteAllIntents(ctx context.Context) error { - count := 0 + keysToDelete := []string{} svc.schedulingIntentsMap.Range(func(key string, value []*domain.SchedulingIntents) bool { - svc.schedulingIntentsMap.Delete(key) - count++ + keysToDelete = append(keysToDelete, key) return true }) - logger.Logger(ctx).Info().Msgf("Deleted all %d scheduling intents", count) + + for _, key := range keysToDelete { + svc.schedulingIntentsMap.Delete(key) + } + + logger.Logger(ctx).Info().Msgf("Deleted all %d scheduling intents", len(keysToDelete)) return nil } diff --git a/go.mod b/go.mod index 211e2ef..7fcc4e0 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/Gthulhu/api -go 1.24.0 - -toolchain go1.24.5 +go 1.24.5 tool github.com/vektra/mockery/v3 diff --git a/manager/client/deicison_maker.go b/manager/client/deicison_maker.go index d2df6df..c0aff54 100644 --- a/manager/client/deicison_maker.go +++ b/manager/client/deicison_maker.go @@ -119,3 +119,67 @@ func (dm *DecisionMakerClient) GetToken(ctx context.Context, decisionMaker *doma return tokenResp.Data.Token, nil } + +func (dm *DecisionMakerClient) DeleteSchedulingIntents(ctx context.Context, decisionMaker *domain.DecisionMakerPod, req *domain.DeleteIntentsRequest) error { + token, err := dm.GetToken(ctx, decisionMaker) + if err != nil { + return err + } + + logger.Logger(ctx).Debug().Msgf("Deleting scheduling intents from decision maker pod (host:%s nodeID:%s port:%d)", decisionMaker.Host, decisionMaker.NodeID, decisionMaker.Port) + + // If All is true, delete all intents; otherwise delete by PodIDs one by one + if req.All { + deleteReq := dmrest.DeleteIntentRequest{ + All: true, + } + jsonBody, err := json.Marshal(deleteReq) + if err != nil { + return err + } + endpoint := "http://" + decisionMaker.Host + ":" + strconv.Itoa(decisionMaker.Port) + "/api/v1/intents" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, bytes.NewBuffer(jsonBody)) + if err != nil { + return err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + resp, err := dm.Client.Do(httpReq) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("decision maker %s returned non-OK status: %s", decisionMaker, resp.Status) + } + return nil + } + + // Delete intents by PodID + for _, podID := range req.PodIDs { + deleteReq := dmrest.DeleteIntentRequest{ + PodID: podID, + } + jsonBody, err := json.Marshal(deleteReq) + if err != nil { + return err + } + endpoint := "http://" + decisionMaker.Host + ":" + strconv.Itoa(decisionMaker.Port) + "/api/v1/intents" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, bytes.NewBuffer(jsonBody)) + if err != nil { + return err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+token) + resp, err := dm.Client.Do(httpReq) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("decision maker %s returned non-OK status for podID %s: %s", decisionMaker, podID, resp.Status) + } + } + + return nil +} diff --git a/manager/domain/interface.go b/manager/domain/interface.go index 8869ed1..f71b17a 100644 --- a/manager/domain/interface.go +++ b/manager/domain/interface.go @@ -111,6 +111,12 @@ type K8SAdapter interface { QueryDecisionMakerPods(ctx context.Context, opt *QueryDecisionMakerPodsOptions) ([]*DecisionMakerPod, error) } +type DeleteIntentsRequest struct { + PodIDs []string // Delete all intents for these pods + All bool // If true, deletes all intents on the decision maker +} + type DecisionMakerAdapter interface { SendSchedulingIntent(ctx context.Context, decisionMaker *DecisionMakerPod, intents []*ScheduleIntent) error + DeleteSchedulingIntents(ctx context.Context, decisionMaker *DecisionMakerPod, req *DeleteIntentsRequest) error } diff --git a/manager/domain/mock_domain.go b/manager/domain/mock_domain.go index a5063c4..d6b1125 100644 --- a/manager/domain/mock_domain.go +++ b/manager/domain/mock_domain.go @@ -2216,3 +2216,66 @@ func (_c *MockDecisionMakerAdapter_SendSchedulingIntent_Call) RunAndReturn(run f _c.Call.Return(run) return _c } + +// DeleteSchedulingIntents provides a mock function for the type MockDecisionMakerAdapter +func (_mock *MockDecisionMakerAdapter) DeleteSchedulingIntents(ctx context.Context, decisionMaker *DecisionMakerPod, req *DeleteIntentsRequest) error { + ret := _mock.Called(ctx, decisionMaker, req) + + if len(ret) == 0 { + panic("no return value specified for DeleteSchedulingIntents") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *DecisionMakerPod, *DeleteIntentsRequest) error); ok { + r0 = returnFunc(ctx, decisionMaker, req) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockDecisionMakerAdapter_DeleteSchedulingIntents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSchedulingIntents' +type MockDecisionMakerAdapter_DeleteSchedulingIntents_Call struct { + *mock.Call +} + +// DeleteSchedulingIntents is a helper method to define mock.On call +// - ctx context.Context +// - decisionMaker *DecisionMakerPod +// - req *DeleteIntentsRequest +func (_e *MockDecisionMakerAdapter_Expecter) DeleteSchedulingIntents(ctx interface{}, decisionMaker interface{}, req interface{}) *MockDecisionMakerAdapter_DeleteSchedulingIntents_Call { + return &MockDecisionMakerAdapter_DeleteSchedulingIntents_Call{Call: _e.mock.On("DeleteSchedulingIntents", ctx, decisionMaker, req)} +} + +func (_c *MockDecisionMakerAdapter_DeleteSchedulingIntents_Call) Run(run func(ctx context.Context, decisionMaker *DecisionMakerPod, req *DeleteIntentsRequest)) *MockDecisionMakerAdapter_DeleteSchedulingIntents_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *DecisionMakerPod + if args[1] != nil { + arg1 = args[1].(*DecisionMakerPod) + } + var arg2 *DeleteIntentsRequest + if args[2] != nil { + arg2 = args[2].(*DeleteIntentsRequest) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockDecisionMakerAdapter_DeleteSchedulingIntents_Call) Return(err error) *MockDecisionMakerAdapter_DeleteSchedulingIntents_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockDecisionMakerAdapter_DeleteSchedulingIntents_Call) RunAndReturn(run func(ctx context.Context, decisionMaker *DecisionMakerPod, req *DeleteIntentsRequest) error) *MockDecisionMakerAdapter_DeleteSchedulingIntents_Call { + _c.Call.Return(run) + return _c +} diff --git a/manager/repository/strategy_repo.go b/manager/repository/strategy_repo.go index 896a7e1..0b7ca48 100644 --- a/manager/repository/strategy_repo.go +++ b/manager/repository/strategy_repo.go @@ -75,6 +75,9 @@ func (r *repo) QueryStrategies(ctx context.Context, opt *domain.QueryStrategyOpt if len(opt.K8SNamespaces) > 0 { filter["k8sNamespace"] = bson.M{"$in": opt.K8SNamespaces} } + if len(opt.CreatorIDs) > 0 { + filter["creatorID"] = bson.M{"$in": opt.CreatorIDs} + } cursor, err := r.db.Collection(scheduleStrategyCollection).Find(ctx, filter) if err != nil { return err @@ -111,6 +114,9 @@ func (r *repo) QueryIntents(ctx context.Context, opt *domain.QueryIntentOptions) if len(opt.States) > 0 { filter["state"] = bson.M{"$in": opt.States} } + if len(opt.CreatorIDs) > 0 { + filter["creatorID"] = bson.M{"$in": opt.CreatorIDs} + } cursor, err := r.db.Collection(scheduleIntentCollection).Find(ctx, filter) if err != nil { return err diff --git a/manager/rest/strategy_hdl.go b/manager/rest/strategy_hdl.go index 2fc9c7e..d645705 100644 --- a/manager/rest/strategy_hdl.go +++ b/manager/rest/strategy_hdl.go @@ -281,7 +281,7 @@ func (h *Handler) DeleteScheduleStrategy(w http.ResponseWriter, r *http.Request) return } - response := NewSuccessResponse[string](nil) + response := NewSuccessResponse[EmptyResponse](&EmptyResponse{}) h.JSONResponse(ctx, w, http.StatusOK, response) } @@ -330,6 +330,6 @@ func (h *Handler) DeleteScheduleIntents(w http.ResponseWriter, r *http.Request) return } - response := NewSuccessResponse[string](nil) + response := NewSuccessResponse[EmptyResponse](&EmptyResponse{}) h.JSONResponse(ctx, w, http.StatusOK, response) } diff --git a/manager/service/strategy_svc.go b/manager/service/strategy_svc.go index b34c4e8..dc0169d 100644 --- a/manager/service/strategy_svc.go +++ b/manager/service/strategy_svc.go @@ -130,6 +130,31 @@ func (svc *Service) DeleteScheduleStrategy(ctx context.Context, operator *domain return errs.NewHTTPStatusError(http.StatusNotFound, "strategy not found or you don't have permission to delete it", nil) } + // Query intents associated with this strategy to get node IDs and pod IDs for DM notification + intentQueryOpt := &domain.QueryIntentOptions{ + StrategyIDs: []bson.ObjectID{strategyObjID}, + } + err = svc.Repo.QueryIntents(ctx, intentQueryOpt) + if err != nil { + return fmt.Errorf("query intents for strategy: %w", err) + } + + // Collect unique node IDs and pod IDs from intents + nodeIDsMap := make(map[string]struct{}) + podIDsMap := make(map[string]struct{}) + for _, intent := range intentQueryOpt.Result { + nodeIDsMap[intent.NodeID] = struct{}{} + podIDsMap[intent.PodID] = struct{}{} + } + nodeIDs := make([]string, 0, len(nodeIDsMap)) + for nodeID := range nodeIDsMap { + nodeIDs = append(nodeIDs, nodeID) + } + podIDs := make([]string, 0, len(podIDsMap)) + for podID := range podIDsMap { + podIDs = append(podIDs, podID) + } + // Delete associated intents first err = svc.Repo.DeleteIntentsByStrategyID(ctx, strategyObjID) if err != nil { @@ -142,6 +167,31 @@ func (svc *Service) DeleteScheduleStrategy(ctx context.Context, operator *domain return fmt.Errorf("delete strategy: %w", err) } + // Notify decision makers to remove intents from their in-memory cache + if len(nodeIDs) > 0 && len(podIDs) > 0 { + dmLabel := domain.LabelSelector{ + Key: "app", + Value: "decisionmaker", + } + dmQueryOpt := &domain.QueryDecisionMakerPodsOptions{ + DecisionMakerLabel: dmLabel, + NodeIDs: nodeIDs, + } + dmPods, err := svc.K8SAdapter.QueryDecisionMakerPods(ctx, dmQueryOpt) + if err != nil { + logger.Logger(ctx).Warn().Err(err).Msg("failed to query decision maker pods for deletion notification") + } else { + deleteReq := &domain.DeleteIntentsRequest{ + PodIDs: podIDs, + } + for _, dmPod := range dmPods { + if err := svc.DMAdapter.DeleteSchedulingIntents(ctx, dmPod, deleteReq); err != nil { + logger.Logger(ctx).Warn().Err(err).Msgf("failed to notify decision maker %s to delete intents", dmPod.NodeID) + } + } + } + } + logger.Logger(ctx).Info().Msgf("deleted strategy %s and its associated intents", strategyID) return nil } @@ -174,16 +224,83 @@ func (svc *Service) DeleteScheduleIntents(ctx context.Context, operator *domain. if err != nil { return err } - if len(queryOpt.Result) != len(intentIDs) { + + // Verify that all requested intents exist, are returned by the query, + // and are owned by the current operator. + if len(queryOpt.Result) == 0 { + return errs.NewHTTPStatusError(http.StatusNotFound, "one or more intents not found or you don't have permission to delete them", nil) + } + + // Build a set of requested intent IDs for exact ID matching. + requestedIDs := make(map[bson.ObjectID]struct{}, len(intentObjIDs)) + for _, id := range intentObjIDs { + requestedIDs[id] = struct{}{} + } + + matchedCount := 0 + for _, intent := range queryOpt.Result { + // Ensure the intent belongs to the operator. + if intent.CreatorID != operatorID { + return errs.NewHTTPStatusError(http.StatusNotFound, "one or more intents not found or you don't have permission to delete them", nil) + } + + // Ensure the intent is one of the requested IDs. + if _, ok := requestedIDs[intent.ID]; ok { + matchedCount++ + } + } + + if matchedCount != len(intentObjIDs) { return errs.NewHTTPStatusError(http.StatusNotFound, "one or more intents not found or you don't have permission to delete them", nil) } + // Collect unique node IDs and pod IDs for DM notification before deleting + nodeIDsMap := make(map[string]struct{}) + podIDsMap := make(map[string]struct{}) + for _, intent := range queryOpt.Result { + nodeIDsMap[intent.NodeID] = struct{}{} + podIDsMap[intent.PodID] = struct{}{} + } + nodeIDs := make([]string, 0, len(nodeIDsMap)) + for nodeID := range nodeIDsMap { + nodeIDs = append(nodeIDs, nodeID) + } + podIDs := make([]string, 0, len(podIDsMap)) + for podID := range podIDsMap { + podIDs = append(podIDs, podID) + } + // Delete the intents err = svc.Repo.DeleteIntents(ctx, intentObjIDs) if err != nil { return fmt.Errorf("delete intents: %w", err) } + // Notify decision makers to remove intents from their in-memory cache + if len(nodeIDs) > 0 && len(podIDs) > 0 { + dmLabel := domain.LabelSelector{ + Key: "app", + Value: "decisionmaker", + } + dmQueryOpt := &domain.QueryDecisionMakerPodsOptions{ + DecisionMakerLabel: dmLabel, + NodeIDs: nodeIDs, + } + dmPods, err := svc.K8SAdapter.QueryDecisionMakerPods(ctx, dmQueryOpt) + if err != nil { + logger.Logger(ctx).Warn().Err(err).Msg("failed to query decision maker pods for deletion notification") + } else { + deleteReq := &domain.DeleteIntentsRequest{ + PodIDs: podIDs, + } + for _, dmPod := range dmPods { + if err := svc.DMAdapter.DeleteSchedulingIntents(ctx, dmPod, deleteReq); err != nil { + logger.Logger(ctx).Warn().Err(err).Msgf("failed to notify decision maker %s to delete intents", dmPod.NodeID) + } + } + } + } + logger.Logger(ctx).Info().Msgf("deleted %d intents", len(intentIDs)) return nil } From ea2c222d7a86e913f8fa64e883a6b4d4ff41a770 Mon Sep 17 00:00:00 2001 From: ianchen0119 Date: Tue, 20 Jan 2026 07:43:37 +0000 Subject: [PATCH 4/5] fix: unit test failed --- .../migration/002_init_default_permissions.up.json | 12 ++++++++++++ manager/migration/003_init_default_roles.up.json | 4 +++- manager/rest/strategy_hdl_test.go | 12 ++++++++---- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/manager/migration/002_init_default_permissions.up.json b/manager/migration/002_init_default_permissions.up.json index 6b96154..2dcd6db 100644 --- a/manager/migration/002_init_default_permissions.up.json +++ b/manager/migration/002_init_default_permissions.up.json @@ -73,6 +73,18 @@ "resource": "schedule_intent", "action": "read", "description": "Read schedule intents" + }, + { + "key": "schedule_strategy.delete", + "resource": "schedule_strategy", + "action": "delete", + "description": "Delete schedule strategies" + }, + { + "key": "schedule_intent.delete", + "resource": "schedule_intent", + "action": "delete", + "description": "Delete schedule intents" } ] } diff --git a/manager/migration/003_init_default_roles.up.json b/manager/migration/003_init_default_roles.up.json index 3068bd5..2a49c8e 100644 --- a/manager/migration/003_init_default_roles.up.json +++ b/manager/migration/003_init_default_roles.up.json @@ -17,7 +17,9 @@ { "permissionKey": "permission.read", "self": false }, { "permissionKey": "schedule_strategy.create", "self": false }, { "permissionKey": "schedule_strategy.read", "self": false }, - { "permissionKey": "schedule_intent.read", "self": false } + { "permissionKey": "schedule_strategy.delete", "self": false }, + { "permissionKey": "schedule_intent.read", "self": false }, + { "permissionKey": "schedule_intent.delete", "self": false } ], "created_time": { "$numberLong": "0" }, "updated_time": { "$numberLong": "0" } diff --git a/manager/rest/strategy_hdl_test.go b/manager/rest/strategy_hdl_test.go index 2613b9d..adc8861 100644 --- a/manager/rest/strategy_hdl_test.go +++ b/manager/rest/strategy_hdl_test.go @@ -67,7 +67,9 @@ func (suite *HandlerTestSuite) TestIntegrationDeleteStrategyHandler() { strategies := suite.listSelfStrategies(adminToken, http.StatusOK) suite.Require().Len(strategies.Strategies, 1, "Expected one strategy") - // Delete the strategy + // Delete the strategy - need to mock DM notification + suite.MockK8SAdapter.EXPECT().QueryDecisionMakerPods(mock.Anything, mock.Anything).Return([]*domain.DecisionMakerPod{{Host: "dm-host", NodeID: "test", Port: 8080}}, nil).Once() + suite.MockDMAdapter.EXPECT().DeleteSchedulingIntents(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() suite.deleteStrategy(adminToken, strategies.Strategies[0].ID.Hex(), http.StatusOK) // Verify strategy and intents are deleted @@ -101,7 +103,9 @@ func (suite *HandlerTestSuite) TestIntegrationDeleteIntentsHandler() { intents := suite.listSelfIntents(adminToken, http.StatusOK) suite.Require().Len(intents.Intents, 2, "Expected two intents") - // Delete one intent + // Delete one intent - need to mock DM notification + suite.MockK8SAdapter.EXPECT().QueryDecisionMakerPods(mock.Anything, mock.Anything).Return([]*domain.DecisionMakerPod{{Host: "dm-host", NodeID: "test", Port: 8080}}, nil).Once() + suite.MockDMAdapter.EXPECT().DeleteSchedulingIntents(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() suite.deleteIntents(adminToken, []string{intents.Intents[0].ID.Hex()}, http.StatusOK) // Verify only one intent remains @@ -133,7 +137,7 @@ func (suite *HandlerTestSuite) deleteStrategy(token string, strategyID string, e deleteReq := rest.DeleteScheduleStrategyRequest{ StrategyID: strategyID, } - deleteResp := rest.SuccessResponse[string]{} + deleteResp := rest.SuccessResponse[rest.EmptyResponse]{} _, resp := suite.sendV1Request("DELETE", "/strategies", deleteReq, &deleteResp, token) suite.Require().Equal(expectedStatus, resp.Code, "Unexpected status code on delete strategy") } @@ -142,7 +146,7 @@ func (suite *HandlerTestSuite) deleteIntents(token string, intentIDs []string, e deleteReq := rest.DeleteScheduleIntentsRequest{ IntentIDs: intentIDs, } - deleteResp := rest.SuccessResponse[string]{} + deleteResp := rest.SuccessResponse[rest.EmptyResponse]{} _, resp := suite.sendV1Request("DELETE", "/intents", deleteReq, &deleteResp, token) suite.Require().Equal(expectedStatus, resp.Code, "Unexpected status code on delete intents") } From 4838ae90e2b1329de7448127e8cd9693d723e34f Mon Sep 17 00:00:00 2001 From: ianchen0119 Date: Tue, 20 Jan 2026 07:44:59 +0000 Subject: [PATCH 5/5] chore: update swagger.yaml --- docs/manager/docs.go | 191 +++++++++++++++++++++++++++++++++++--- docs/manager/swagger.json | 191 +++++++++++++++++++++++++++++++++++--- docs/manager/swagger.yaml | 123 ++++++++++++++++++++++-- 3 files changed, 466 insertions(+), 39 deletions(-) diff --git a/docs/manager/docs.go b/docs/manager/docs.go index c780225..399e23c 100644 --- a/docs/manager/docs.go +++ b/docs/manager/docs.go @@ -76,6 +76,75 @@ const docTemplate = `{ } } }, + "/api/v1/intents": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete one or more schedule intents.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Strategies" + ], + "summary": "Delete schedule intents", + "parameters": [ + { + "description": "Intent IDs to delete", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/rest.DeleteScheduleIntentsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.SuccessResponse-github_com_Gthulhu_api_manager_rest_EmptyResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + } + } + } + }, "/api/v1/intents/self": { "get": { "security": [ @@ -446,6 +515,73 @@ const docTemplate = `{ } } } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a schedule strategy and its associated intents.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Strategies" + ], + "summary": "Delete schedule strategy", + "parameters": [ + { + "description": "Strategy ID to delete", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/rest.DeleteScheduleStrategyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.SuccessResponse-github_com_Gthulhu_api_manager_rest_EmptyResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + } + } } }, "/api/v1/strategies/self": { @@ -862,6 +998,7 @@ const docTemplate = `{ "definitions": { "domain.IntentState": { "type": "integer", + "format": "int32", "enum": [ 0, 1, @@ -887,7 +1024,9 @@ const docTemplate = `{ "permission.read", "schedule_strategy.create", "schedule_strategy.read", - "schedule_intent.read" + "schedule_strategy.delete", + "schedule_intent.read", + "schedule_intent.delete" ], "x-enum-varnames": [ "CreateUser", @@ -901,11 +1040,14 @@ const docTemplate = `{ "PermissionRead", "ScheduleStrategyCreate", "ScheduleStrategyRead", - "ScheduleIntentRead" + "ScheduleStrategyDelete", + "ScheduleIntentRead", + "ScheduleIntentDelete" ] }, "domain.UserStatus": { "type": "integer", + "format": "int32", "enum": [ 1, 2, @@ -973,6 +1115,17 @@ const docTemplate = `{ } } }, + "github_com_Gthulhu_api_manager_rest.LabelSelector": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "github_com_Gthulhu_api_manager_rest.SuccessResponse-github_com_Gthulhu_api_manager_rest_EmptyResponse": { "type": "object", "properties": { @@ -1145,7 +1298,7 @@ const docTemplate = `{ "labelSelectors": { "type": "array", "items": { - "$ref": "#/definitions/rest.LabelSelector" + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.LabelSelector" } }, "priority": { @@ -1175,6 +1328,25 @@ const docTemplate = `{ } } }, + "rest.DeleteScheduleIntentsRequest": { + "type": "object", + "properties": { + "intentIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "rest.DeleteScheduleStrategyRequest": { + "type": "object", + "properties": { + "strategyId": { + "type": "string" + } + } + }, "rest.GetSelfUserResponse": { "type": "object", "properties": { @@ -1195,17 +1367,6 @@ const docTemplate = `{ } } }, - "rest.LabelSelector": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, "rest.ListPermissionsResponse": { "type": "object", "properties": { @@ -1409,7 +1570,7 @@ const docTemplate = `{ "labelSelectors": { "type": "array", "items": { - "$ref": "#/definitions/rest.LabelSelector" + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.LabelSelector" } }, "priority": { diff --git a/docs/manager/swagger.json b/docs/manager/swagger.json index 04f8db6..8c5a841 100644 --- a/docs/manager/swagger.json +++ b/docs/manager/swagger.json @@ -73,6 +73,75 @@ } } }, + "/api/v1/intents": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete one or more schedule intents.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Strategies" + ], + "summary": "Delete schedule intents", + "parameters": [ + { + "description": "Intent IDs to delete", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/rest.DeleteScheduleIntentsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.SuccessResponse-github_com_Gthulhu_api_manager_rest_EmptyResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + } + } + } + }, "/api/v1/intents/self": { "get": { "security": [ @@ -443,6 +512,73 @@ } } } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a schedule strategy and its associated intents.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Strategies" + ], + "summary": "Delete schedule strategy", + "parameters": [ + { + "description": "Strategy ID to delete", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/rest.DeleteScheduleStrategyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.SuccessResponse-github_com_Gthulhu_api_manager_rest_EmptyResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse" + } + } + } } }, "/api/v1/strategies/self": { @@ -859,6 +995,7 @@ "definitions": { "domain.IntentState": { "type": "integer", + "format": "int32", "enum": [ 0, 1, @@ -884,7 +1021,9 @@ "permission.read", "schedule_strategy.create", "schedule_strategy.read", - "schedule_intent.read" + "schedule_strategy.delete", + "schedule_intent.read", + "schedule_intent.delete" ], "x-enum-varnames": [ "CreateUser", @@ -898,11 +1037,14 @@ "PermissionRead", "ScheduleStrategyCreate", "ScheduleStrategyRead", - "ScheduleIntentRead" + "ScheduleStrategyDelete", + "ScheduleIntentRead", + "ScheduleIntentDelete" ] }, "domain.UserStatus": { "type": "integer", + "format": "int32", "enum": [ 1, 2, @@ -970,6 +1112,17 @@ } } }, + "github_com_Gthulhu_api_manager_rest.LabelSelector": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "github_com_Gthulhu_api_manager_rest.SuccessResponse-github_com_Gthulhu_api_manager_rest_EmptyResponse": { "type": "object", "properties": { @@ -1142,7 +1295,7 @@ "labelSelectors": { "type": "array", "items": { - "$ref": "#/definitions/rest.LabelSelector" + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.LabelSelector" } }, "priority": { @@ -1172,6 +1325,25 @@ } } }, + "rest.DeleteScheduleIntentsRequest": { + "type": "object", + "properties": { + "intentIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "rest.DeleteScheduleStrategyRequest": { + "type": "object", + "properties": { + "strategyId": { + "type": "string" + } + } + }, "rest.GetSelfUserResponse": { "type": "object", "properties": { @@ -1192,17 +1364,6 @@ } } }, - "rest.LabelSelector": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, "rest.ListPermissionsResponse": { "type": "object", "properties": { @@ -1406,7 +1567,7 @@ "labelSelectors": { "type": "array", "items": { - "$ref": "#/definitions/rest.LabelSelector" + "$ref": "#/definitions/github_com_Gthulhu_api_manager_rest.LabelSelector" } }, "priority": { diff --git a/docs/manager/swagger.yaml b/docs/manager/swagger.yaml index 9e0179a..05da2da 100644 --- a/docs/manager/swagger.yaml +++ b/docs/manager/swagger.yaml @@ -7,6 +7,7 @@ definitions: - 0 - 1 - 2 + format: int32 type: integer x-enum-varnames: - IntentStateUnknown @@ -25,7 +26,9 @@ definitions: - permission.read - schedule_strategy.create - schedule_strategy.read + - schedule_strategy.delete - schedule_intent.read + - schedule_intent.delete type: string x-enum-varnames: - CreateUser @@ -39,12 +42,15 @@ definitions: - PermissionRead - ScheduleStrategyCreate - ScheduleStrategyRead + - ScheduleStrategyDelete - ScheduleIntentRead + - ScheduleIntentDelete domain.UserStatus: enum: - 1 - 2 - 3 + format: int32 type: integer x-enum-varnames: - UserStatusActive @@ -86,6 +92,13 @@ definitions: timestamp: type: string type: object + github_com_Gthulhu_api_manager_rest.LabelSelector: + properties: + key: + type: string + value: + type: string + type: object github_com_Gthulhu_api_manager_rest.SuccessResponse-github_com_Gthulhu_api_manager_rest_EmptyResponse: properties: data: @@ -197,7 +210,7 @@ definitions: type: array labelSelectors: items: - $ref: '#/definitions/rest.LabelSelector' + $ref: '#/definitions/github_com_Gthulhu_api_manager_rest.LabelSelector' type: array priority: type: integer @@ -216,6 +229,18 @@ definitions: id: type: string type: object + rest.DeleteScheduleIntentsRequest: + properties: + intentIds: + items: + type: string + type: array + type: object + rest.DeleteScheduleStrategyRequest: + properties: + strategyId: + type: string + type: object rest.GetSelfUserResponse: properties: id: @@ -229,13 +254,6 @@ definitions: username: type: string type: object - rest.LabelSelector: - properties: - key: - type: string - value: - type: string - type: object rest.ListPermissionsResponse: properties: permissions: @@ -367,7 +385,7 @@ definitions: type: array labelSelectors: items: - $ref: '#/definitions/rest.LabelSelector' + $ref: '#/definitions/github_com_Gthulhu_api_manager_rest.LabelSelector' type: array priority: type: integer @@ -444,6 +462,50 @@ paths: summary: User login tags: - Auth + /api/v1/intents: + delete: + consumes: + - application/json + description: Delete one or more schedule intents. + parameters: + - description: Intent IDs to delete + in: body + name: request + required: true + schema: + $ref: '#/definitions/rest.DeleteScheduleIntentsRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_Gthulhu_api_manager_rest.SuccessResponse-github_com_Gthulhu_api_manager_rest_EmptyResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete schedule intents + tags: + - Strategies /api/v1/intents/self: get: consumes: @@ -641,6 +703,49 @@ paths: tags: - Roles /api/v1/strategies: + delete: + consumes: + - application/json + description: Delete a schedule strategy and its associated intents. + parameters: + - description: Strategy ID to delete + in: body + name: request + required: true + schema: + $ref: '#/definitions/rest.DeleteScheduleStrategyRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_Gthulhu_api_manager_rest.SuccessResponse-github_com_Gthulhu_api_manager_rest_EmptyResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_Gthulhu_api_manager_rest.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete schedule strategy + tags: + - Strategies post: consumes: - application/json