diff --git a/Makefile b/Makefile index 59dde545..fb95b70d 100644 --- a/Makefile +++ b/Makefile @@ -45,6 +45,7 @@ AGENT_IMG_NAME ?= $(PROJECT_NAME)-agent AGENT_IMAGE ?= $(REGISTRY)/$(REGISTRY_REPO)/$(AGENT_IMG_NAME) AGENT_CONTAINERFILE ?= ./$(DEPLOYMENTS_DIR)/containerfiles/Containerfile-agent AGENT_PROTO_PATH ?= ./cmd/agent/proto/agent.proto +SCANNER_PROTO_PATH ?= ./cmd/scanner/proto/scanner.proto PGSQL_IMG_NAME ?= $(PROJECT_NAME)-pgsql PGSQL_IMAGE ?= $(REGISTRY)/$(REGISTRY_REPO)/$(PGSQL_IMG_NAME) PGSQL_CONTAINERFILE ?= ./$(DEPLOYMENTS_DIR)/containerfiles/Containerfile-pgsql @@ -81,6 +82,8 @@ local-build-api: generate-converters swagger-doc ## Build the API binary local-build-scanner: ## Build the scanner binary @echo "### [Building Scanner] ###" + @[ ! -d $(GENERATED_DIR) ] && { mkdir $(GENERATED_DIR); } || { exit 0; } + @$(PROTOC) --go_out=$(GENERATED_DIR) --go-grpc_out=$(GENERATED_DIR) $(SCANNER_PROTO_PATH) @$(GO) build -o $(BIN_DIR)/scanners/scanner $(LDFLAGS) ./cmd/scanner local-build-agent: ## Build the agent binary diff --git a/cmd/agent/executor_agent_service.go b/cmd/agent/executor_agent_service.go index 5ba8b830..ede9681b 100644 --- a/cmd/agent/executor_agent_service.go +++ b/cmd/agent/executor_agent_service.go @@ -6,9 +6,11 @@ import ( "crypto/tls" "fmt" "net/http" + "strconv" "sync" "github.com/RHEcosystemAppEng/cluster-iq/internal/actions" + "github.com/RHEcosystemAppEng/cluster-iq/internal/clients" cexec "github.com/RHEcosystemAppEng/cluster-iq/internal/cloud_executors" "github.com/RHEcosystemAppEng/cluster-iq/internal/config" "github.com/RHEcosystemAppEng/cluster-iq/internal/credentials" @@ -28,6 +30,8 @@ type ExecutorAgentService struct { client http.Client // HTTP Client for retrieving the schedule from API eventService *eventservice.EventService // Service for handling audit logs actionRepo repositories.ActionRepository + scannerClient *clients.ScannerGRPCClient // gRPC client for the Scanner service + actionRunRepo repositories.ActionRunRepository } // NewExecutorAgentService creates and initializes a new AgentCron instance for managing the scheduled actions @@ -57,6 +61,13 @@ func NewExecutorAgentService(cfg *config.ExecutorAgentServiceConfig, actionsChan eventService := eventservice.NewEventService(db, logger) actionRepo := repositories.NewActionRepository(db) + actionRunRepo := repositories.NewActionRunRepository(db) + + scannerClient, err := clients.NewScannerGRPCClient(cfg.ScannerURL, logger) + if err != nil { + logger.Error("Failed to create Scanner gRPC client", zap.Error(err)) + return nil + } eas := ExecutorAgentService{ cfg: cfg, @@ -66,9 +77,11 @@ func NewExecutorAgentService(cfg *config.ExecutorAgentServiceConfig, actionsChan logger: logger, wg: wg, }, - client: client, - eventService: eventService, - actionRepo: actionRepo, + client: client, + eventService: eventService, + actionRepo: actionRepo, + scannerClient: scannerClient, + actionRunRepo: actionRunRepo, } // Reading credentials file and creating executors per account @@ -198,17 +211,38 @@ func (e *ExecutorAgentService) processAction(action actions.Action) { zap.Any("requester", action.GetRequester()), ) - // Initialize event tracker + target := action.GetTarget() + + resourceType := inventory.ClusterResourceType + resourceID := target.ClusterID + if action.GetActionOperation() == actions.Scan { + resourceType = inventory.AccountResourceType + if len(target.TargetAccountIDs) > 0 { + resourceID = target.TargetAccountIDs[0] + } + } + + var scheduleID *int64 + if sid, err := strconv.ParseInt(action.GetID(), 10, 64); err == nil { + scheduleID = &sid + } + tracker := e.eventService.StartTracking(&eventservice.EventOptions{ Action: action.GetActionOperation(), Description: action.GetDescription(), - ResourceID: action.GetTarget().ClusterID, - ResourceType: inventory.ClusterResourceType, + ResourceID: resourceID, + ResourceType: resourceType, Result: eventservice.ResultPending, Severity: eventservice.SeverityInfo, - TriggeredBy: action.GetRequester(), + Requester: action.GetRequester(), + ScheduleID: scheduleID, }) + if action.GetActionOperation() == actions.Scan { + e.processScanAction(action, tracker) + return + } + // Mark as running if !e.setActionStatus(action, actions.StatusRunning) { tracker.Failed() @@ -216,7 +250,7 @@ func (e *ExecutorAgentService) processAction(action actions.Action) { } // Get executor - executor := e.GetExecutor(action.GetTarget().AccountID) + executor := e.GetExecutor(target.AccountID) if executor == nil { e.handleMissingExecutor(action, tracker) return @@ -235,6 +269,59 @@ func (e *ExecutorAgentService) processAction(action actions.Action) { e.resetCronActionStatus(action) } +// processScanAction dispatches a Scan action to the Scanner gRPC service. +func (e *ExecutorAgentService) processScanAction(action actions.Action, tracker *eventservice.EventTracker) { + if !e.setActionStatus(action, actions.StatusRunning) { + tracker.Failed() + return + } + + target := action.GetTarget() + + runID, err := e.actionRunRepo.Create(context.Background(), action.GetID()) + if err != nil { + e.logger.Error("Failed to create action run for scan", + zap.String("action_id", action.GetID()), zap.Error(err)) + e.setActionStatus(action, actions.StatusFailed) + tracker.Failed() + return + } + runIDStr := strconv.FormatInt(runID, 10) + + resp, err := e.scannerClient.Scan( + context.Background(), + runID, + target.TargetAccountIDs, + target.SelectAll, + ) + if err != nil { + e.logger.Error("Scanner gRPC call failed", + zap.String("action_id", action.GetID()), zap.Error(err)) + e.setActionStatus(action, actions.StatusFailed) + _ = e.actionRunRepo.Update(context.Background(), runIDStr, "Failed", err.Error()) + tracker.Failed() + return + } + + if resp.Error != 0 { + e.logger.Error("Scanner returned error", + zap.String("action_id", action.GetID()), + zap.String("message", resp.Message)) + e.setActionStatus(action, actions.StatusFailed) + _ = e.actionRunRepo.Update(context.Background(), runIDStr, "Failed", resp.Message) + tracker.Failed() + return + } + + e.logger.Info("Scan completed successfully", + zap.String("action_id", action.GetID()), + zap.Int32("accounts_scanned", resp.AccountsScanned)) + e.setActionStatus(action, actions.StatusSuccess) + _ = e.actionRunRepo.Update(context.Background(), runIDStr, "Success", "") + tracker.Success() + e.resetCronActionStatus(action) +} + // setActionStatus safely updates action status with type assertion. // Returns false if update failed (caller should abort). func (e *ExecutorAgentService) setActionStatus(action actions.Action, status actions.ActionStatus) bool { diff --git a/cmd/agent/schedule_agent_service.go b/cmd/agent/schedule_agent_service.go index f2b23382..15eb6db9 100644 --- a/cmd/agent/schedule_agent_service.go +++ b/cmd/agent/schedule_agent_service.go @@ -271,26 +271,45 @@ func (a *ScheduleAgentService) ScheduleNewActions(newSchedule []actions.Action) // Checking the entire new schedule to schedule or reschedule actions for _, action := range newSchedule { - var scheduledFunc func(*actions.ScheduledAction) - var cronFunc func(*actions.CronAction) - - if _, exists := a.schedule[action.GetID()]; !exists { // Schedule new actions - scheduledFunc = a.scheduleNewScheduledAction - cronFunc = a.scheduleNewCronAction - } else { // Reschedule actions - scheduledFunc = a.rescheduleScheduledAction - cronFunc = a.rescheduleCronAction - } + a.dispatchActionLocked(action) + } +} - // managing actions based on type - switch t := action.(type) { - case *actions.ScheduledAction: - scheduledFunc(t) - case *actions.CronAction: - cronFunc(t) - default: - a.logger.Error("Unknown action type", zap.String("action_id", action.GetID())) +// dispatchActionLocked schedules, reschedules, or dispatches a single action. +// Must be called with a.mutex held. +func (a *ScheduleAgentService) dispatchActionLocked(action actions.Action) { + _, exists := a.schedule[action.GetID()] + + switch t := action.(type) { + case *actions.InstantAction: + if exists { + return + } + a.logger.Info("Dispatching InstantAction for immediate execution", zap.String("action_id", t.GetID())) + a.schedule[t.GetID()] = scheduleItem{ + cancel: func() {}, + action: t, + } + go func() { + a.actionsChannel <- t + a.mutex.Lock() + delete(a.schedule, t.GetID()) + a.mutex.Unlock() + }() + case *actions.ScheduledAction: + if !exists { + a.scheduleNewScheduledAction(t) + } else { + a.rescheduleScheduledAction(t) + } + case *actions.CronAction: + if !exists { + a.scheduleNewCronAction(t) + } else { + a.rescheduleCronAction(t) } + default: + a.logger.Error("Unknown action type", zap.String("action_id", action.GetID())) } } diff --git a/cmd/api/router.go b/cmd/api/router.go index 7900e2c7..daa810ba 100644 --- a/cmd/api/router.go +++ b/cmd/api/router.go @@ -14,6 +14,7 @@ type APIHandlers struct { ExpenseHandler *handlers.ExpenseHandler EventHandler *handlers.EventHandler ActionHandler *handlers.ActionHandler + ActionRunHandler *handlers.ActionRunHandler OverviewHandler *handlers.OverviewHandler HealthCheckHandler *handlers.HealthCheckHandler } @@ -30,6 +31,7 @@ func Setup(engine *gin.Engine, handlers APIHandlers) { setupExpenseRoutes(baseGroup, handlers.ExpenseHandler) setupEventRoutes(baseGroup, handlers.EventHandler) setupActionRoutes(baseGroup, handlers.ActionHandler) + setupActionRunRoutes(baseGroup, handlers.ActionRunHandler) setupOverviewRoutes(baseGroup, handlers.OverviewHandler) } } @@ -110,6 +112,16 @@ func setupActionRoutes(group *gin.RouterGroup, handler *handlers.ActionHandler) } } +func setupActionRunRoutes(group *gin.RouterGroup, handler *handlers.ActionRunHandler) { + actionRuns := group.Group("/action-runs") + { + actionRuns.GET("", handler.List) + actionRuns.GET("/:id", handler.Get) + actionRuns.POST("", handler.Create) + actionRuns.PATCH("/:id", handler.Update) + } +} + func setupOverviewRoutes(group *gin.RouterGroup, handler *handlers.OverviewHandler) { overview := group.Group("/overview") { diff --git a/cmd/api/server.go b/cmd/api/server.go index e5bd8b3d..bb33aec7 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -200,6 +200,7 @@ func main() { expenseRepo := repositories.NewExpenseRepository(dbClient) eventRepo := repositories.NewEventRepository(dbClient) actionRepo := repositories.NewActionRepository(dbClient) + actionRunRepo := repositories.NewActionRunRepository(dbClient) // Initializing services inventoryService := services.NewInventoryService(inventoryRepo) @@ -212,6 +213,7 @@ func main() { expenseService := services.NewExpenseService(expenseRepo) eventService := services.NewEventService(eventRepo) actionService := services.NewActionService(actionRepo) + actionRunService := services.NewActionRunService(actionRunRepo) overviewService := services.NewOverviewService(clusterRepo, instanceRepo, accountRepo) // Initializing handlers @@ -223,6 +225,7 @@ func main() { ExpenseHandler: handlers.NewExpenseHandler(expenseService, logger), EventHandler: handlers.NewEventHandler(eventService, logger), ActionHandler: handlers.NewActionHandler(actionService, logger), + ActionRunHandler: handlers.NewActionRunHandler(actionRunService, logger), OverviewHandler: handlers.NewOverviewHandler(overviewService, logger), HealthCheckHandler: handlers.NewHealthCheckHandler(dbClient, logger), } diff --git a/cmd/scanner/proto/scanner.proto b/cmd/scanner/proto/scanner.proto new file mode 100644 index 00000000..6940f2a7 --- /dev/null +++ b/cmd/scanner/proto/scanner.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +package scanner; + +option go_package = "./scanner"; + +// gRPC service for the Scanner +service ScannerService { + rpc Scan (ScanRequest) returns (ScanResponse); + rpc Health (HealthRequest) returns (HealthResponse); +} + +// Message for requesting a scan +message ScanRequest { + int64 run_id = 1; + repeated string account_ids = 2; + bool select_all = 3; +} + +// Message for answering to ScanRequests +message ScanResponse { + int32 error = 1; + string message = 2; + int32 accounts_scanned = 3; +} + +// Message for health check requests +message HealthRequest {} + +// Message for health check responses +message HealthResponse { + bool ready = 1; +} diff --git a/cmd/scanner/scanner.go b/cmd/scanner/scanner.go index e7c5ba16..2490b580 100644 --- a/cmd/scanner/scanner.go +++ b/cmd/scanner/scanner.go @@ -6,8 +6,10 @@ import ( "crypto/md5" "crypto/tls" "encoding/json" + "errors" "fmt" "io" + "net" "net/http" "os" "os/signal" @@ -15,6 +17,7 @@ import ( "syscall" "time" + pb "github.com/RHEcosystemAppEng/cluster-iq/generated/scanner" responsetypes "github.com/RHEcosystemAppEng/cluster-iq/internal/api/response_types" "github.com/RHEcosystemAppEng/cluster-iq/internal/config" "github.com/RHEcosystemAppEng/cluster-iq/internal/credentials" @@ -23,6 +26,8 @@ import ( "github.com/RHEcosystemAppEng/cluster-iq/internal/models/dto" "github.com/RHEcosystemAppEng/cluster-iq/internal/stocker" "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" ) const ( @@ -31,9 +36,12 @@ const ( apiClusterEndpoint = "/clusters" apiInstanceEndpoint = "/instances" apiExpenseEndpoint = "/expenses" - // apiRequestTimeout defines the timeout for HTTP POST requests to the API apiRequestTimeout = 60 * time.Second + // apiHealthcheckTimeout defines the timeout for each healthcheck attempt + apiHealthcheckTimeout = 5 * time.Second + // apiHealthcheckRetryInterval defines how long to wait between healthcheck retries + apiHealthcheckRetryInterval = 3 * time.Second ) var ( @@ -57,16 +65,17 @@ var ( // Scanner models the cloud agnostic Scanner for looking up OCP deployments type Scanner struct { - inventory inventory.Inventory - stockers []stocker.Stocker - billingStockers []stocker.Stocker - cfg *config.ScannerConfig - logger *zap.Logger + pb.UnimplementedScannerServiceServer + allAccounts map[string]credentials.AccountConfig + cfg *config.ScannerConfig + logger *zap.Logger + grpcServer *grpc.Server + mu sync.Mutex + scanning bool } // NewScanner creates and returns a new Scanner instance func NewScanner(cfg *config.ScannerConfig, logger *zap.Logger) *Scanner { - // Calculate Credentials file MD5 checksum for checking on runtime hash := md5.Sum([]byte(cfg.CredentialsFile)) credsFileHash = hash[:] @@ -77,81 +86,131 @@ func NewScanner(cfg *config.ScannerConfig, logger *zap.Logger) *Scanner { APIURL = cfg.APIURL return &Scanner{ - inventory: *inventory.NewInventory(), - stockers: make([]stocker.Stocker, 0), - cfg: cfg, - logger: logger, + allAccounts: make(map[string]credentials.AccountConfig), + cfg: cfg, + logger: logger, } } func init() { - // Initialize logging configuration. logger = ciqLogger.NewLogger() } -// readCloudProviderAccounts reads and loads cloud provider accounts from a credentials file. -func (s *Scanner) readCloudProviderAccounts() error { - // Load cloud accounts credentials file. +// loadAccounts reads the credentials file and caches all account configs. +func (s *Scanner) loadAccounts() error { accountConfigs, err := credentials.ReadCloudAccounts(s.cfg.CredentialsFile) if err != nil { - return err + return fmt.Errorf("failed to read cloud accounts: %w", err) } - // Read INI file content. - for _, accountConfig := range accountConfigs { - newAccount, err := inventory.NewAccount( - accountConfig.ID, - accountConfig.Name, - accountConfig.Provider, - accountConfig.User, - accountConfig.Key, - ) - if err != nil { - return err - } + for _, ac := range accountConfigs { + s.allAccounts[ac.ID] = ac + } - // Getting billing enabled flag from config - if accountConfig.BillingEnabled { - newAccount.EnableBilling() - } + s.logger.Info("Loaded cloud accounts from credentials file", + zap.Int("count", len(s.allAccounts))) - // Adding account to Inventory for scanning - if err := s.inventory.AddAccount(newAccount); err != nil { - return err + return nil +} + +// waitForAPI blocks until the API server responds to /healthcheck. +func (s *Scanner) waitForAPI() { + url := fmt.Sprintf("%s/healthcheck", APIURL) + for { + ctx, cancel := context.WithTimeout(context.Background(), apiHealthcheckTimeout) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err == nil { + resp, err := client.Do(req) + if resp != nil { + resp.Body.Close() + } + if err == nil && resp.StatusCode == http.StatusOK { + cancel() + s.logger.Info("API server is ready") + return + } } + cancel() + s.logger.Info("Waiting for API server...", zap.String("url", url)) + time.Sleep(apiHealthcheckRetryInterval) + } +} + +// seedAccounts posts account records from the credentials file to the API +// so they are available in the console before the first scan runs. +func (s *Scanner) seedAccounts() error { + var accounts []dto.AccountDTORequest + for _, ac := range s.allAccounts { + accounts = append(accounts, dto.AccountDTORequest{ + AccountID: ac.ID, + AccountName: ac.Name, + Provider: ac.Provider, + }) + } + + b, err := json.Marshal(accounts) + if err != nil { + return fmt.Errorf("failed to marshal seed accounts: %w", err) } + if err := postData(apiAccountEndpoint, b); err != nil { + return fmt.Errorf("failed to seed accounts: %w", err) + } + + if err := refreshInventory(s.logger); err != nil { + return fmt.Errorf("failed to refresh materialized views after seed: %w", err) + } + + s.logger.Info("Seeded accounts into database", zap.Int("count", len(accounts))) return nil } -// nolint:cyclop // createStockers creates and configures stocker instances for each provided account to be inventoried. -func (s *Scanner) createStockers() error { - for _, account := range s.inventory.Accounts { +// buildInventory creates an Inventory from the given account configs. +func (s *Scanner) buildInventory(configs []credentials.AccountConfig) (*inventory.Inventory, error) { + inv := inventory.NewInventory() + for _, ac := range configs { + newAccount, err := inventory.NewAccount(ac.ID, ac.Name, ac.Provider, ac.User, ac.Key) + if err != nil { + return nil, fmt.Errorf("failed to create account %s: %w", ac.ID, err) + } + if ac.BillingEnabled { + newAccount.EnableBilling() + } + if err := inv.AddAccount(newAccount); err != nil { + return nil, fmt.Errorf("failed to add account %s: %w", ac.ID, err) + } + } + return inv, nil +} + +// nolint:cyclop +func (s *Scanner) createStockers(inv *inventory.Inventory) ([]stocker.Stocker, []stocker.Stocker, []error) { + var stockers []stocker.Stocker + var billingStockers []stocker.Stocker + var errs []error + + for _, account := range inv.Accounts { switch account.Provider { case inventory.AWSProvider: s.logger.Info("Processing AWS account", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName)) - - // AWS API Stoker awsStocker, err := stocker.NewAWSStocker(account, s.cfg.SkipNoOpenShiftInstances, s.logger) if err != nil { s.logger.Error("Failed to create AWS stocker; skipping this account", - zap.String("account", account.AccountName), - zap.Error(err)) + zap.String("account", account.AccountName), zap.Error(err)) + errs = append(errs, fmt.Errorf("account %s: %w", account.AccountName, err)) continue } - s.stockers = append(s.stockers, awsStocker) + stockers = append(stockers, awsStocker) - // AWS Billing API Stoker if account.IsBillingEnabled() { - s.logger.Warn("Enabled AWS Billing Stocker", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName)) - instancesToScan, err := s.getInstancesForBillingUpdate(account.AccountID) + s.logger.Info("Enabled AWS Billing Stocker", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName)) + instancesToScan, err := getInstancesForBillingUpdate(s.cfg.APIURL, account.AccountID, s.logger) if err != nil { - s.logger.Error("Failed to retrieve the list of instances required for billing information from AWS Cost Explorer.", - zap.String("account_name", account.AccountName), - zap.Error(err)) + s.logger.Error("Failed to retrieve instances for billing", + zap.String("account_name", account.AccountName), zap.Error(err)) } else { if bs := stocker.NewAWSBillingStocker(account, s.logger, instancesToScan); bs != nil { - s.billingStockers = append(s.billingStockers, bs) + billingStockers = append(billingStockers, bs) } } } @@ -159,18 +218,12 @@ func (s *Scanner) createStockers() error { s.logger.Warn("Failed to scan GCP account", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName), - zap.String("reason", "not implemented"), - ) - // TODO: Uncomment line below when GCP Stocker is implemented - // gcpStocker = stocker.NewGCPStocker(account, s.cfg.SkipNoOpenShiftInstances, s.logger)) + zap.String("reason", "not implemented")) case inventory.AzureProvider: s.logger.Warn("Failed to scan Azure account", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName), - zap.String("reason", "not implemented"), - ) - // TODO: Uncomment line below when Azure Stocker is implemented - // azureStocker = stocker.NewAzureStocker(account, s.cfg.SkipNoOpenShiftInstances, s.logger)) + zap.String("reason", "not implemented")) case inventory.UnknownProvider: s.logger.Warn("Unknown cloud provider, skipping account", zap.String("account_id", account.AccountID), @@ -185,120 +238,203 @@ func (s *Scanner) createStockers() error { } s.logger.Info("Account registration complete", - zap.Int("registeredAccounts", len(s.inventory.Accounts)), - zap.Int("registeredStockers", len(s.stockers)), - zap.Int("skippedAccounts", len(s.inventory.Accounts)-len(s.stockers))) + zap.Int("registeredAccounts", len(inv.Accounts)), + zap.Int("registeredStockers", len(stockers)), + zap.Int("skippedAccounts", len(inv.Accounts)-len(stockers))) - // If there are no stockers, nothing to do - if len(s.stockers) == 0 { - return fmt.Errorf("no valid accounts found for scanning") - } - - // Checking the logLevel before entering on the For loop for optimization - if s.logger.Core().Enabled(zap.DebugLevel) { - s.logger.Debug("Total Stockers created", zap.Int("count", len(s.stockers))) - for i, stocker := range s.stockers { - s.logger.Debug("Stocker", zap.Int("id", i), zap.String("account_id", stocker.GetAccount().AccountID), zap.String("account_name", stocker.GetAccount().AccountName)) - } - } - - return nil + return stockers, billingStockers, errs } -// startStockers runs every stocker instance -func (s *Scanner) startStockers() error { +func runStockers(stockers []stocker.Stocker, billingStockers []stocker.Stocker, l *zap.Logger) error { var wg sync.WaitGroup - errChan := make(chan error, len(s.stockers)+len(s.billingStockers)) + errChan := make(chan error, len(stockers)+len(billingStockers)) - // First iteration for infrastructure stockers - s.logger.Warn("Running Infrastructure Stockers!", zap.Int("stockers_count", len(s.stockers))) - for _, stockerInstance := range s.stockers { + l.Warn("Running Infrastructure Stockers!", zap.Int("stockers_count", len(stockers))) + for _, st := range stockers { wg.Add(1) go func() { defer wg.Done() - if err := stockerInstance.MakeStock(); err != nil { + if err := st.MakeStock(); err != nil { errChan <- err } }() } - - // Waiting for every Stock wg.Wait() - // Second iteration for billing stockers - s.logger.Warn("Running Billing Stockers!", zap.Int("stockers_count", len(s.billingStockers))) - for _, stockerInstance := range s.billingStockers { + l.Warn("Running Billing Stockers!", zap.Int("stockers_count", len(billingStockers))) + for _, st := range billingStockers { wg.Add(1) go func() { defer wg.Done() - if err := stockerInstance.MakeStock(); err != nil { + if err := st.MakeStock(); err != nil { errChan <- err } }() } - // Waiting for every Stock go func() { wg.Wait() close(errChan) }() - // Collecting stockers errors var errorList []error for err := range errChan { errorList = append(errorList, err) } - // Processing errors when every stocker has finished if len(errorList) > 0 { for _, err := range errorList { - s.logger.Error("Stocker Error", zap.Error(err)) + l.Error("Stocker Error", zap.Error(err)) } return fmt.Errorf("error when running Scanner stockers. Failed Stockers: (%d)", len(errorList)) } - s.logger.Info("Stockers executed correctly") + l.Info("Stockers executed correctly") return nil } -// postNewAccount posts into the API an account, its clusters, instances and expenses -func (s *Scanner) postNewAccount(account inventory.Account) error { - s.logger.Debug("Posting new Account", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName)) +// selectAccounts returns account configs matching the given IDs, or all accounts if selectAll is true or no IDs are provided. +func (s *Scanner) selectAccounts(accountIDs []string, selectAll bool) []credentials.AccountConfig { + var configs []credentials.AccountConfig + if selectAll || len(accountIDs) == 0 { + for _, ac := range s.allAccounts { + configs = append(configs, ac) + } + return configs + } + + for _, id := range accountIDs { + ac, ok := s.allAccounts[id] + if !ok { + s.logger.Warn("Account not found in credentials, skipping", zap.String("account_id", id)) + continue + } + configs = append(configs, ac) + } + return configs +} + +// ExecuteScan runs the full scan pipeline for the given accounts. +func (s *Scanner) ExecuteScan(accountIDs []string, selectAll bool) (int, error) { + s.mu.Lock() + if s.scanning { + s.mu.Unlock() + return 0, fmt.Errorf("a scan is already in progress") + } + s.scanning = true + s.mu.Unlock() + + defer func() { + s.mu.Lock() + s.scanning = false + s.mu.Unlock() + }() + + configs := s.selectAccounts(accountIDs, selectAll) + if len(configs) == 0 { + return 0, fmt.Errorf("no valid accounts found for scanning") + } + + inv, err := s.buildInventory(configs) + if err != nil { + return 0, fmt.Errorf("failed to build inventory: %w", err) + } + + stockers, billingStockers, stockerErrors := s.createStockers(inv) + if len(stockers) == 0 { + return 0, fmt.Errorf("no valid stockers created: %w", errors.Join(stockerErrors...)) + } + + if err := runStockers(stockers, billingStockers, s.logger); err != nil { + return 0, fmt.Errorf("failed to run stockers: %w", err) + } + + inv.PrintInventory() + if err := postScannerInventory(inv, s.logger); err != nil { + return 0, fmt.Errorf("failed to post inventory: %w", err) + } + + return len(configs), nil +} + +// Scan handles a gRPC scan request. +func (s *Scanner) Scan(_ context.Context, req *pb.ScanRequest) (*pb.ScanResponse, error) { + s.logger.Info("Received scan request", + zap.Int64("run_id", req.RunId), + zap.Strings("account_ids", req.AccountIds), + zap.Bool("select_all", req.SelectAll)) + + scanned, err := s.ExecuteScan(req.AccountIds, req.SelectAll) + if err != nil { + s.logger.Error("Scan failed", zap.Error(err)) + return &pb.ScanResponse{ + Error: 1, + Message: err.Error(), + AccountsScanned: 0, + }, nil + } + + s.logger.Info("Scan completed successfully", zap.Int("accounts_scanned", scanned)) + return &pb.ScanResponse{ + Error: 0, + Message: "Scan completed successfully", + AccountsScanned: int32(scanned), + }, nil +} + +// Health reports the scanner's readiness. +func (s *Scanner) Health(_ context.Context, _ *pb.HealthRequest) (*pb.HealthResponse, error) { + return &pb.HealthResponse{Ready: true}, nil +} + +// startGRPCServer initializes and starts the gRPC server. +func (s *Scanner) startGRPCServer() error { + lc := net.ListenConfig{} + lis, err := lc.Listen(context.Background(), "tcp", s.cfg.ListenURL) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", s.cfg.ListenURL, err) + } + + s.grpcServer = grpc.NewServer() + pb.RegisterScannerServiceServer(s.grpcServer, s) + reflection.Register(s.grpcServer) + + s.logger.Info("Scanner gRPC server listening", zap.String("address", s.cfg.ListenURL)) + + return s.grpcServer.Serve(lis) +} + +func postNewAccount(account inventory.Account, l *zap.Logger) error { + l.Debug("Posting new Account", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName)) - // Converting to Array because API handler assumes a list of accounts var accounts []dto.AccountDTORequest accounts = append(accounts, *dto.ToAccountDTORequest(account)) b, err := json.Marshal(accounts) if err != nil { - s.logger.Error("Failed to marshal account", zap.String("account_id", account.AccountID), zap.String("account_name", account.AccountName), zap.Error(err)) + l.Error("Failed to marshal account", zap.String("account_id", account.AccountID), zap.Error(err)) return err } - // Posting Account data if err := postData(apiAccountEndpoint, b); err != nil { return err } - // Flattering account for posting its elements clusters, instances, expenses := flatternAccount(account) - // Posting Clusters if len(clusters) > 0 { if err := postClusters(clusters); err != nil { return err } } - // Posting Instances if len(instances) > 0 { if err := postInstances(instances); err != nil { return err } } - // Posting Expenses if len(expenses) > 0 { - s.logger.Info("Posting expenses", zap.Int("expenses_count", len(expenses))) + l.Info("Posting expenses", zap.Int("expenses_count", len(expenses))) if err := postExpenses(expenses); err != nil { return err } @@ -306,7 +442,6 @@ func (s *Scanner) postNewAccount(account inventory.Account) error { return nil } -// flatternAccount extracts every Cluster, Instance and Expense from an Account for posting func flatternAccount(account inventory.Account) ([]inventory.Cluster, []inventory.Instance, []inventory.Expense) { var clusters []inventory.Cluster var instances []inventory.Instance @@ -315,95 +450,86 @@ func flatternAccount(account inventory.Account) ([]inventory.Cluster, []inventor for _, instance := range cluster.Instances { expenses = append(expenses, instance.Expenses...) instances = append(instances, instance) - } clusters = append(clusters, *cluster) } - return clusters, instances, expenses } -// postClusters posts into the API, the new instances obtained after scanning func postClusters(clusters []inventory.Cluster) error { b, err := json.Marshal(dto.ToClusterDTORequestList(clusters)) if err != nil { return err } - return postData(apiClusterEndpoint, b) } -// postInstances posts into the API, the instances obtained after scanning func postInstances(instances []inventory.Instance) error { b, err := json.Marshal(dto.ToInstanceDTORequestList(instances)) if err != nil { return err } - return postData(apiInstanceEndpoint, b) } -// postExpenses posts into the API, the expenses obtained after scanning func postExpenses(expenses []inventory.Expense) error { b, err := json.Marshal(dto.ToExpenseDTORequestList(expenses)) if err != nil { return err } - return postData(apiExpenseEndpoint, b) } -// postScannerInventory posts to ClusterIQ API the information obtained of the scanning process -// This function parallelizes the post operations creating a thread by account(or stocker) -func (s *Scanner) postScannerInventory() error { +func postScannerInventory(inv *inventory.Inventory, l *zap.Logger) error { var wg sync.WaitGroup - errChan := make(chan error, len(s.inventory.Accounts)) + errChan := make(chan error, len(inv.Accounts)) - for _, account := range s.inventory.Accounts { + for _, account := range inv.Accounts { wg.Add(1) go func() { defer wg.Done() - if err := s.postNewAccount(*account); err != nil { + if err := postNewAccount(*account, l); err != nil { errChan <- err } }() - } - // Waiting for every Stock + go func() { wg.Wait() close(errChan) }() - // Collecting account posting errors var errorList []error for err := range errChan { errorList = append(errorList, err) } - // Processing errors when every post account operation has finished if len(errorList) > 0 { for _, err := range errorList { - s.logger.Error("Post Account Error", zap.Error(err)) + l.Error("Post Account Error", zap.Error(err)) } return fmt.Errorf("error when posting Scanner inventory") } - s.logger.Info("Inventory posted correctly") + l.Info("Inventory posted correctly") - // HTTP post to /inventory to refresh views - if err := postData(apiInventoryEndpoint, []byte{}); err != nil { + if err := refreshInventory(l); err != nil { return err } - s.logger.Info("Inventory refreshed correctly") + return nil +} + +func refreshInventory(l *zap.Logger) error { + if err := postData(apiInventoryEndpoint, []byte{}); err != nil { + return err + } + l.Info("Inventory refreshed") return nil } func postData(path string, b []byte) error { url := fmt.Sprintf("%s%s", APIURL, path) - - // Create context with timeout for API requests ctx, cancel := context.WithTimeout(context.Background(), apiRequestTimeout) defer cancel() @@ -420,77 +546,54 @@ func postData(path string, b []byte) error { return err } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("API returned HTTP %d for %s", response.StatusCode, path) + } return nil } -// getInstances fetches instances from the backend API -func (s *Scanner) getInstancesForBillingUpdate(accountID string) ([]inventory.Instance, error) { - s.logger.Debug("Fetching instances for update billing from backend") - - requestURL := s.cfg.APIURL + apiAccountEndpoint + "/" + accountID + "/expense_update" +func getInstancesForBillingUpdate(apiURL string, accountID string, l *zap.Logger) ([]string, error) { + l.Debug("Fetching instances for update billing from backend") + requestURL := apiURL + apiAccountEndpoint + "/" + accountID + "/expense_update" req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, requestURL, nil) if err != nil { - s.logger.Error("Failed preparing last expenses list request", zap.Error(err)) + l.Error("Failed preparing last expenses list request", zap.Error(err)) return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { - s.logger.Error("Failed to get last expenses from API", zap.Error(err)) + l.Error("Failed to get last expenses from API", zap.Error(err)) return nil, err } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - s.logger.Error("Failed to get last expenses from API", zap.Int("status_code", resp.StatusCode)) + l.Error("Failed to get last expenses from API", zap.Int("status_code", resp.StatusCode)) return nil, fmt.Errorf("failed to get last expenses, status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { - s.logger.Error("Failed to read response body", zap.Error(err)) + l.Error("Failed to read response body", zap.Error(err)) return nil, err } - var response responsetypes.ListResponse[dto.InstanceDTOResponse] + var response responsetypes.ListResponse[string] err = json.Unmarshal(body, &response) if err != nil { - s.logger.Error("Failed to unmarshal instances JSON", zap.Error(err)) + l.Error("Failed to unmarshal instance IDs JSON", zap.Error(err)) return nil, err } - if response.Count == 0 { - return nil, fmt.Errorf("no instances for billing update") - } - - s.logger.Debug("Successfully fetched instances from backend", zap.Int("instances_num", response.Count)) - - var instances []inventory.Instance - for _, instance := range response.Items { - instances = append(instances, *instance.ToInventoryInstance()) - } - return instances, nil -} - -// signalHandler for managing incoming OS signals -func signalHandler(sig os.Signal) { - if sig == syscall.SIGTERM { - logger.Fatal("SIGTERM signal received. Stopping ClusterIQ Scanner") - os.Exit(0) - } - - logger.Warn("Ignoring signal: ", zap.String("signal_id", sig.String())) + l.Debug("Successfully fetched instance IDs from backend", zap.Int("instances_num", response.Count)) + return response.Items, nil } -// Main method func main() { - // Ignore Logger sync error defer func() { _ = logger.Sync() }() - var err error - cfg, err := config.LoadScannerConfig() if err != nil { logger.Fatal("Failed to load config", zap.Error(err)) @@ -503,44 +606,33 @@ func main() { zap.String("commit", commit), zap.String("credentials_file_path", cfg.CredentialsFile), zap.ByteString("credentials_file_hash", credsFileHash), + zap.String("listen_url", cfg.ListenURL), ) - // Listen Signals block for receive OS signals. This is used by K8s/OCP for - // interacting with this software when it's deployed on a Pod - go func() { - quitChan := make(chan os.Signal, 1) - signal.Notify(quitChan, syscall.SIGTERM) - s := <-quitChan - signalHandler(s) - logger.Info("Scanner stopped") - }() - - // Get Cloud Accounts from credentials file - err = scan.readCloudProviderAccounts() - if err != nil { - logger.Error("Failed to get cloud provider accounts", zap.Error(err)) - return + if err := scan.loadAccounts(); err != nil { + logger.Fatal("Failed to load cloud accounts", zap.Error(err)) } - // Run Stockers - err = scan.createStockers() - if err != nil { - logger.Error("Failed to create stockers", zap.Error(err)) - return + scan.waitForAPI() + if err := scan.seedAccounts(); err != nil { + logger.Warn("Failed to seed accounts", zap.Error(err)) } - err = scan.startStockers() - if err != nil { - logger.Error("Failed to start up stocker instances", zap.Error(err)) - return - } + // Signal handling for graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + s := <-quit + logger.Warn("Received signal, shutting down...", zap.String("signal", s.String())) + if scan.grpcServer != nil { + scan.grpcServer.GracefulStop() + } + }() - // Writing into DB - scan.inventory.PrintInventory() - if err := scan.postScannerInventory(); err != nil { - logger.Error("Can't post scanned results", zap.Error(err)) - return + if err := scan.startGRPCServer(); err != nil { + logger.Fatal("gRPC server failed", zap.Error(err)) } - logger.Info("Scanner finished successfully") + logger.Info("Scanner stopped") } diff --git a/console/package-lock.json b/console/package-lock.json index 932d0017..5b9c185c 100644 --- a/console/package-lock.json +++ b/console/package-lock.json @@ -1,12 +1,12 @@ { "name": "cluster-iq-console", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cluster-iq-console", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@patternfly/react-core": "^6.4.0", "@patternfly/react-icons": "^6.4.0", @@ -16,6 +16,7 @@ "@types/react": "^19.0.1", "axios": "^1.8.2", "cron-validate": "^1.5.3", + "cronstrue": "^3.14.0", "date-fns": "^4.1.0", "lodash.debounce": "4.0.8", "luxon": "^3.5.0", @@ -2195,6 +2196,15 @@ "yup": "1.7.1" } }, + "node_modules/cronstrue": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.14.0.tgz", + "integrity": "sha512-XnW4vuK/jPJjmTyDWiej1Zq36Od7ITwxaV2O1pzHZuyMVvdy7NAvyvIBzybt+idqSpfqYuoDG7uf/ocGtJVWxA==", + "license": "MIT", + "bin": { + "cronstrue": "bin/cli.js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/console/package.json b/console/package.json index ae11accf..0bb0a900 100644 --- a/console/package.json +++ b/console/package.json @@ -18,6 +18,7 @@ "@types/react": "^19.0.1", "axios": "^1.8.2", "cron-validate": "^1.5.3", + "cronstrue": "^3.14.0", "date-fns": "^4.1.0", "lodash.debounce": "4.0.8", "luxon": "^3.5.0", diff --git a/console/src/api/Events.ts b/console/src/api/Events.ts index 012258d4..6a53165a 100644 --- a/console/src/api/Events.ts +++ b/console/src/api/Events.ts @@ -53,8 +53,8 @@ export class Events { result?: string; /** Severity */ severity?: string; - /** Triggered by */ - triggered_by?: string; + /** Requester */ + requester?: string; }, params: RequestParams = {} ) => diff --git a/console/src/api/data-contracts.ts b/console/src/api/data-contracts.ts index a1159237..6d8268fd 100644 --- a/console/src/api/data-contracts.ts +++ b/console/src/api/data-contracts.ts @@ -45,11 +45,13 @@ export interface ActionRequestApi { accountId?: string; clusterId?: string; cronExpression?: string; + description?: string; enabled?: boolean; id?: string; instances?: string[]; operation?: string; region?: string; + requester?: string; status?: string; time?: string; type?: string; @@ -58,13 +60,20 @@ export interface ActionRequestApi { export interface ActionResponseApi { accountId?: string; clusterId?: string; + clusterName?: string; cronExpression?: string; + description?: string; enabled?: boolean; id?: string; instances?: string[]; operation?: string; region?: string; + requester?: string; + selectAll?: boolean; status?: string; + targetAccountIds?: string[]; + targetAccountNames?: string[]; + targetType?: string; time?: string; type?: string; } @@ -90,7 +99,7 @@ export interface ClusterEventResponseApi { result?: ResultStatus; severity?: string; timestamp?: string; - triggeredBy?: string; + requester?: string; } export interface ClusterListResponseApi { @@ -149,7 +158,7 @@ export interface EventRequestApi { result?: string; severity?: string; timestamp?: string; - triggeredBy?: string; + requester?: string; } export interface ExpenseListResponseApi { @@ -297,13 +306,16 @@ export interface SystemEventResponseApi { id?: number; action?: string; resourceId?: string; + resourceName?: string; resourceType?: string; timestamp?: string; result?: ResultStatus; severity?: string; - triggeredBy?: string; + requester?: string; description?: string; + scheduleId?: number; accountId?: string; + accountName?: string; provider?: string; } diff --git a/console/src/api/index.ts b/console/src/api/index.ts index fd8a1536..227b183c 100644 --- a/console/src/api/index.ts +++ b/console/src/api/index.ts @@ -48,18 +48,6 @@ export const api = { schedule: new Schedule(http), }; -export const startCluster = (clusterID: string, userEmail?: string, description?: string) => - http.instance.post(`/clusters/${clusterID}/power_on`, { - requester: userEmail || 'unknown', - description: description, - }); - -export const stopCluster = (clusterID: string, userEmail?: string, description?: string) => - http.instance.post(`/clusters/${clusterID}/power_off`, { - requester: userEmail || 'unknown', - description: description, - }); - export type { ClusterResponseApi, InstanceResponseApi, diff --git a/console/src/app/AccountDetails/AccountDetails.tsx b/console/src/app/AccountDetails/AccountDetails.tsx index dc27d834..fc46e6de 100644 --- a/console/src/app/AccountDetails/AccountDetails.tsx +++ b/console/src/app/AccountDetails/AccountDetails.tsx @@ -30,7 +30,7 @@ const AccountDetails: React.FunctionComponent = () => { return ( - + } clustersTabContent={} diff --git a/console/src/app/AccountDetails/components/AccountDescriptionList.tsx b/console/src/app/AccountDetails/components/AccountDescriptionList.tsx index 47c13f26..5faf5486 100644 --- a/console/src/app/AccountDetails/components/AccountDescriptionList.tsx +++ b/console/src/app/AccountDetails/components/AccountDescriptionList.tsx @@ -36,7 +36,7 @@ export const AccountDescriptionList: React.FunctionComponent - Last scanned at + Last scan {parseScanTimestamp(account.lastScanTimestamp)} Created at (in ClusterIQ) {parseScanTimestamp(account.createdAt)} diff --git a/console/src/app/AccountDetails/components/AccountDetailsDropdown.tsx b/console/src/app/AccountDetails/components/AccountDetailsDropdown.tsx new file mode 100644 index 00000000..7a5333f5 --- /dev/null +++ b/console/src/app/AccountDetails/components/AccountDetailsDropdown.tsx @@ -0,0 +1,75 @@ +import { api, ActionRequestApi } from '@api'; +import { Dropdown, DropdownItem, DropdownList, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; +import React from 'react'; +import { ActionOperations, ActionTypes, ActionStatus } from '@app/types/types'; +import { AccountScanConfirm } from './AccountScanConfirm'; +import { useUser } from '@app/Contexts/UserContext'; + +interface AccountDetailsDropdownProps { + accountId: string; + accountName: string; +} + +export const AccountDetailsDropdown: React.FunctionComponent = ({ + accountId, + accountName, +}) => { + const { userEmail } = useUser(); + const [isOpen, setIsOpen] = React.useState(false); + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const onSelect = () => { + setIsModalOpen(true); + setIsOpen(false); + }; + + const actionCreate = async () => { + const actionRequest = { + accountId, + description: `Scan ${accountName} account`, + enabled: true, + operation: ActionOperations.SCAN, + requester: userEmail || undefined, + status: ActionStatus.Pending, + type: ActionTypes.INSTANT_ACTION, + } as ActionRequestApi; + + try { + await api.actions.actionsCreate([actionRequest]); + } catch (error) { + console.error('Failed to create scan action:', error); + } + }; + + return ( + <> + ) => ( + setIsOpen(v => !v)} isExpanded={isOpen}> + Actions + + )} + > + + + {ActionOperations.SCAN} + + + + + { + actionCreate(); + setIsModalOpen(false); + }} + onClose={() => setIsModalOpen(false)} + accountName={accountName} + /> + + ); +}; diff --git a/console/src/app/AccountDetails/components/AccountHeader.tsx b/console/src/app/AccountDetails/components/AccountHeader.tsx index 4b260ee7..3a0a1929 100644 --- a/console/src/app/AccountDetails/components/AccountHeader.tsx +++ b/console/src/app/AccountDetails/components/AccountHeader.tsx @@ -1,8 +1,10 @@ -import { Flex, FlexItem, Label, PageSection, Title } from '@patternfly/react-core'; +import { Flex, FlexItem, PageSection, Title } from '@patternfly/react-core'; import { AccountsHeaderProps } from './types'; +import { ResourceLabel } from '@app/utils/renderUtils'; +import { AccountDetailsDropdown } from './AccountDetailsDropdown'; import React from 'react'; -export const AccountsHeader: React.FunctionComponent = ({ accountName, label }) => { +export const AccountsHeader: React.FunctionComponent = ({ accountName, accountId }) => { return ( = ({ a alignItems={{ default: 'alignItemsFlexStart' }} flexWrap={{ default: 'nowrap' }} > - - - - {accountName} + <ResourceLabel label="Account" color="#c9190b" /> {accountName} + + + ); diff --git a/console/src/app/AccountDetails/components/AccountScanConfirm.tsx b/console/src/app/AccountDetails/components/AccountScanConfirm.tsx new file mode 100644 index 00000000..c68720d9 --- /dev/null +++ b/console/src/app/AccountDetails/components/AccountScanConfirm.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Button, Content } from '@patternfly/react-core'; +import { Modal, ModalVariant } from '@patternfly/react-core/deprecated'; + +interface AccountScanConfirmProps { + isOpen: boolean; + accountName: string; + onConfirm: () => void; + onClose: () => void; +} + +export const AccountScanConfirm: React.FunctionComponent = ({ + isOpen, + accountName, + onConfirm, + onClose, +}) => { + if (!isOpen) { + return null; + } + + return ( + + Confirm + , + , + ]} + > + + Are you sure you want to trigger a scan on the account {accountName}? + + + ); +}; diff --git a/console/src/app/AccountDetails/components/types.ts b/console/src/app/AccountDetails/components/types.ts index 276c291d..cfb1ca14 100644 --- a/console/src/app/AccountDetails/components/types.ts +++ b/console/src/app/AccountDetails/components/types.ts @@ -3,7 +3,7 @@ import React from 'react'; export interface AccountsHeaderProps { accountName: string; - label: string; + accountId: string; } export interface AccountsTabsProps { diff --git a/console/src/app/Actions/AuditLogs/AuditLogs.tsx b/console/src/app/Actions/AuditLogs/AuditLogs.tsx index 4429b88e..9c390f61 100644 --- a/console/src/app/Actions/AuditLogs/AuditLogs.tsx +++ b/console/src/app/Actions/AuditLogs/AuditLogs.tsx @@ -11,11 +11,11 @@ const filterParams = { action: parseAsArrayOf(parseAsStringEnum(Object.values(ActionOperations))).withDefault([]), provider: parseAsArrayOf(parseAsStringEnum(Object.values(ProviderApi))).withDefault([]), result: parseAsArrayOf(parseAsStringEnum(Object.values(ResultStatus))).withDefault([]), - triggered_by: parseAsString.withDefault(''), + requester: parseAsString.withDefault(''), }; const AuditLogs: React.FunctionComponent = () => { - const [{ accountName, action, provider, result, triggered_by }, setQuery] = useQueryStates(filterParams); + const [{ accountName, action, provider, result, requester }, setQuery] = useQueryStates(filterParams); return ( @@ -33,8 +33,8 @@ const AuditLogs: React.FunctionComponent = () => { setAction={value => setQuery({ action: value || [] })} result={result} setResult={value => setQuery({ result: value })} - triggered_by={triggered_by} - setTriggeredBy={value => setQuery({ triggered_by: value })} + requester={requester} + setRequester={value => setQuery({ requester: value })} providerSelections={provider} setProviderSelections={value => setQuery({ provider: value || [] })} /> @@ -43,7 +43,7 @@ const AuditLogs: React.FunctionComponent = () => { action={action} provider={provider} result={result} - triggered_by={triggered_by} + requester={requester} /> diff --git a/console/src/app/Actions/AuditLogs/AuditLogsTable.tsx b/console/src/app/Actions/AuditLogs/AuditLogsTable.tsx index 6b98fd70..672313cc 100644 --- a/console/src/app/Actions/AuditLogs/AuditLogsTable.tsx +++ b/console/src/app/Actions/AuditLogs/AuditLogsTable.tsx @@ -3,7 +3,13 @@ import { ActionOperations, ResultStatus } from '@app/types/types'; import { SystemEventResponseApi } from '@api'; import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import React, { useMemo } from 'react'; -import { getResultIcon, renderOperationLabel } from '@app/utils/renderUtils'; +import { + renderOperationLabel, + renderActionStatusLabel, + renderResourceBadge, + ResourceBadge, +} from '@app/utils/renderUtils'; +import { parseScanTimestamp, resolveResourcePath } from '@app/utils/parseFuncs'; import { useTableSort } from '@app/hooks/useTableSort.tsx'; import { EmptyState } from '@patternfly/react-core'; import { TablePagination } from '@app/components/common/TablesPagination'; @@ -14,13 +20,12 @@ import { useEvents } from '@app/hooks/useEvents'; import { useTablePagination } from '@app/hooks/useTablePagination'; const columnNames = { - action: 'Action', - result: 'Result', + scheduledAction: 'Action', + operation: 'Operation', resource: 'Resource', - account: 'Account', - provider: 'Provider', - triggeredBy: 'Triggered By', description: 'Description', + status: 'Status', + requester: 'Requester', date: 'Date', }; @@ -33,7 +38,7 @@ export const AuditLogsTable: React.FunctionComponent = ({ action, provider, result, - triggered_by, + requester, }) => { const { data: allEvents = [], isLoading } = useEvents(); @@ -58,38 +63,35 @@ export const AuditLogsTable: React.FunctionComponent = ({ filteredResult = filteredResult.filter(event => result.includes(event.result as ResultStatus)); } - if (triggered_by) { - filteredResult = filteredResult.filter(event => - event.triggeredBy?.toLowerCase().includes(triggered_by.toLowerCase()) - ); + if (requester) { + filteredResult = filteredResult.filter(event => event.requester?.toLowerCase().includes(requester.toLowerCase())); } return filteredResult; - }, [allEvents, accountName, action, provider, result, triggered_by]); + }, [allEvents, accountName, action, provider, result, requester]); const { page, perPage, setPage, setPerPage, paginatedData, totalItems } = useTablePagination({ data: filtered, - filterDeps: [accountName, action, provider, result, triggered_by], + filterDeps: [accountName, action, provider, result, requester], }); const getSortableRowValues = (event: SystemEventResponseApi): (string | number | null)[] => { - const { action, result, resourceId, accountId, provider, triggeredBy, description, timestamp } = event; + const { timestamp, action, resourceId, result, requester, scheduleId, description } = event; return [ + timestamp ?? null, action ?? null, - result ?? null, resourceId ?? null, - accountId ?? null, - provider ?? null, - triggeredBy ?? null, + result ?? null, + requester ?? null, + scheduleId ?? null, description ?? null, - timestamp ?? null, ]; }; const { sortedData, getSortParams } = useTableSort( paginatedData, getSortableRowValues, - 7, + 0, 'desc' ); @@ -101,41 +103,42 @@ export const AuditLogsTable: React.FunctionComponent = ({ + + - - - - + + + - - {sortedData.map(event => ( - - - + + - - - - + + - + ))} diff --git a/console/src/app/Actions/AuditLogs/AuditLogsTableToolbar.tsx b/console/src/app/Actions/AuditLogs/AuditLogsTableToolbar.tsx index 8c7c97c0..e987bcd6 100644 --- a/console/src/app/Actions/AuditLogs/AuditLogsTableToolbar.tsx +++ b/console/src/app/Actions/AuditLogs/AuditLogsTableToolbar.tsx @@ -22,7 +22,7 @@ import { ActionOperations, ResultStatus } from '@app/types/types'; import { usePopperContainer } from '@app/hooks/usePopperContainer'; import { ProviderApi } from '@api'; -type AttributeMenuOption = 'Account' | 'Provider' | 'Action' | 'Result' | 'TriggeredBy'; +type AttributeMenuOption = 'Account' | 'Provider' | 'Action' | 'Result' | 'Requester'; export const AuditLogsTableToolbar: React.FunctionComponent = ({ searchValue, @@ -31,22 +31,22 @@ export const AuditLogsTableToolbar: React.FunctionComponent { const debouncedSearch = React.useMemo(() => debounce(setSearchValue, 300), [setSearchValue]); - const debouncedTriggeredBy = React.useMemo(() => debounce(setTriggeredBy, 300), [setTriggeredBy]); + const debouncedRequester = React.useMemo(() => debounce(setRequester, 300), [setRequester]); const debouncedResult = React.useMemo(() => debounce(setResult, 300), [setResult]); React.useEffect(() => { return () => { debouncedSearch.cancel(); - debouncedTriggeredBy.cancel(); + debouncedRequester.cancel(); debouncedResult.cancel(); }; - }, [debouncedSearch, debouncedTriggeredBy, debouncedResult]); + }, [debouncedSearch, debouncedRequester, debouncedResult]); // Set up name search input const searchInput = ( @@ -58,12 +58,12 @@ export const AuditLogsTableToolbar: React.FunctionComponent ); // Set up triggered by search input - const triggeredByInput = ( + const requesterInput = ( debouncedTriggeredBy(value)} - onClear={() => debouncedTriggeredBy('')} + value={requester} + onChange={(_event, value) => debouncedRequester(value)} + onClear={() => debouncedRequester('')} /> ); @@ -518,7 +518,7 @@ export const AuditLogsTableToolbar: React.FunctionComponentAction Result Provider - Triggered by + Requester @@ -545,7 +545,7 @@ export const AuditLogsTableToolbar: React.FunctionComponent @@ -562,13 +562,13 @@ export const AuditLogsTableToolbar: React.FunctionComponent setTriggeredBy('')} - deleteLabelGroup={() => setTriggeredBy('')} - categoryName="TriggeredBy" - showToolbarItem={activeAttributeMenu === 'TriggeredBy'} + labels={requester !== '' ? [requester] : []} + deleteLabel={() => setRequester('')} + deleteLabelGroup={() => setRequester('')} + categoryName="Requester" + showToolbarItem={activeAttributeMenu === 'Requester'} > - {triggeredByInput} + {requesterInput} void; providerSelections: ProviderApi[] | null; setProviderSelections: (value: ProviderApi[] | null) => void; - triggered_by: string; - setTriggeredBy: (value: string) => void; + requester: string; + setRequester: (value: string) => void; } export interface AuditLogsTableProps { @@ -19,5 +19,5 @@ export interface AuditLogsTableProps { action?: ActionOperations[]; provider?: ProviderApi[]; result?: ResultStatus[]; - triggered_by?: string; + requester?: string; } diff --git a/console/src/app/Actions/Scheduler/Schedule.tsx b/console/src/app/Actions/Scheduler/Schedule.tsx index cb88a80c..31a72333 100644 --- a/console/src/app/Actions/Scheduler/Schedule.tsx +++ b/console/src/app/Actions/Scheduler/Schedule.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ModalPowerManagement } from './components/ModalPowerManagement'; +import { ModalCreateAction } from './components/ModalCreateAction'; import { Flex, FlexItem, Button, PageSection, Panel, Content } from '@patternfly/react-core'; import ScheduleActionsTable from './components/ActionsTable'; import ScheduleActionsTableToolbar from './components/ActionsToolBar'; @@ -70,11 +70,7 @@ const Scheduler: React.FunctionComponent = () => { reloadFlag={reloadFlag} /> - setReloadFlag(k => k + 1)} - /> + setReloadFlag(k => k + 1)} /> ); diff --git a/console/src/app/Actions/Scheduler/components/AccountSelector.tsx b/console/src/app/Actions/Scheduler/components/AccountSelector.tsx index 1a785a68..b947f4f7 100644 --- a/console/src/app/Actions/Scheduler/components/AccountSelector.tsx +++ b/console/src/app/Actions/Scheduler/components/AccountSelector.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Button, FormGroup, + Popover, Select, SelectOption, MenuToggle, @@ -10,14 +11,18 @@ import { TextInputGroupUtilities, Tooltip, } from '@patternfly/react-core'; +import { HelpIcon } from '@patternfly/react-icons'; import { AccountResponseApi } from '@api'; import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; +export const ALL_ACCOUNTS_ID = '__all__'; + interface AccountTypeaheadSelectProps { accounts: AccountResponseApi[]; selectedAccount: AccountResponseApi | null; onSelectAccount: (account: AccountResponseApi | null) => void; onClearAccount: () => void; + showAllOption?: boolean; } export const AccountTypeaheadSelect: React.FunctionComponent = ({ @@ -25,34 +30,66 @@ export const AccountTypeaheadSelect: React.FunctionComponent { const [isOpen, setIsOpen] = React.useState(false); const [inputValue, setInputValue] = React.useState(''); const safeAccounts = React.useMemo(() => (Array.isArray(accounts) ? accounts : []), [accounts]); + const allAccountsEntry: AccountResponseApi = React.useMemo( + () => ({ accountId: ALL_ACCOUNTS_ID, accountName: 'All Accounts' }), + [] + ); + const filteredAccounts = React.useMemo(() => { const q = inputValue.trim().toLowerCase(); - if (!q) return safeAccounts; + const filtered = q + ? safeAccounts.filter(a => { + const haystack = `${a.accountName ?? ''} ${a.accountId ?? ''}`.toLowerCase(); + return haystack.includes(q); + }) + : safeAccounts; - return safeAccounts.filter(a => { - const haystack = `${a.accountName ?? ''} ${a.accountId ?? ''}`.toLowerCase(); - return haystack.includes(q); - }); - }, [safeAccounts, inputValue]); + if (showAllOption) { + const allMatches = !q || 'all accounts'.includes(q); + return allMatches ? [allAccountsEntry, ...filtered] : filtered; + } + return filtered; + }, [safeAccounts, inputValue, showAllOption, allAccountsEntry]); const onSelect = (_event?: React.MouseEvent, value?: string | number) => { const id = String(value ?? ''); - const acc = safeAccounts.find(a => a.accountId === id) ?? null; + const acc = + (showAllOption && id === ALL_ACCOUNTS_ID ? allAccountsEntry : null) ?? + safeAccounts.find(a => a.accountId === id) ?? + null; - // Keep input in sync with selection for a predictable UX - setInputValue(acc ? `${acc.accountName} (${acc.accountId})` : ''); + setInputValue( + acc ? (acc.accountId === ALL_ACCOUNTS_ID ? (acc.accountName ?? '') : `${acc.accountName} (${acc.accountId})`) : '' + ); onSelectAccount(acc); setIsOpen(false); }; return ( - + + - - + - - - + + + @@ -99,21 +103,26 @@ export const ScheduleActionsTable: React.FunctionComponent<{ - - - - - + + - + @@ -48,7 +48,7 @@ const TableEvents: React.FunctionComponent = ({ data, getSortP {getResultIcon(event.result as ResultStatus)} {event.result} - + @@ -101,8 +101,8 @@ export const ClusterDetailsEvents: React.FunctionComponent = () => { console.log('Rendered events data:', data); const getSortableRowValues = (event: SystemEventResponseApi): (string | number | null)[] => { - const { action, result, severity, triggeredBy, description: description, timestamp } = event; - return [action, result, severity, triggeredBy, description ?? null, timestamp]; + const { action, result, severity, requester, description: description, timestamp } = event; + return [action, result, severity, requester, description ?? null, timestamp]; }; const { sortedData, getSortParams } = useTableSort(data, getSortableRowValues, 5, 'desc'); diff --git a/console/src/app/ClusterDetails/components/ClusterDetailsOverview.tsx b/console/src/app/ClusterDetails/components/ClusterDetailsOverview.tsx index f2f6db3e..19474a64 100644 --- a/console/src/app/ClusterDetails/components/ClusterDetailsOverview.tsx +++ b/console/src/app/ClusterDetails/components/ClusterDetailsOverview.tsx @@ -1,6 +1,6 @@ import { LoadingSpinner } from '@app/components/common/LoadingSpinner'; import { parseNumberToCurrency, parseScanTimestamp } from '@app/utils/parseFuncs'; -import { renderStatusLabel } from '@app/utils/renderUtils'; +import { renderStatusLabel, ResourceLabel } from '@app/utils/renderUtils'; import { ClusterResponseApi, TagResponseApi } from '@api'; import { Flex, @@ -12,7 +12,6 @@ import { DescriptionListDescription, TabContentBody, PageSection, - Label, Divider, Tabs, Tab, @@ -117,7 +116,7 @@ const ClusterDetailsOverview: React.FunctionComponent = () => { Created at {parseScanTimestamp(cluster?.createdAt)} - Last scanned at + Last scan {parseScanTimestamp(cluster?.lastScanTimestamp)} @@ -183,13 +182,9 @@ const ClusterDetailsOverview: React.FunctionComponent = () => { alignItems={{ default: 'alignItemsFlexStart' }} flexWrap={{ default: 'nowrap' }} > - - - - - {clusterID} + <ResourceLabel label="Cluster" color="#0066cc" /> {cluster?.clusterName || clusterID} diff --git a/console/src/app/Overview/components/ActivityTable.tsx b/console/src/app/Overview/components/ActivityTable.tsx index 743d3fe4..caf8cb93 100644 --- a/console/src/app/Overview/components/ActivityTable.tsx +++ b/console/src/app/Overview/components/ActivityTable.tsx @@ -3,10 +3,15 @@ import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; import { EmptyState } from '@patternfly/react-core'; import { SystemEventResponseApi } from '@api'; import { Link } from 'react-router-dom'; -import { resolveResourcePath } from '@app/utils/parseFuncs'; +import { parseScanTimestamp, resolveResourcePath } from '@app/utils/parseFuncs'; import { InboxIcon } from '@patternfly/react-icons'; -import { getResultIcon } from '@app/utils/renderUtils'; -import { ResultStatus } from '@app/types/types'; +import { + renderOperationLabel, + renderActionStatusLabel, + renderResourceBadge, + ResourceBadge, +} from '@app/utils/renderUtils'; +import { ActionOperations } from '@app/types/types'; interface ActivityTableProps { events: SystemEventResponseApi[]; @@ -21,27 +26,36 @@ export const ActivityTable: React.FunctionComponent = ({ eve
{columnNames.date}{columnNames.operation} {columnNames.resource}{columnNames.action}{columnNames.account}{columnNames.provider}{columnNames.triggeredBy}{columnNames.status}{columnNames.requester}{columnNames.scheduledAction} {columnNames.description}{columnNames.result}{columnNames.date}
- - {event.resourceId} - - {renderOperationLabel(event.action)} - {event.accountId} + {parseScanTimestamp(event.timestamp)}{renderOperationLabel(event.action)} + {event.resourceId ? ( + <> + {renderResourceBadge(event.resourceType)}{' '} + + {event.resourceName || event.resourceId} + + + ) : event.action === ActionOperations.SCAN ? ( + <> + All Accounts + + ) : ( + '-' + )} {event.provider}{event.triggeredBy}{event.description} - {getResultIcon(event.result as ResultStatus)} {event.result} + {renderActionStatusLabel(event.result)}{event.requester} + {event.scheduleId ? #{event.scheduleId} : '-'} {event.timestamp}{event.description}
{columnNames.id} {columnNames.type}{columnNames.time}{columnNames.cronExpression}{columnNames.schedule} {columnNames.operation} {columnNames.status}{columnNames.clusterId}{columnNames.region}{columnNames.accountId}{columnNames.target}{columnNames.requester}{columnNames.description} {columnNames.enabled}
{action.id} {renderActionTypeLabel(action.type)}{action.type !== ActionTypes.CRON_ACTION ? action.time : '-'} - {action.type === ActionTypes.CRON_ACTION ? action.cronExpression : '-'} + + {action.type === ActionTypes.CRON_ACTION + ? `${action.cronExpression} (${cronstrue.toString(action.cronExpression ?? '', { use24HourTimeFormat: true })})` + : parseScanTimestamp(action.time)} {renderOperationLabel(action.operation)} {renderActionStatusLabel(action.status)} - {action.clusterId} - {action.region} - {action.accountId} + + {renderTargetLabel( + action.clusterId, + action.clusterName, + action.targetAccountIds, + action.targetAccountNames, + action.selectAll + )} {action.requester || '-'}{action.description || '-'} - {action.enabled ? : } + {action.enabled ? : } diff --git a/console/src/app/Actions/Scheduler/components/ModalPowerManagement.tsx b/console/src/app/Actions/Scheduler/components/ModalCreateAction.tsx similarity index 61% rename from console/src/app/Actions/Scheduler/components/ModalPowerManagement.tsx rename to console/src/app/Actions/Scheduler/components/ModalCreateAction.tsx index d6931404..f202050d 100644 --- a/console/src/app/Actions/Scheduler/components/ModalPowerManagement.tsx +++ b/console/src/app/Actions/Scheduler/components/ModalCreateAction.tsx @@ -8,53 +8,45 @@ import { HelperText, HelperTextItem, Radio, + ToggleGroup, + ToggleGroupItem, } from '@patternfly/react-core'; -import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import { ExclamationCircleIcon, HelpIcon } from '@patternfly/react-icons'; +import { Popover } from '@patternfly/react-core'; import { Modal, ModalVariant } from '@patternfly/react-core/deprecated'; import React from 'react'; import { ActionOperations, ActionTypes } from '@app/types/types'; import DateTimePicker from './DateTimePicker'; -import { AccountTypeaheadSelect } from './AccountSelector'; +import { AccountTypeaheadSelect, ALL_ACCOUNTS_ID } from './AccountSelector'; import { ClusterTypeaheadSelect } from './ClusterSelector'; import { ActionStatus } from '@app/types/types'; -import { useUser } from '@app/Contexts/UserContext.tsx'; import { debug } from '@app/utils/debugLogs'; -import { api, startCluster, stopCluster, AccountResponseApi, ClusterResponseApi, ActionRequestApi } from '@api'; +import { api, AccountResponseApi, ClusterResponseApi, ActionRequestApi } from '@api'; +import { useUser } from '@app/Contexts/UserContext'; import cronValidate from 'cron-validate'; -interface ModalPowerManagementProps { +interface ModalCreateActionProps { isOpen: boolean; onClose: () => void; onCreated: () => void; } -export const ModalPowerManagement: React.FunctionComponent = ({ - isOpen, - onClose, - onCreated, -}) => { +export const ModalCreateAction: React.FunctionComponent = ({ isOpen, onClose, onCreated }) => { const { userEmail } = useUser(); - - // Modal From parameters + const [actionOperation, setActionOperation] = React.useState(''); const [selectedAccount, setSelectedAccount] = React.useState(null); const [selectedCluster, setSelectedCluster] = React.useState(null); - const [actionOperation, setActionOperation] = React.useState(''); const [scheduledDateTime, setScheduledDateTime] = React.useState(''); const [showSchedule, setShowSchedule] = React.useState(false); const [cronExpression, setCronExpression] = React.useState(''); const [cronTouched, setCronTouched] = React.useState(false); const [description, setDescription] = React.useState(''); - const [showDescriptionField, setShowDescriptionField] = React.useState(false); + const [actionType, setActionType] = React.useState(ActionTypes.INSTANT_ACTION); - // Account/Cluster typeahead vars const [allAccounts, setAllAccounts] = React.useState([]); const [allClusters, setAllClusters] = React.useState([]); - // TODO: restore Loading spinner - //const [loading, setLoading] = React.useState(true); - - // Action type selection - const [actionType, setActionType] = React.useState(ActionTypes.INSTANT_ACTION); + const isScan = actionOperation === ActionOperations.SCAN; const isValidCronExpression = (expr: string): boolean => { if (!expr.trim()) return false; @@ -62,25 +54,25 @@ export const ModalPowerManagement: React.FunctionComponent { - setSelectedAccount(null); - }; - - const onClusterClearButtonClick = () => { + const handleOperationChange = (value: string) => { + setActionOperation(value); setSelectedCluster(null); + setShowSchedule(false); + setActionType(ActionTypes.INSTANT_ACTION); + setScheduledDateTime(''); + setCronExpression(''); + setCronTouched(false); }; - // Load accounts when the modal opens. React.useEffect(() => { if (!isOpen) return; @@ -111,7 +103,7 @@ export const ModalPowerManagement: React.FunctionComponent controller.abort(); }, [isOpen, selectedAccount?.accountId]); - // Reset modal state when closing to avoid leaking previous selections. React.useEffect(() => { if (isOpen) return; - setShowDescriptionField(false); + setActionOperation(''); setDescription(''); setShowSchedule(false); setActionType(ActionTypes.INSTANT_ACTION); @@ -150,76 +141,43 @@ export const ModalPowerManagement: React.FunctionComponent { - // Ensure clusterID is not undefined before performing any action - if (!selectedCluster?.clusterId) { - console.error('ClusterID is undefined. Cannot perform scheduled action'); + if (!isScan && !selectedCluster?.clusterId) { + console.error('ClusterID is undefined. Cannot perform action'); return; } - const finalDescription = showDescriptionField ? description : 'Routine maintenance'; - - // Instant Action run - if (actionType === ActionTypes.INSTANT_ACTION) { - if (actionOperation === ActionOperations.POWER_ON) { - debug('Powering on the cluster'); - startCluster(selectedCluster?.clusterId, userEmail ?? undefined, finalDescription); - } else if (actionOperation === ActionOperations.POWER_OFF) { - debug('Powering off the cluster'); - stopCluster(selectedCluster?.clusterId, userEmail ?? undefined, finalDescription); - } - } else { - // Creating base action. Tunning depending on ActionType - const powerActionRequest = { - accountId: selectedAccount?.accountId, - clusterId: selectedCluster?.clusterId, - enabled: true, - operation: actionOperation, - region: selectedCluster?.region, - status: ActionStatus.Pending, - description: finalDescription, - } as ActionRequestApi; - - // Scheduled Action run - if (actionType === ActionTypes.SCHEDULED_ACTION) { - // TODO: Convert to data validation - if (!scheduledDateTime) { - console.error('Scheduled DateTime is required'); - return; - } - powerActionRequest.type = ActionTypes.SCHEDULED_ACTION; - powerActionRequest.time = scheduledDateTime; - // Cron Action run - } else if (actionType === ActionTypes.CRON_ACTION) { - // TODO: Convert to data validation - if (!cronExpression.trim()) { - console.error('Cron expression is empty'); - return; - } - - powerActionRequest.type = ActionTypes.CRON_ACTION; - powerActionRequest.cronExpression = cronExpression.trim(); - } - - const powerActionRequests: ActionRequestApi[] = [powerActionRequest]; - console.log(powerActionRequest); - await api.actions.actionsCreate(powerActionRequests); - onCreated(); - onClose(); + const actionRequest = { + accountId: selectedAccount?.accountId === ALL_ACCOUNTS_ID ? '' : selectedAccount?.accountId, + clusterId: isScan ? undefined : selectedCluster?.clusterId, + description: description || undefined, + enabled: true, + operation: actionOperation, + region: isScan ? undefined : selectedCluster?.region, + requester: userEmail || undefined, + status: ActionStatus.Pending, + type: actionType, + } as ActionRequestApi; + + if (actionType === ActionTypes.SCHEDULED_ACTION) { + actionRequest.time = scheduledDateTime; + } else if (actionType === ActionTypes.CRON_ACTION) { + actionRequest.cronExpression = cronExpression.trim(); } + debug('Creating action', actionRequest); + await api.actions.actionsCreate([actionRequest]); + onCreated(); onClose(); }; - // Do not render anything if there is no action - if (!isOpen || !actionType) { + if (!isOpen) { return null; } - // Scheduling modal for Schedule action return ( - {/* Account selection */} -
+ {/* Operation selection */} + +
{columnNames.action} {columnNames.result} {columnNames.severity}{columnNames.loggedBy}{columnNames.requester} {columnNames.description} {columnNames.date}
{event.severity}{event.triggeredBy}{event.requester} {event.description} {event.timestamp}
- - - + + - + + {events.map(event => ( - - + + - - + + ))} diff --git a/console/src/app/ServerDetails/ServerDetails.tsx b/console/src/app/ServerDetails/ServerDetails.tsx index 98ac78d8..1b04d0ba 100644 --- a/console/src/app/ServerDetails/ServerDetails.tsx +++ b/console/src/app/ServerDetails/ServerDetails.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { renderStatusLabel } from '@app/utils/renderUtils'; +import { renderStatusLabel, ResourceLabel } from '@app/utils/renderUtils'; import { parseScanTimestamp, parseNumberToCurrency } from 'src/app/utils/parseFuncs'; import { useParams } from 'react-router-dom'; import { @@ -14,9 +14,9 @@ import { DescriptionListGroup, DescriptionListTerm, DescriptionListDescription, - Label, Flex, FlexItem, + Label, LabelGroup, Bullseye, Spinner, @@ -100,7 +100,7 @@ const ServerDetails: React.FunctionComponent = () => { Labels - Last scanned at + Last scan {parseScanTimestamp(instanceData?.lastScanTimestamp)} @@ -131,20 +131,9 @@ const ServerDetails: React.FunctionComponent = () => { {/* Page header */} - - - - - - - {instanceID} - - - + + <ResourceLabel label="Instance" color="#4cb140" /> {instanceData?.instanceName || instanceID} + {/* Page tabs */} diff --git a/console/src/app/components/common/TablesPagination.tsx b/console/src/app/components/common/TablesPagination.tsx index 4cbc6862..d08665f5 100644 --- a/console/src/app/components/common/TablesPagination.tsx +++ b/console/src/app/components/common/TablesPagination.tsx @@ -18,6 +18,7 @@ export const TablePagination: React.FC = ({ }) => { return ( ({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: '1.5em', + height: '1.5em', + padding: '0 0.35em', + borderRadius: '50%', + backgroundColor: color, + color: '#fff', + fontSize: '0.75rem', + fontWeight: 700, + lineHeight: 1, + verticalAlign: 'middle', +}); + +export function ResourceBadge({ label, color }: { label: string; color: string }) { + return {label}; +} + +const resourceLabelStyle = (color: string): CSSProperties => ({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + padding: '0.1em 0.5em', + borderRadius: '0.75em', + backgroundColor: color, + color: '#fff', + fontSize: '0.75em', + fontWeight: 700, + lineHeight: 1, + verticalAlign: 'middle', +}); + +export function ResourceLabel({ label, color }: { label: string; color: string }) { + return {label}; +} export function renderActionStatusLabel(labelText: string | null | undefined) { switch (labelText) { @@ -41,11 +84,11 @@ export function renderStatusLabel(labelText: string | null | undefined) { export function renderActionTypeLabel(labelText: string | null | undefined) { switch (labelText) { case ActionTypes.INSTANT_ACTION: - return ; + return ; case ActionTypes.SCHEDULED_ACTION: - return ; + return ; case ActionTypes.CRON_ACTION: - return ; + return ; default: return ; } @@ -57,11 +100,44 @@ export function renderOperationLabel(labelText: string | null | undefined) { return ; case ActionOperations.POWER_OFF: return ; + case ActionOperations.SCAN: + return ; default: return ; } } +export function renderTargetLabel( + clusterId: string | undefined, + clusterName: string | undefined, + targetAccountIds: string[] | undefined, + targetAccountNames: string[] | undefined, + selectAll: boolean | undefined +): React.ReactNode { + if (clusterId) { + return ( + <> + {' '} + {clusterName || clusterId} + + ); + } + if (!selectAll && targetAccountIds?.length) { + const accId = targetAccountIds[0]; + const accName = targetAccountNames?.[0]; + return ( + <> + {accName || accId} + + ); + } + return ( + <> + All Accounts + + ); +} + export const getResultIcon = (result: ResultStatus) => { return ( { @@ -84,3 +160,30 @@ export const getResultIcon = (result: ResultStatus) => { }[result] || ); }; + +const providerIconStyle: CSSProperties = { fontSize: '2.0em', verticalAlign: 'middle' }; + +export function renderProviderIcon(provider: string | null | undefined): React.ReactNode { + switch (provider) { + case 'AWS': + return ; + case 'GCP': + return ; + case 'Azure': + return ; + default: + return provider || '-'; + } +} + +const RESOURCE_BADGE_MAP: Record = { + Cluster: { label: 'C', color: '#0066cc' }, + Instance: { label: 'I', color: '#4cb140' }, + Account: { label: 'A', color: '#c9190b' }, +}; + +export function renderResourceBadge(resourceType: string | undefined): React.ReactNode { + const badge = RESOURCE_BADGE_MAP[resourceType ?? '']; + if (!badge) return null; + return ; +} diff --git a/db/sql/init.sql b/db/sql/init.sql index 19d7ecec..e99d9524 100644 --- a/db/sql/init.sql +++ b/db/sql/init.sql @@ -26,7 +26,8 @@ CREATE TYPE RESOURCE_TYPE AS ENUM ( -- Supported values of Action Operations CREATE TYPE ACTION_OPERATION AS ENUM ( 'PowerOn', - 'PowerOff' + 'PowerOff', + 'Scan' ); -- Supported values of action types @@ -192,13 +193,14 @@ CREATE TABLE expenses_default PARTITION OF expenses DEFAULT; CREATE TABLE IF NOT EXISTS events ( id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL, event_timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - triggered_by TEXT NOT NULL, + requester TEXT NOT NULL, action TEXT NOT NULL, resource_id BIGINT, resource_type RESOURCE_TYPE NOT NULL, result ACTION_STATUS NOT NULL, description TEXT NULL, severity TEXT DEFAULT 'info'::TEXT NOT NULL, + schedule_id BIGINT NULL, PRIMARY KEY (id, event_timestamp) ) PARTITION BY RANGE (event_timestamp); @@ -237,6 +239,37 @@ CREATE TRIGGER trg_delete_instance_events EXECUTE FUNCTION delete_instance_events(); +-- ############################################################################# +-- ## Targets definition ## +-- ############################################################################# +\! echo '## Creating Targets tables' + +CREATE TABLE IF NOT EXISTS targets ( + id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL, + target_type RESOURCE_TYPE NOT NULL, + select_all BOOLEAN DEFAULT false, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS target_accounts ( + target_id BIGINT REFERENCES targets(id) ON DELETE CASCADE NOT NULL, + account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (target_id, account_id) +); + +CREATE TABLE IF NOT EXISTS target_clusters ( + target_id BIGINT REFERENCES targets(id) ON DELETE CASCADE NOT NULL, + cluster_id BIGINT REFERENCES clusters(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (target_id, cluster_id) +); + +CREATE TABLE IF NOT EXISTS target_instances ( + target_id BIGINT REFERENCES targets(id) ON DELETE CASCADE NOT NULL, + instance_id BIGINT REFERENCES instances(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (target_id, instance_id) +); + + -- ############################################################################# -- ## Actions and Scheduling definition ## -- ############################################################################# @@ -248,10 +281,12 @@ CREATE TABLE IF NOT EXISTS schedule ( time TIMESTAMP WITH TIME ZONE, cron_exp TEXT, operation ACTION_OPERATION NOT NULL, - target INTEGER REFERENCES clusters(id) ON DELETE CASCADE NOT NULL, + target BIGINT REFERENCES targets(id) ON DELETE CASCADE NOT NULL, status ACTION_STATUS DEFAULT 'Unknown' NOT NULL, enabled BOOLEAN DEFAULT false, - PRIMARY KEY (id), + requester TEXT, + description TEXT, + PRIMARY KEY (id), CONSTRAINT chk_schedule_time_or_cron CHECK ((time IS NOT NULL) <> (cron_exp IS NOT NULL)) ); @@ -259,6 +294,25 @@ CREATE INDEX IF NOT EXISTS ix_schedule_target_enabled ON schedule (target, enabl CREATE INDEX IF NOT EXISTS ix_schedule_status ON schedule (status); +-- ############################################################################# +-- ## Action Runs (execution history) ## +-- ############################################################################# +\! echo '## Creating Action Runs table' + +CREATE TABLE IF NOT EXISTS action_runs ( + id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL, + schedule_id BIGINT REFERENCES schedule(id) ON DELETE CASCADE NOT NULL, + started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + finished_at TIMESTAMP WITH TIME ZONE, + status ACTION_STATUS NOT NULL DEFAULT 'Running', + error_msg TEXT, + PRIMARY KEY (id) +); + +CREATE INDEX IF NOT EXISTS ix_action_runs_schedule ON action_runs (schedule_id); +CREATE INDEX IF NOT EXISTS ix_action_runs_status ON action_runs (status); + + -- ## Advanced Inventory definition (Views & Functions) ## -- ################################################################################################# @@ -516,29 +570,50 @@ WHERE -- ## Schedule -- ############################################################################# --- Schedule with cluster and instances list view +-- Schedule with target details view CREATE OR REPLACE VIEW schedule_full_view AS SELECT - s.id, - s.type, - s.time, - s.cron_exp, - s.operation, - s.status, - s.enabled, - c.cluster_id, - c.region, - a.account_id, - COALESCE( - array_agg(DISTINCT i.instance_id ORDER BY i.instance_id), - '{}' - ) AS instances -FROM - schedule s -JOIN clusters c ON c.id = s.target + s.id, + s.type, + s.time, + s.cron_exp, + s.operation, + s.status, + s.enabled, + s.requester, + s.description, + t.target_type, + t.select_all, + c.cluster_id, + c.cluster_name, + c.region, + COALESCE(a_power.account_id, '') AS account_id, + COALESCE( + array_agg(DISTINCT i.instance_id ORDER BY i.instance_id) + FILTER (WHERE i.instance_id IS NOT NULL), + '{}' + ) AS instances, + COALESCE( + (SELECT array_agg(DISTINCT accs.account_id ORDER BY accs.account_id) + FROM target_accounts ta_sub + JOIN accounts accs ON accs.id = ta_sub.account_id + WHERE ta_sub.target_id = t.id), + '{}' + ) AS target_account_ids, + COALESCE( + (SELECT array_agg(DISTINCT accs.account_name ORDER BY accs.account_name) + FROM target_accounts ta_sub + JOIN accounts accs ON accs.id = ta_sub.account_id + WHERE ta_sub.target_id = t.id), + '{}' + ) AS target_account_names +FROM schedule s +JOIN targets t ON t.id = s.target +LEFT JOIN target_clusters tc ON tc.target_id = t.id +LEFT JOIN clusters c ON c.id = tc.cluster_id LEFT JOIN instances i ON i.cluster_id = c.id -JOIN accounts a ON c.account_id = a.id -GROUP BY a.account_id, s.id, c.id +LEFT JOIN accounts a_power ON a_power.id = c.account_id +GROUP BY s.id, t.id, c.id, a_power.account_id ORDER BY s.id; @@ -578,16 +653,18 @@ CREATE OR REPLACE VIEW cluster_events AS SELECT ev.id, ev.event_timestamp, - ev.triggered_by, + ev.requester, ev.action, - COALESCE(c.cluster_id, i.instance_id) AS resource_id, + COALESCE(c.cluster_id, i.instance_id, a.account_id) AS resource_id, ev.resource_type, ev.result, ev.description, - ev.severity + ev.severity, + ev.schedule_id FROM events ev LEFT JOIN clusters c ON ev.resource_type = 'Cluster'::RESOURCE_TYPE AND c.id = ev.resource_id LEFT JOIN instances i ON ev.resource_type = 'Instance'::RESOURCE_TYPE AND i.id = ev.resource_id +LEFT JOIN accounts a ON ev.resource_type = 'Account'::RESOURCE_TYPE AND a.id = ev.resource_id ORDER BY event_timestamp DESC; -- View for System Events @@ -595,24 +672,30 @@ CREATE OR REPLACE VIEW system_events AS SELECT ev.id, ev.event_timestamp, - ev.triggered_by, + ev.requester, ev.action, - COALESCE(c.cluster_id, i.instance_id) AS resource_id, + COALESCE(c.cluster_id, i.instance_id, a.account_id) AS resource_id, + COALESCE(c.cluster_name, i.instance_name, a.account_name) AS resource_name, ev.resource_type, ev.result, ev.description, ev.severity, + ev.schedule_id, acc.account_id, + acc.account_name, acc.provider FROM events ev LEFT JOIN clusters c ON ev.resource_type = 'Cluster'::RESOURCE_TYPE AND c.id = ev.resource_id LEFT JOIN instances i ON ev.resource_type = 'Instance'::RESOURCE_TYPE AND i.id = ev.resource_id +LEFT JOIN accounts a ON ev.resource_type = 'Account'::RESOURCE_TYPE AND a.id = ev.resource_id LEFT JOIN accounts acc ON acc.id = ( CASE WHEN ev.resource_type = 'Cluster'::RESOURCE_TYPE THEN (SELECT c.account_id FROM clusters c WHERE c.id = ev.resource_id) WHEN ev.resource_type = 'Instance'::RESOURCE_TYPE THEN (SELECT c.account_id FROM clusters c WHERE c.id = (SELECT i.cluster_id FROM instances i WHERE i.id = ev.resource_id)) + WHEN ev.resource_type = 'Account'::RESOURCE_TYPE + THEN ev.resource_id END ) ORDER BY ev.event_timestamp DESC; diff --git a/db/test_files/integration_tests_data.sql b/db/test_files/integration_tests_data.sql index aaddeaf3..4c63021e 100644 --- a/db/test_files/integration_tests_data.sql +++ b/db/test_files/integration_tests_data.sql @@ -1,7 +1,7 @@ BEGIN; -- Cleaning -TRUNCATE expenses, tags, instances, clusters, accounts RESTART IDENTITY CASCADE; +TRUNCATE action_runs, schedule, targets, expenses, tags, instances, clusters, accounts RESTART IDENTITY CASCADE; -- ## Accounts ## INSERT INTO accounts (account_id, account_name, provider, last_scan_ts, created_at) VALUES @@ -70,17 +70,35 @@ INSERT INTO expenses (instance_id, date, amount) VALUES (12,'2025-07-30',1.10),(12,'2025-07-31',1.15),(12,'2025-08-01',1.20),(12,'2025-08-02',1.25),(12,'2025-08-03',1.30); -INSERT INTO events (event_timestamp, triggered_by, action, resource_id, resource_type, result, description, severity) VALUES +INSERT INTO events (event_timestamp, requester, action, resource_id, resource_type, result, description, severity) VALUES ('2025-08-02 12:00:00+00', 'cluster-iq-tester', 'test', '1', 'Cluster', 'Success', 'integration test event', 'info'), ('2025-08-02 12:00:00+00', 'cluster-iq-tester', 'test', '10', 'Instance', 'Pending', 'integration test event', 'critical'); +-- ## Targets (for schedule entries) ## +INSERT INTO targets (target_type, select_all) VALUES + ('Cluster', false), + ('Cluster', false), + ('Cluster', false); + +INSERT INTO target_clusters (target_id, cluster_id) VALUES + (1, 1), + (2, 2), + (3, 4); + INSERT INTO schedule (type, time, operation, target, status, enabled) VALUES ('scheduled_action', '1970-01-01 00:00:00+000', 'PowerOff', 1, 'Pending', 't'), - ('scheduled_action', '1970-01-01 00:00:00+000', 'PowerOn', 4, 'Pending', 'f'); + ('scheduled_action', '1970-01-01 00:00:00+000', 'PowerOn', 3, 'Pending', 'f'); INSERT INTO schedule (type, cron_exp, operation, target, status, enabled) VALUES ('scheduled_action', '30 */12 * 6 *', 'PowerOff', 2, 'Pending', 'f'); +-- ## Action Runs ## +INSERT INTO action_runs (schedule_id, status) VALUES + (1, 'Running'); + +INSERT INTO action_runs (schedule_id, status, finished_at, error_msg) VALUES + (2, 'Success', '2025-08-02 13:00:00+00', NULL); + COMMIT; diff --git a/db/test_files/load_example_data.sql b/db/test_files/load_example_data.sql index 7367fd47..cacd945d 100644 --- a/db/test_files/load_example_data.sql +++ b/db/test_files/load_example_data.sql @@ -3,7 +3,7 @@ BEGIN; -- Limpia datos previos (si los hubiera) -TRUNCATE expenses, tags, instances, clusters, accounts RESTART IDENTITY CASCADE; +TRUNCATE action_runs, schedule, targets, expenses, tags, instances, clusters, accounts RESTART IDENTITY CASCADE; -- Inserta 3 cuentas (una por proveedor) y guarda sus IDs WITH ins AS ( INSERT INTO accounts (account_id, account_name, provider, last_scan_ts) @@ -204,39 +204,49 @@ BEGIN END $$; --- Generating scheduled actions -INSERT INTO schedule (type, time, cron_exp, operation, target, status, enabled) -SELECT - 'scheduled_action'::ACTION_TYPE, - now() + (g * interval '1 day') AS time, - NULL, - (ARRAY['PowerOn','PowerOff'])[1 + (random()*1)::int]::ACTION_OPERATION, - c.id AS target, - 'Pending'::ACTION_STATUS, - (random() > 0.5) AS enabled -FROM generate_series(1,3) g -JOIN LATERAL ( - SELECT id FROM clusters ORDER BY random() LIMIT 1 -) c ON true; - --- Generating cron-based action -INSERT INTO schedule (type, time, cron_exp, operation, target, status, enabled) -SELECT - 'cron_action'::ACTION_TYPE, - NULL, - (ARRAY['0 6 * * *', '0 0 * * 0', '*/30 * * * *'])[g] AS cron_exp, - (ARRAY['PowerOn','PowerOff'])[1 + (random()*1)::int]::ACTION_OPERATION, - c.id AS target, - 'Pending'::ACTION_STATUS, - (random() > 0.5) AS enabled -FROM generate_series(1,3) g -JOIN LATERAL ( - SELECT id FROM clusters ORDER BY random() LIMIT 1 -) c ON true; +-- Generating scheduled actions (with targets) +DO $$ +DECLARE + v_target_id BIGINT; + v_cluster_id BIGINT; + v_operation ACTION_OPERATION; +BEGIN + FOR g IN 1..3 LOOP + SELECT id INTO v_cluster_id FROM clusters ORDER BY random() LIMIT 1; + v_operation := (ARRAY['PowerOn','PowerOff'])[1 + (random()*1)::int]::ACTION_OPERATION; + + INSERT INTO targets (target_type, select_all) VALUES ('Cluster', false) RETURNING id INTO v_target_id; + INSERT INTO target_clusters (target_id, cluster_id) VALUES (v_target_id, v_cluster_id); + INSERT INTO schedule (type, time, cron_exp, operation, target, status, enabled) + VALUES ('scheduled_action', now() + (g * interval '1 day'), NULL, v_operation, v_target_id, 'Pending', (random() > 0.5)); + END LOOP; +END +$$; + +-- Generating cron-based actions (with targets) +DO $$ +DECLARE + v_target_id BIGINT; + v_cluster_id BIGINT; + v_operation ACTION_OPERATION; + v_cron TEXT; +BEGIN + FOR g IN 1..3 LOOP + SELECT id INTO v_cluster_id FROM clusters ORDER BY random() LIMIT 1; + v_operation := (ARRAY['PowerOn','PowerOff'])[1 + (random()*1)::int]::ACTION_OPERATION; + v_cron := (ARRAY['0 6 * * *', '0 0 * * 0', '*/30 * * * *'])[g]; + + INSERT INTO targets (target_type, select_all) VALUES ('Cluster', false) RETURNING id INTO v_target_id; + INSERT INTO target_clusters (target_id, cluster_id) VALUES (v_target_id, v_cluster_id); + INSERT INTO schedule (type, time, cron_exp, operation, target, status, enabled) + VALUES ('cron_action', NULL, v_cron, v_operation, v_target_id, 'Pending', (random() > 0.5)); + END LOOP; +END +$$; -- Generating events INSERT INTO events ( - event_timestamp, triggered_by, action, resource_id, resource_type, result, description, severity + event_timestamp, requester, action, resource_id, resource_type, result, description, severity ) SELECT now() - (random() * interval '10 days') AS event_timestamp, diff --git a/deployments/compose/compose-devel.yaml b/deployments/compose/compose-devel.yaml index 6f190c92..d3f86112 100644 --- a/deployments/compose/compose-devel.yaml +++ b/deployments/compose/compose-devel.yaml @@ -10,8 +10,8 @@ services: container_name: api restart: "always" depends_on: - pgsql: - condition: service_started + init-pgsql: + condition: service_completed_successfully ports: - 8081:8080 environment: @@ -30,16 +30,24 @@ services: scanner: image: quay.io/ecosystem-appeng/cluster-iq-scanner:latest container_name: scanner - restart: "no" + restart: "always" depends_on: api: condition: service_healthy restart: true + ports: + - 50052:50052 environment: CIQ_API_URL: "http://api:8080/api/v1" + CIQ_SCANNER_LISTEN_URL: "0.0.0.0:50052" CIQ_CREDS_FILE: "/credentials" CIQ_SKIP_NO_OPENSHIFT_INSTANCES: "true" CIQ_LOG_LEVEL: "DEBUG" + healthcheck: + test: ["CMD-SHELL", "timeout 2 bash -lc 'echo > /dev/tcp/127.0.0.1/50052'"] + interval: 10s + timeout: 5s + retries: 5 volumes: - ../../secrets/credentials:/credentials:ro,Z networks: @@ -57,6 +65,7 @@ services: CIQ_API_URL: "http://api:8080/api/v1" CIQ_DB_URL: "postgresql://user:password@pgsql:5432/clusteriq?sslmode=disable" CIQ_AGENT_INSTANT_SERVICE_LISTEN_URL: "0.0.0.0:50051" + CIQ_SCANNER_URL: "scanner:50052" CIQ_CREDS_FILE: "/credentials" CIQ_LOG_LEVEL: "DEBUG" CIQ_AGENT_POLLING_SECONDS_INTERVAL: 10 # Seconds @@ -141,7 +150,7 @@ services: echo "Done!" ' environment: - CIQ_DB_PRELOAD_DATA: "true" + CIQ_DB_PRELOAD_DATA: "false" volumes: - ./../../db/sql/init.sql:/init.sql:ro,Z - ./../../db/sql/cron.sql:/cron.sql:ro,Z diff --git a/deployments/compose/compose-gh.yaml b/deployments/compose/compose-gh.yaml index 5e1139cd..040a15b1 100644 --- a/deployments/compose/compose-gh.yaml +++ b/deployments/compose/compose-gh.yaml @@ -7,6 +7,9 @@ services: image: quay.io/ecosystem-appeng/cluster-iq-api:latest container_name: api restart: always + depends_on: + init-pgsql: + condition: service_completed_successfully ports: - 8081:8080 environment: @@ -28,6 +31,11 @@ services: POSTGRESQL_PASSWORD: "password" POSTGRESQL_DATABASE: "clusteriq" POSTGRESQL_ADMIN_PASSWORD: "admin" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 networks: - cluster_iq @@ -35,6 +43,9 @@ services: image: quay.io/fedora/postgresql-16:16 container_name: init-pgsql restart: "no" + depends_on: + pgsql: + condition: service_healthy command: | sh -c 'while true; do psql postgresql://user:password@pgsql:5432/clusteriq -c "SELECT true" && break || sleep 2; diff --git a/deployments/containerfiles/Containerfile-scanner b/deployments/containerfiles/Containerfile-scanner index 2511b17c..52bf3f72 100644 --- a/deployments/containerfiles/Containerfile-scanner +++ b/deployments/containerfiles/Containerfile-scanner @@ -7,18 +7,43 @@ FROM golang:1.25.7 AS builder ARG VERSION ARG COMMIT +# Versions for Protobuf and gRPC +ENV PROTOC_VERSION=29.3 +ENV PROTOC_GEN_GO_VERSION=v1.36.2 +ENV PROTOC_GEN_GO_GRPC_VERSION=v1.5.1 + +# Installing ProtoBuf 29.3 +RUN apt-get update && apt-get install -y --no-install-recommends \ + unzip \ + curl && \ + curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip && \ + unzip protoc-${PROTOC_VERSION}-linux-x86_64.zip -d /usr/local && \ + rm -f protoc-${PROTOC_VERSION}-linux-x86_64.zip && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +# Installing protoc-gen-go and protoc-gen-go-grpc +RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@${PROTOC_GEN_GO_VERSION} && \ + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@${PROTOC_GEN_GO_GRPC_VERSION} + +# Adding /go/bin to path so the 'protoc-gen-go' can be found +ENV PATH="${PATH}:/go/bin" + # Code copy WORKDIR /app COPY . . -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o cluster-iq-scanner -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT}" ./cmd/scanner/scanner.go +# gRPC code generation +RUN mkdir -p ./generated && \ + protoc --go_out=./generated --go-grpc_out=./generated ./cmd/scanner/proto/scanner.proto +# Scanner building +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o cluster-iq-scanner -ldflags "-X main.version=${VERSION} -X main.commit=${COMMIT}" ./cmd/scanner ## Run #################### FROM registry.access.redhat.com/ubi8/ubi-micro:8.10-15 # Labels -LABEL version="v0.5" +LABEL version="v0.6" LABEL description="ClusterIQ cloud provider Scanner" # Binary @@ -29,6 +54,9 @@ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certifi # Default config ENV CIQ_API_URL: "http://api:8080/api/v1" ENV CIQ_CREDS_FILE: "/credentials" +ENV CIQ_SCANNER_LISTEN_URL: "0.0.0.0:50052" ENV CIQ_LOG_LEVEL: "DEBUG" +EXPOSE 50052 + ENTRYPOINT ["/cluster-iq-scanner"] diff --git a/deployments/helm/cluster-iq/templates/agent/configmap.yaml b/deployments/helm/cluster-iq/templates/agent/configmap.yaml index 3fd9d454..42e6f87e 100644 --- a/deployments/helm/cluster-iq/templates/agent/configmap.yaml +++ b/deployments/helm/cluster-iq/templates/agent/configmap.yaml @@ -8,6 +8,7 @@ metadata: data: CIQ_AGENT_INSTANT_SERVICE_LISTEN_URL: 0.0.0.0:{{ .Values.agent.service.port }} CIQ_API_URL: api:{{ .Values.api.service.port }} + CIQ_SCANNER_URL: 'scanner.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.scanner.service.port }}' CIQ_CREDS_FILE: /credentials/credentials CIQ_LOG_LEVEL: {{ .Values.agent.logLevel }} CIQ_AGENT_POLLING_SECONDS_INTERVAL: "{{ .Values.agent.pollingInterval }}" diff --git a/deployments/helm/cluster-iq/templates/scanner/configmap.yaml b/deployments/helm/cluster-iq/templates/scanner/configmap.yaml index b666a74a..7c1a97e1 100644 --- a/deployments/helm/cluster-iq/templates/scanner/configmap.yaml +++ b/deployments/helm/cluster-iq/templates/scanner/configmap.yaml @@ -4,9 +4,10 @@ metadata: name: scanner labels: {{- include "cluster-iq.labels" . | nindent 4 }} - {{- include "cluster-iq.componentLabels" "api" | nindent 4 }} + {{- include "cluster-iq.componentLabels" "scanner" | nindent 4 }} data: CIQ_API_URL: 'http://api.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.api.service.port }}/api/v1' + CIQ_SCANNER_LISTEN_URL: '0.0.0.0:{{ .Values.scanner.service.port }}' CIQ_CREDS_FILE: /credentials/credentials CIQ_LOG_LEVEL: {{ .Values.scanner.logLevel }} CIQ_SKIP_NO_OPENSHIFT_INSTANCES: "{{ .Values.scanner.skipNoOpenshiftInstances }}" diff --git a/deployments/helm/cluster-iq/templates/scanner/cronjob.yaml b/deployments/helm/cluster-iq/templates/scanner/cronjob.yaml deleted file mode 100644 index e1d6a635..00000000 --- a/deployments/helm/cluster-iq/templates/scanner/cronjob.yaml +++ /dev/null @@ -1,45 +0,0 @@ -kind: CronJob -apiVersion: batch/v1 -metadata: - name: scanner - labels: - {{- include "cluster-iq.labels" . | nindent 4 }} - {{- include "cluster-iq.componentLabels" "scanner" | nindent 4 }} -spec: - schedule: 0 0 * * * - concurrencyPolicy: Allow - suspend: false - successfulJobsHistoryLimit: 1 - failedJobsHistoryLimit: 5 - jobTemplate: - spec: - template: - spec: - serviceAccountName: {{ include "cluster-iq.scannerServiceAccountName" . }} - volumes: - {{- if .Values.vault.enabled }} - - name: credentials - csi: - driver: secrets-store.csi.k8s.io - readOnly: true - volumeAttributes: - secretProviderClass: "cluster-iq-credentials-scanner" - {{- else }} - - name: credentials - secret: - secretName: credentials - {{- end }} - containers: - - name: scanner - image: "{{ .Values.scanner.image.repository }}:{{ .Values.scanner.image.tag | default .Chart.AppVersion }}" - envFrom: - - configMapRef: - name: scanner - volumeMounts: - - name: credentials - readOnly: true - mountPath: /credentials - imagePullPolicy: IfNotPresent - resources: - {{- toYaml .Values.scanner.resources | nindent 16 }} - restartPolicy: OnFailure diff --git a/deployments/helm/cluster-iq/templates/scanner/deployment.yaml b/deployments/helm/cluster-iq/templates/scanner/deployment.yaml new file mode 100644 index 00000000..0cf7b205 --- /dev/null +++ b/deployments/helm/cluster-iq/templates/scanner/deployment.yaml @@ -0,0 +1,64 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: scanner + labels: + {{- include "cluster-iq.labels" . | nindent 4 }} + {{- include "cluster-iq.componentLabels" "scanner" | nindent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + {{- include "cluster-iq.selectorLabels" . | nindent 6 }} + {{- include "cluster-iq.componentLabels" "scanner" | nindent 6 }} + template: + metadata: + labels: + {{- include "cluster-iq.labels" . | nindent 8 }} + {{- include "cluster-iq.componentLabels" "scanner" | nindent 8 }} + spec: + serviceAccountName: {{ include "cluster-iq.scannerServiceAccountName" . }} + containers: + - name: scanner + image: "{{ .Values.scanner.image.repository }}:{{ .Values.scanner.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.scanner.image.pullPolicy }} + envFrom: + - configMapRef: + name: scanner + ports: + - name: {{ .Values.scanner.service.name }} + containerPort: {{ .Values.scanner.service.port }} + protocol: TCP + startupProbe: + tcpSocket: + port: {{ .Values.scanner.service.port }} + {{- toYaml .Values.scanner.startupProbe | nindent 12 }} + readinessProbe: + tcpSocket: + port: {{ .Values.scanner.service.port }} + {{- toYaml .Values.scanner.readinessProbe | nindent 12 }} + livenessProbe: + tcpSocket: + port: {{ .Values.scanner.service.port }} + {{- toYaml .Values.scanner.livenessProbe | nindent 12 }} + resources: + {{- toYaml .Values.scanner.resources | nindent 12 }} + volumeMounts: + - name: credentials + readOnly: true + mountPath: /credentials + volumes: + {{- if .Values.vault.enabled }} + - name: credentials + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: "cluster-iq-credentials-scanner" + {{- else }} + - name: credentials + secret: + secretName: credentials + optional: false + {{- end }} diff --git a/deployments/helm/cluster-iq/templates/scanner/service.yaml b/deployments/helm/cluster-iq/templates/scanner/service.yaml new file mode 100644 index 00000000..406841d0 --- /dev/null +++ b/deployments/helm/cluster-iq/templates/scanner/service.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: scanner + labels: + {{- include "cluster-iq.labels" . | nindent 4 }} + {{- include "cluster-iq.componentLabels" "scanner" | nindent 4 }} +spec: + type: {{ .Values.scanner.service.type }} + ports: + - port: {{ .Values.scanner.service.port }} + targetPort: {{ .Values.scanner.service.name }} + protocol: TCP + name: {{ .Values.scanner.service.name }} + selector: + {{- include "cluster-iq.selectorLabels" . | nindent 4 }} + {{- include "cluster-iq.componentLabels" "scanner" | nindent 4 }} diff --git a/deployments/helm/cluster-iq/values.yaml b/deployments/helm/cluster-iq/values.yaml index f25825e9..c8e42b39 100644 --- a/deployments/helm/cluster-iq/values.yaml +++ b/deployments/helm/cluster-iq/values.yaml @@ -255,6 +255,11 @@ scanner: # Overrides the image tag whose default is the chart appVersion. tag: "latest" + service: + type: ClusterIP + port: 50052 + name: scanner-grpc + resources: requests: memory: "128Mi" @@ -262,6 +267,26 @@ scanner: skipNoOpenshiftInstances: true + startupProbe: + initialDelaySeconds: 0 + periodSeconds: 1 + timeoutSeconds: 3 + successThreshold: 1 + failureThreshold: 5 + livenessProbe: + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 5 + successThreshold: 1 + failureThreshold: 3 + terminationGracePeriodSeconds: 30 + readinessProbe: + initialDelaySeconds: 5 + periodSeconds: 15 + timeoutSeconds: 3 + successThreshold: 1 + failureThreshold: 3 + #This section builds out the service account serviceAccount: # Specifies whether a service account should be created diff --git a/generated/scanner/scanner.pb.go b/generated/scanner/scanner.pb.go new file mode 100644 index 00000000..44d135d3 --- /dev/null +++ b/generated/scanner/scanner.pb.go @@ -0,0 +1,377 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.31.0 +// protoc v3.19.6 +// source: cmd/scanner/proto/scanner.proto + +package scanner + +import ( + reflect "reflect" + sync "sync" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +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) +) + +// Message for requesting a scan +type ScanRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + RunId int64 `protobuf:"varint,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + AccountIds []string `protobuf:"bytes,2,rep,name=account_ids,json=accountIds,proto3" json:"account_ids,omitempty"` + SelectAll bool `protobuf:"varint,3,opt,name=select_all,json=selectAll,proto3" json:"select_all,omitempty"` +} + +func (x *ScanRequest) Reset() { + *x = ScanRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ScanRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScanRequest) ProtoMessage() {} + +func (x *ScanRequest) ProtoReflect() protoreflect.Message { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScanRequest.ProtoReflect.Descriptor instead. +func (*ScanRequest) Descriptor() ([]byte, []int) { + return file_cmd_scanner_proto_scanner_proto_rawDescGZIP(), []int{0} +} + +func (x *ScanRequest) GetRunId() int64 { + if x != nil { + return x.RunId + } + return 0 +} + +func (x *ScanRequest) GetAccountIds() []string { + if x != nil { + return x.AccountIds + } + return nil +} + +func (x *ScanRequest) GetSelectAll() bool { + if x != nil { + return x.SelectAll + } + return false +} + +// Message for answering to ScanRequests +type ScanResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error int32 `protobuf:"varint,1,opt,name=error,proto3" json:"error,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + AccountsScanned int32 `protobuf:"varint,3,opt,name=accounts_scanned,json=accountsScanned,proto3" json:"accounts_scanned,omitempty"` +} + +func (x *ScanResponse) Reset() { + *x = ScanResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ScanResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScanResponse) ProtoMessage() {} + +func (x *ScanResponse) ProtoReflect() protoreflect.Message { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScanResponse.ProtoReflect.Descriptor instead. +func (*ScanResponse) Descriptor() ([]byte, []int) { + return file_cmd_scanner_proto_scanner_proto_rawDescGZIP(), []int{1} +} + +func (x *ScanResponse) GetError() int32 { + if x != nil { + return x.Error + } + return 0 +} + +func (x *ScanResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ScanResponse) GetAccountsScanned() int32 { + if x != nil { + return x.AccountsScanned + } + return 0 +} + +// Message for health check requests +type HealthRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *HealthRequest) Reset() { + *x = HealthRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *HealthRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthRequest) ProtoMessage() {} + +func (x *HealthRequest) ProtoReflect() protoreflect.Message { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead. +func (*HealthRequest) Descriptor() ([]byte, []int) { + return file_cmd_scanner_proto_scanner_proto_rawDescGZIP(), []int{2} +} + +// Message for health check responses +type HealthResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Ready bool `protobuf:"varint,1,opt,name=ready,proto3" json:"ready,omitempty"` +} + +func (x *HealthResponse) Reset() { + *x = HealthResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *HealthResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthResponse) ProtoMessage() {} + +func (x *HealthResponse) ProtoReflect() protoreflect.Message { + mi := &file_cmd_scanner_proto_scanner_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. +func (*HealthResponse) Descriptor() ([]byte, []int) { + return file_cmd_scanner_proto_scanner_proto_rawDescGZIP(), []int{3} +} + +func (x *HealthResponse) GetReady() bool { + if x != nil { + return x.Ready + } + return false +} + +var File_cmd_scanner_proto_scanner_proto protoreflect.FileDescriptor + +var file_cmd_scanner_proto_scanner_proto_rawDesc = []byte{ + 0x0a, 0x1f, 0x63, 0x6d, 0x64, 0x2f, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2f, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x12, 0x07, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x22, 0x64, 0x0a, 0x0b, 0x53, 0x63, + 0x61, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x15, 0x0a, 0x06, 0x72, 0x75, 0x6e, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x72, 0x75, 0x6e, 0x49, 0x64, + 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x73, 0x18, + 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, + 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x5f, 0x61, 0x6c, 0x6c, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x41, 0x6c, 0x6c, + 0x22, 0x69, 0x0a, 0x0c, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, + 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x12, 0x29, 0x0a, 0x10, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x5f, 0x73, 0x63, 0x61, + 0x6e, 0x6e, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0f, 0x61, 0x63, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x73, 0x53, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x64, 0x22, 0x0f, 0x0a, 0x0d, 0x48, + 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x26, 0x0a, 0x0e, + 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, + 0x0a, 0x05, 0x72, 0x65, 0x61, 0x64, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x72, + 0x65, 0x61, 0x64, 0x79, 0x32, 0x80, 0x01, 0x0a, 0x0e, 0x53, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x33, 0x0a, 0x04, 0x53, 0x63, 0x61, 0x6e, 0x12, + 0x14, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x2e, 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x2e, + 0x53, 0x63, 0x61, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x39, 0x0a, 0x06, + 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x12, 0x16, 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, + 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, + 0x2e, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0b, 0x5a, 0x09, 0x2e, 0x2f, 0x73, 0x63, 0x61, + 0x6e, 0x6e, 0x65, 0x72, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_cmd_scanner_proto_scanner_proto_rawDescOnce sync.Once + file_cmd_scanner_proto_scanner_proto_rawDescData = file_cmd_scanner_proto_scanner_proto_rawDesc +) + +func file_cmd_scanner_proto_scanner_proto_rawDescGZIP() []byte { + file_cmd_scanner_proto_scanner_proto_rawDescOnce.Do(func() { + file_cmd_scanner_proto_scanner_proto_rawDescData = protoimpl.X.CompressGZIP(file_cmd_scanner_proto_scanner_proto_rawDescData) + }) + return file_cmd_scanner_proto_scanner_proto_rawDescData +} + +var file_cmd_scanner_proto_scanner_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_cmd_scanner_proto_scanner_proto_goTypes = []interface{}{ + (*ScanRequest)(nil), // 0: scanner.ScanRequest + (*ScanResponse)(nil), // 1: scanner.ScanResponse + (*HealthRequest)(nil), // 2: scanner.HealthRequest + (*HealthResponse)(nil), // 3: scanner.HealthResponse +} +var file_cmd_scanner_proto_scanner_proto_depIdxs = []int32{ + 0, // 0: scanner.ScannerService.Scan:input_type -> scanner.ScanRequest + 2, // 1: scanner.ScannerService.Health:input_type -> scanner.HealthRequest + 1, // 2: scanner.ScannerService.Scan:output_type -> scanner.ScanResponse + 3, // 3: scanner.ScannerService.Health:output_type -> scanner.HealthResponse + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_cmd_scanner_proto_scanner_proto_init() } +func file_cmd_scanner_proto_scanner_proto_init() { + if File_cmd_scanner_proto_scanner_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_cmd_scanner_proto_scanner_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ScanRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_cmd_scanner_proto_scanner_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ScanResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_cmd_scanner_proto_scanner_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*HealthRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_cmd_scanner_proto_scanner_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*HealthResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_cmd_scanner_proto_scanner_proto_rawDesc, + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_cmd_scanner_proto_scanner_proto_goTypes, + DependencyIndexes: file_cmd_scanner_proto_scanner_proto_depIdxs, + MessageInfos: file_cmd_scanner_proto_scanner_proto_msgTypes, + }.Build() + File_cmd_scanner_proto_scanner_proto = out.File + file_cmd_scanner_proto_scanner_proto_rawDesc = nil + file_cmd_scanner_proto_scanner_proto_goTypes = nil + file_cmd_scanner_proto_scanner_proto_depIdxs = nil +} diff --git a/generated/scanner/scanner_grpc.pb.go b/generated/scanner/scanner_grpc.pb.go new file mode 100644 index 00000000..26e68926 --- /dev/null +++ b/generated/scanner/scanner_grpc.pb.go @@ -0,0 +1,149 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc v3.19.6 +// source: cmd/scanner/proto/scanner.proto + +package scanner + +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.62.0 or later. +const _ = grpc.SupportPackageIsVersion8 + +const ( + ScannerService_Scan_FullMethodName = "/scanner.ScannerService/Scan" + ScannerService_Health_FullMethodName = "/scanner.ScannerService/Health" +) + +// ScannerServiceClient is the client API for ScannerService 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 ScannerServiceClient interface { + Scan(ctx context.Context, in *ScanRequest, opts ...grpc.CallOption) (*ScanResponse, error) + Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) +} + +type scannerServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewScannerServiceClient(cc grpc.ClientConnInterface) ScannerServiceClient { + return &scannerServiceClient{cc} +} + +func (c *scannerServiceClient) Scan(ctx context.Context, in *ScanRequest, opts ...grpc.CallOption) (*ScanResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ScanResponse) + err := c.cc.Invoke(ctx, ScannerService_Scan_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *scannerServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HealthResponse) + err := c.cc.Invoke(ctx, ScannerService_Health_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ScannerServiceServer is the server API for ScannerService service. +// All implementations must embed UnimplementedScannerServiceServer +// for forward compatibility +type ScannerServiceServer interface { + Scan(context.Context, *ScanRequest) (*ScanResponse, error) + Health(context.Context, *HealthRequest) (*HealthResponse, error) + mustEmbedUnimplementedScannerServiceServer() +} + +// UnimplementedScannerServiceServer must be embedded to have forward compatible implementations. +type UnimplementedScannerServiceServer struct { +} + +func (UnimplementedScannerServiceServer) Scan(context.Context, *ScanRequest) (*ScanResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Scan not implemented") +} +func (UnimplementedScannerServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Health not implemented") +} +func (UnimplementedScannerServiceServer) mustEmbedUnimplementedScannerServiceServer() {} + +// UnsafeScannerServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ScannerServiceServer will +// result in compilation errors. +type UnsafeScannerServiceServer interface { + mustEmbedUnimplementedScannerServiceServer() +} + +func RegisterScannerServiceServer(s grpc.ServiceRegistrar, srv ScannerServiceServer) { + s.RegisterService(&ScannerService_ServiceDesc, srv) +} + +func _ScannerService_Scan_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ScanRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ScannerServiceServer).Scan(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ScannerService_Scan_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ScannerServiceServer).Scan(ctx, req.(*ScanRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ScannerService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HealthRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ScannerServiceServer).Health(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ScannerService_Health_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ScannerServiceServer).Health(ctx, req.(*HealthRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ScannerService_ServiceDesc is the grpc.ServiceDesc for ScannerService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ScannerService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "scanner.ScannerService", + HandlerType: (*ScannerServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Scan", + Handler: _ScannerService_Scan_Handler, + }, + { + MethodName: "Health", + Handler: _ScannerService_Health_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "cmd/scanner/proto/scanner.proto", +} diff --git a/internal/actions/action_operation.go b/internal/actions/action_operation.go index 83b6a8f3..d14afb80 100644 --- a/internal/actions/action_operation.go +++ b/internal/actions/action_operation.go @@ -10,6 +10,9 @@ const ( // PowerOffCluster represents an action to power off a cluster. PowerOff ActionOperation = "PowerOff" + + // Scan represents an action to scan cloud accounts for resource discovery. + Scan ActionOperation = "Scan" ) func NewPowerOnClusterAction(target ActionTarget, requester string, description *string) *InstantAction { diff --git a/internal/actions/action_target.go b/internal/actions/action_target.go index 74034491..2dbb699a 100644 --- a/internal/actions/action_target.go +++ b/internal/actions/action_target.go @@ -14,6 +14,15 @@ type ActionTarget struct { // Instances is a list of instance IDs associated with the target cluster. Instances []string `db:"instances"` + + // TargetType indicates the resource type being targeted (Account, Cluster, Instance). + TargetType string `db:"target_type"` + + // SelectAll when true, targets all resources of the given TargetType. + SelectAll bool `db:"select_all"` + + // TargetAccountIDs lists account IDs for scan-type actions. + TargetAccountIDs []string `db:"target_account_ids"` } // NewActionTarget creates and returns a new instance of ActionTarget. diff --git a/internal/api/handlers/account_handler.go b/internal/api/handlers/account_handler.go index bdccb103..63ed6c71 100644 --- a/internal/api/handlers/account_handler.go +++ b/internal/api/handlers/account_handler.go @@ -176,12 +176,10 @@ func (h *AccountHandler) GetAccountClustersByID(c *gin.Context) { // @Accept json // @Produce json // @Param id path string true "Account ID" -// @Success 200 {object} dto.InstanceListResponse +// @Success 200 {object} responsetypes.ListResponse[string] // @Failure 404 {object} responsetypes.GenericErrorResponse // @Failure 500 {object} responsetypes.GenericErrorResponse // @Router /accounts/{id}/expense_update [get] -// -// NOTE: Align the documented route with the actual router configuration. func (h *AccountHandler) GetExpensesUpdateInstances(c *gin.Context) { accountID := c.Param("id") @@ -201,8 +199,12 @@ func (h *AccountHandler) GetExpensesUpdateInstances(c *gin.Context) { return } - response := responsetypes.NewListResponse((&convert.ConverterImpl{}).ToInstanceDTOs(instances), len(instances)) + instanceIDs := make([]string, len(instances)) + for i, inst := range instances { + instanceIDs[i] = inst.InstanceID + } + response := responsetypes.NewListResponse(instanceIDs, len(instanceIDs)) c.JSON(http.StatusOK, response) } diff --git a/internal/api/handlers/action_run_handler.go b/internal/api/handlers/action_run_handler.go new file mode 100644 index 00000000..6340f6b1 --- /dev/null +++ b/internal/api/handlers/action_run_handler.go @@ -0,0 +1,217 @@ +package handlers + +import ( + "errors" + "net/http" + "strconv" + + responsetypes "github.com/RHEcosystemAppEng/cluster-iq/internal/api/response_types" + "github.com/RHEcosystemAppEng/cluster-iq/internal/models" + "github.com/RHEcosystemAppEng/cluster-iq/internal/models/convert" + "github.com/RHEcosystemAppEng/cluster-iq/internal/models/dto" + "github.com/RHEcosystemAppEng/cluster-iq/internal/repositories" + "github.com/RHEcosystemAppEng/cluster-iq/internal/services" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// ActionRunHandler wires HTTP endpoints to the ActionRunService. +type ActionRunHandler struct { + service services.ActionRunService + logger *zap.Logger +} + +// NewActionRunHandler returns an ActionRunHandler with its dependencies. +func NewActionRunHandler(service services.ActionRunService, logger *zap.Logger) *ActionRunHandler { + return &ActionRunHandler{ + service: service, + logger: logger, + } +} + +type actionRunFilterParams struct { + ScheduleID string `form:"schedule_id"` + Status string `form:"status"` +} + +func (f *actionRunFilterParams) toRepoFilters() map[string]interface{} { + filters := make(map[string]interface{}) + if f.ScheduleID != "" { + filters["schedule_id"] = f.ScheduleID + } + if f.Status != "" { + filters["status"] = f.Status + } + return filters +} + +type listActionRunsRequest struct { + dto.PaginationRequest + Filters actionRunFilterParams `form:"inline"` +} + +// List returns a paginated list of action runs. +// +// @Summary List action runs +// @Description Paginated retrieval of action execution history. +// @Tags ActionRuns +// @Accept json +// @Produce json +// @Param schedule_id query string false "Filter by schedule ID" +// @Param status query string false "Filter by status" +// @Param page query int false "Page number" default(1) +// @Param page_size query int false "Items per page" default(10) +// @Success 200 {object} dto.ActionRunListResponse +// @Failure 400 {object} responsetypes.GenericErrorResponse +// @Failure 500 {object} responsetypes.GenericErrorResponse +// @Router /action-runs [get] +func (h *ActionRunHandler) List(c *gin.Context) { + var req listActionRunsRequest + + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, responsetypes.GenericErrorResponse{ + Message: "Invalid query parameters: " + err.Error(), + }) + return + } + + opts := models.ListOptions{ + PageSize: req.PageSize, + Offset: (req.Page - 1) * req.PageSize, + Filters: req.Filters.toRepoFilters(), + } + + runs, total, err := h.service.List(c.Request.Context(), opts) + if err != nil { + h.logger.Error("error listing action runs", zap.Error(err)) + c.JSON(http.StatusInternalServerError, responsetypes.GenericErrorResponse{ + Message: "Failed to list action runs", + }) + return + } + + response := responsetypes.NewListResponse((&convert.ConverterImpl{}).ToActionRunDTOs(runs), total) + + c.Header("X-Total-Count", strconv.Itoa(total)) + c.JSON(http.StatusOK, response) +} + +// Get returns an action run by ID. +// +// @Summary Get action run by ID +// @Description Return an action run record. +// @Tags ActionRuns +// @Accept json +// @Produce json +// @Param id path string true "Action run ID" +// @Success 200 {object} dto.ActionRunDTOResponse +// @Failure 404 {object} responsetypes.GenericErrorResponse +// @Failure 500 {object} responsetypes.GenericErrorResponse +// @Router /action-runs/{id} [get] +func (h *ActionRunHandler) Get(c *gin.Context) { + runID := c.Param("id") + + run, err := h.service.Get(c.Request.Context(), runID) + if err != nil { + h.logger.Error("error getting action run", zap.String("run_id", runID), zap.Error(err)) + if errors.Is(err, repositories.ErrNotFound) { + c.JSON(http.StatusNotFound, responsetypes.GenericErrorResponse{ + Message: "Action run not found", + }) + return + } + c.JSON(http.StatusInternalServerError, responsetypes.GenericErrorResponse{ + Message: "Failed to retrieve action run", + }) + return + } + + c.JSON(http.StatusOK, (&convert.ConverterImpl{}).ToActionRunDTO(run)) +} + +// Create creates a new action run. +// +// @Summary Create action run +// @Description Create a new execution record for a scheduled action. +// @Tags ActionRuns +// @Accept json +// @Produce json +// @Param run body dto.ActionRunDTORequest true "Action run to create" +// @Success 201 {object} responsetypes.PostResponse +// @Failure 400 {object} responsetypes.GenericErrorResponse +// @Failure 500 {object} responsetypes.GenericErrorResponse +// @Router /action-runs [post] +func (h *ActionRunHandler) Create(c *gin.Context) { + var req dto.ActionRunDTORequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, responsetypes.GenericErrorResponse{ + Message: "Invalid request body: " + err.Error(), + }) + return + } + + id, err := h.service.Create(c.Request.Context(), req.ScheduleID) + if err != nil { + h.logger.Error("error creating action run", zap.Error(err)) + c.JSON(http.StatusInternalServerError, responsetypes.GenericErrorResponse{ + Message: "Failed to create action run: " + err.Error(), + }) + return + } + + c.JSON(http.StatusCreated, responsetypes.PostResponse{ + Count: 1, + Status: strconv.FormatInt(id, 10), + }) +} + +// Update updates an action run's status. +// +// @Summary Update action run +// @Description Update the status of an action run (called by scanner on completion). +// @Tags ActionRuns +// @Accept json +// @Produce json +// @Param id path string true "Action run ID" +// @Param run body dto.ActionRunDTORequest true "Updated status" +// @Success 200 {object} nil +// @Failure 400 {object} responsetypes.GenericErrorResponse +// @Failure 404 {object} responsetypes.GenericErrorResponse +// @Failure 500 {object} responsetypes.GenericErrorResponse +// @Router /action-runs/{id} [patch] +func (h *ActionRunHandler) Update(c *gin.Context) { + runID := c.Param("id") + + var req dto.ActionRunDTORequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, responsetypes.GenericErrorResponse{ + Message: "Invalid request body: " + err.Error(), + }) + return + } + + if _, err := h.service.Get(c.Request.Context(), runID); err != nil { + h.logger.Error("error updating action run", zap.String("run_id", runID), zap.Error(err)) + if errors.Is(err, repositories.ErrNotFound) { + c.JSON(http.StatusNotFound, responsetypes.GenericErrorResponse{ + Message: "Action run not found", + }) + return + } + c.JSON(http.StatusInternalServerError, responsetypes.GenericErrorResponse{ + Message: "Failed to update action run", + }) + return + } + + if err := h.service.Update(c.Request.Context(), runID, req.Status, req.ErrorMsg); err != nil { + h.logger.Error("error updating action run", zap.String("run_id", runID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, responsetypes.GenericErrorResponse{ + Message: "Failed to update action run: " + err.Error(), + }) + return + } + + c.Status(http.StatusOK) +} diff --git a/internal/api/handlers/event_handler.go b/internal/api/handlers/event_handler.go index a964b4c3..1a24cd35 100644 --- a/internal/api/handlers/event_handler.go +++ b/internal/api/handlers/event_handler.go @@ -29,7 +29,7 @@ func NewEventHandler(service services.EventService, logger *zap.Logger) *EventHa // systemEventFilterParams defines the supported filter parameters type systemEventFilterParams struct { - TriggeredBy string `form:"triggered_by"` + Requester string `form:"requester"` ActionName string `form:"action"` ResourceType string `form:"resource_type"` Result string `form:"result"` @@ -39,8 +39,8 @@ type systemEventFilterParams struct { // toRepoFilters maps bound query params to repository filters. func (f *systemEventFilterParams) toRepoFilters() map[string]interface{} { filters := make(map[string]interface{}) - if f.TriggeredBy != "" { - filters["triggered_by"] = f.TriggeredBy + if f.Requester != "" { + filters["requester"] = f.Requester } if f.ActionName != "" { filters["action"] = f.ActionName @@ -71,7 +71,7 @@ type listSystemEventsRequest struct { // @Produce json // @Param page query int false "Page number" default(1) // @Param page_size query int false "Items per page" default(10) -// @Param triggered_by query string false "Triggered by" +// @Param requester query string false "Requester" // @Param action query string false "Action" // @Param resource_type query string false "Resource type" // @Param result query string false "Result" diff --git a/internal/clients/agent.go b/internal/clients/agent.go index f0d78593..8233fff8 100644 --- a/internal/clients/agent.go +++ b/internal/clients/agent.go @@ -64,6 +64,8 @@ func (a APIGRPCClient) ProcessInstantAction(ctx context.Context, action *actions return a.PowerOffCluster(ctx, action) case actions.PowerOn: return a.PowerOnCluster(ctx, action) + case actions.Scan: + return fmt.Errorf("scan operations are handled by the scanner service, not the agent") default: return fmt.Errorf("received InstantAction with unknown Operation") } diff --git a/internal/clients/scanner.go b/internal/clients/scanner.go new file mode 100644 index 00000000..56f8e244 --- /dev/null +++ b/internal/clients/scanner.go @@ -0,0 +1,64 @@ +package clients + +import ( + "context" + + pb "github.com/RHEcosystemAppEng/cluster-iq/generated/scanner" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// ScannerGRPCClient manages the gRPC client connection to the Scanner service. +type ScannerGRPCClient struct { + Client pb.ScannerServiceClient + conn *grpc.ClientConn + logger *zap.Logger +} + +// NewScannerGRPCClient initializes and returns a new ScannerGRPCClient. +func NewScannerGRPCClient(scannerURL string, logger *zap.Logger) (*ScannerGRPCClient, error) { + conn, err := grpc.NewClient(scannerURL, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, err + } + + return &ScannerGRPCClient{ + Client: pb.NewScannerServiceClient(conn), + conn: conn, + logger: logger, + }, nil +} + +// Close closes the underlying gRPC connection. +func (s *ScannerGRPCClient) Close() error { + return s.conn.Close() +} + +// Scan sends a scan request to the Scanner service. +func (s *ScannerGRPCClient) Scan(ctx context.Context, runID int64, accountIDs []string, selectAll bool) (*pb.ScanResponse, error) { + req := &pb.ScanRequest{ + RunId: runID, + AccountIds: accountIDs, + SelectAll: selectAll, + } + + s.logger.Info("Sending scan request to Scanner", + zap.Int64("run_id", runID), + zap.Strings("account_ids", accountIDs), + zap.Bool("select_all", selectAll), + ) + + resp, err := s.Client.Scan(ctx, req) + if err != nil { + return nil, err + } + + s.logger.Info("Scan response received", + zap.Int32("error_code", resp.Error), + zap.String("message", resp.Message), + zap.Int32("accounts_scanned", resp.AccountsScanned), + ) + + return resp, nil +} diff --git a/internal/cloud_executors/aws.go b/internal/cloud_executors/aws.go index f8dcf8a9..70270e40 100644 --- a/internal/cloud_executors/aws.go +++ b/internal/cloud_executors/aws.go @@ -58,6 +58,9 @@ func (e *AWSExecutor) ProcessAction(action actions.Action) error { case actions.PowerOff: return e.PowerOffCluster(target.GetInstances()) + case actions.Scan: + return fmt.Errorf("scan operations are handled by the scanner service, not the cloud executor") + default: // No registered ActionOperation return fmt.Errorf("cannot identify ActionOperation while processing an Action") } diff --git a/internal/config/agent_config.go b/internal/config/agent_config.go index 1c0bcb63..550fc27c 100644 --- a/internal/config/agent_config.go +++ b/internal/config/agent_config.go @@ -10,6 +10,8 @@ type ExecutorAgentServiceConfig struct { // APIURL refers to the ClusterIQ API Endpoint APIURL string `env:"CIQ_API_URL,required"` DBURL string `env:"CIQ_DB_URL,required"` + // ScannerURL refers to the Scanner gRPC endpoint + ScannerURL string `env:"CIQ_SCANNER_URL" envDefault:"scanner:50052"` // Credentials for accessing the cloud providers accounts Credentials CloudCredentialsConfig } diff --git a/internal/config/scanner_config.go b/internal/config/scanner_config.go index 97cc8143..ddc99aa5 100644 --- a/internal/config/scanner_config.go +++ b/internal/config/scanner_config.go @@ -6,6 +6,7 @@ import env "github.com/caarlos0/env/v11" type ScannerConfig struct { CloudCredentialsConfig APIURL string `env:"CIQ_API_URL,required"` + ListenURL string `env:"CIQ_SCANNER_LISTEN_URL" envDefault:"0.0.0.0:50052"` SkipNoOpenShiftInstances bool `env:"CIQ_SKIP_NO_OPENSHIFT_INSTANCES" envDefault:"true"` } diff --git a/internal/events/event_service/event_service.go b/internal/events/event_service/event_service.go index c9a04227..093c8eb6 100644 --- a/internal/events/event_service/event_service.go +++ b/internal/events/event_service/event_service.go @@ -32,7 +32,8 @@ type EventOptions struct { ResourceType string Result string Severity string - TriggeredBy string + Requester string + ScheduleID *int64 } // EventService to write events from every clusteriq component @@ -52,7 +53,7 @@ func NewEventService(dbClient *dbclient.DBClient, logger *zap.Logger) *EventServ // LogEvent creates a new events log entry and returns its ID. func (e *EventService) LogEvent(ctx context.Context, opts EventOptions) (int64, error) { event := events.Event{ - TriggeredBy: opts.TriggeredBy, + Requester: opts.Requester, Action: opts.Action, ResourceID: opts.ResourceID, ResourceType: opts.ResourceType, @@ -60,6 +61,7 @@ func (e *EventService) LogEvent(ctx context.Context, opts EventOptions) (int64, Description: opts.Description, Severity: opts.Severity, EventTimestamp: time.Now().UTC(), + ScheduleID: opts.ScheduleID, } eventID, err := e.repo.CreateEvent(ctx, event) e.logger.Debug("Tracking new event", zap.Int64("event_id", eventID)) diff --git a/internal/events/event_service/event_service_test.go b/internal/events/event_service/event_service_test.go index 5ae45fb9..4e330680 100644 --- a/internal/events/event_service/event_service_test.go +++ b/internal/events/event_service/event_service_test.go @@ -93,7 +93,7 @@ func TestLogEvent(t *testing.T) { func testLogEvent_Success(t *testing.T) { desc := "something happened" opts := EventOptions{ - TriggeredBy: "scanner", + Requester: "scanner", Action: actions.ActionOperation("START"), ResourceID: "cluster-1", ResourceType: inventory.ClusterResourceType, @@ -121,7 +121,7 @@ func testLogEvent_Success(t *testing.T) { assert.NotNil(t, repo.lastCreateEventCtx) ev := repo.lastCreateEventEvent - assert.Equal(t, opts.TriggeredBy, ev.TriggeredBy) + assert.Equal(t, opts.Requester, ev.Requester) assert.Equal(t, opts.Action, ev.Action) assert.Equal(t, opts.ResourceID, ev.ResourceID) assert.Equal(t, opts.ResourceType, ev.ResourceType) @@ -136,7 +136,7 @@ func testLogEvent_Success(t *testing.T) { func testLogEvent_RepoError(t *testing.T) { opts := EventOptions{ - TriggeredBy: "api", + Requester: "api", Action: actions.ActionOperation("STOP"), ResourceID: "cluster-1", ResourceType: inventory.ClusterResourceType, @@ -208,7 +208,7 @@ func TestStartTracking(t *testing.T) { func testStartTracking_Success(t *testing.T) { opts := &EventOptions{ - TriggeredBy: "agent", + Requester: "agent", Action: actions.ActionOperation("START"), ResourceID: "cluster-1", ResourceType: inventory.ClusterResourceType, @@ -234,7 +234,7 @@ func testStartTracking_Success(t *testing.T) { func testStartTracking_LogEventError(t *testing.T) { opts := &EventOptions{ - TriggeredBy: "agent", + Requester: "agent", Action: actions.ActionOperation("STOP"), ResourceID: "cluster-1", ResourceType: inventory.ClusterResourceType, diff --git a/internal/events/events.go b/internal/events/events.go index f793121c..676b3c13 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -25,5 +25,7 @@ type Event struct { // Log severity level (e.g., "info", "warning", "error"). Severity string `db:"severity"` // User or system entity responsible for the action. - TriggeredBy string `db:"triggered_by"` + Requester string `db:"requester"` + // ID of the scheduled action that generated this event. + ScheduleID *int64 `db:"schedule_id"` } diff --git a/internal/inventory/types.go b/internal/inventory/types.go index 2945ecbc..952502ee 100644 --- a/internal/inventory/types.go +++ b/internal/inventory/types.go @@ -9,6 +9,7 @@ const ( ClusterPowerOffAction = "PowerOff" // Resource types + AccountResourceType = "Account" ClusterResourceType = "Cluster" InstanceResourceType = "Instance" ) diff --git a/internal/models/convert/generated.go b/internal/models/convert/generated.go index 1384b4bf..f43a36c3 100644 --- a/internal/models/convert/generated.go +++ b/internal/models/convert/generated.go @@ -45,10 +45,17 @@ func (c *ConverterImpl) ToActionDTO(source db.ActionDBResponse) dto.ActionDTORes dtoActionDTOResponse.Operation = source.Operation dtoActionDTOResponse.Status = source.Status dtoActionDTOResponse.Enabled = source.Enabled - dtoActionDTOResponse.ClusterID = source.ClusterID - dtoActionDTOResponse.Region = source.Region + dtoActionDTOResponse.TargetType = source.TargetType + dtoActionDTOResponse.SelectAll = source.SelectAll + dtoActionDTOResponse.ClusterID = NullString(source.ClusterID) + dtoActionDTOResponse.ClusterName = NullString(source.ClusterName) + dtoActionDTOResponse.Region = NullString(source.Region) dtoActionDTOResponse.AccountID = source.AccountID dtoActionDTOResponse.Instances = StringArray(source.Instances) + dtoActionDTOResponse.TargetAccountIDs = StringArray(source.TargetAccountIDs) + dtoActionDTOResponse.TargetAccountNames = StringArray(source.TargetAccountNames) + dtoActionDTOResponse.Requester = NullString(source.Requester) + dtoActionDTOResponse.Description = NullStringPtr(source.Description) return dtoActionDTOResponse } func (c *ConverterImpl) ToActionDTOs(source []db.ActionDBResponse) []dto.ActionDTOResponse { @@ -61,6 +68,26 @@ func (c *ConverterImpl) ToActionDTOs(source []db.ActionDBResponse) []dto.ActionD } return dtoActionDTOResponseList } +func (c *ConverterImpl) ToActionRunDTO(source db.ActionRunDBResponse) dto.ActionRunDTOResponse { + var dtoActionRunDTOResponse dto.ActionRunDTOResponse + dtoActionRunDTOResponse.ID = source.ID + dtoActionRunDTOResponse.ScheduleID = source.ScheduleID + dtoActionRunDTOResponse.StartedAt = NullTime(source.StartedAt) + dtoActionRunDTOResponse.FinishedAt = NullTime(source.FinishedAt) + dtoActionRunDTOResponse.Status = source.Status + dtoActionRunDTOResponse.ErrorMsg = NullString(source.ErrorMsg) + return dtoActionRunDTOResponse +} +func (c *ConverterImpl) ToActionRunDTOs(source []db.ActionRunDBResponse) []dto.ActionRunDTOResponse { + var dtoActionRunDTOResponseList []dto.ActionRunDTOResponse + if source != nil { + dtoActionRunDTOResponseList = make([]dto.ActionRunDTOResponse, len(source)) + for i := 0; i < len(source); i++ { + dtoActionRunDTOResponseList[i] = c.ToActionRunDTO(source[i]) + } + } + return dtoActionRunDTOResponseList +} func (c *ConverterImpl) ToClusterDTO(source db.ClusterDBResponse) dto.ClusterDTOResponse { var dtoClusterDTOResponse dto.ClusterDTOResponse dtoClusterDTOResponse.ClusterID = source.ClusterID @@ -105,11 +132,15 @@ func (c *ConverterImpl) ToClusterEventDTO(source db.ClusterEventDBResponse) dto. dtoClusterEventDTOResponse.EventTimestamp = Time(source.EventTimestamp) dtoClusterEventDTOResponse.Result = actions.ActionStatus(source.Result) dtoClusterEventDTOResponse.Severity = source.Severity - dtoClusterEventDTOResponse.TriggeredBy = source.TriggeredBy + dtoClusterEventDTOResponse.Requester = source.Requester if source.Description != nil { xstring2 := *source.Description dtoClusterEventDTOResponse.Description = &xstring2 } + if source.ScheduleID != nil { + xint64 := *source.ScheduleID + dtoClusterEventDTOResponse.ScheduleID = &xint64 + } return dtoClusterEventDTOResponse } func (c *ConverterImpl) ToClusterEventDTOs(source []db.ClusterEventDBResponse) []dto.ClusterEventDTOResponse { @@ -172,8 +203,10 @@ func (c *ConverterImpl) ToInstanceDTOs(source []db.InstanceDBResponse) []dto.Ins func (c *ConverterImpl) ToSystemEventDTO(source db.SystemEventDBResponse) dto.SystemEventDTOResponse { var dtoSystemEventDTOResponse dto.SystemEventDTOResponse dtoSystemEventDTOResponse.ClusterEventDTOResponse = c.ToClusterEventDTO(source.ClusterEventDBResponse) - dtoSystemEventDTOResponse.AccountID = source.AccountID - dtoSystemEventDTOResponse.Provider = source.Provider + dtoSystemEventDTOResponse.ResourceName = NullString(source.ResourceName) + dtoSystemEventDTOResponse.AccountID = NullString(source.AccountID) + dtoSystemEventDTOResponse.AccountName = NullString(source.AccountName) + dtoSystemEventDTOResponse.Provider = NullString(source.Provider) return dtoSystemEventDTOResponse } func (c *ConverterImpl) ToSystemEventDTOs(source []db.SystemEventDBResponse) []dto.SystemEventDTOResponse { diff --git a/internal/models/convert/mapper.go b/internal/models/convert/mapper.go index 975953a6..2c07b64f 100644 --- a/internal/models/convert/mapper.go +++ b/internal/models/convert/mapper.go @@ -16,6 +16,7 @@ import ( // goverter:extend Time // goverter:extend NullTime // goverter:extend NullString +// goverter:extend NullStringPtr // goverter:extend StringArray // goverter:extend TagDBResponsesToDTO type Converter interface { @@ -45,10 +46,13 @@ type Converter interface { ToSystemEventDTOs(src []db.SystemEventDBResponse) []dto.SystemEventDTOResponse // Action - // goverter:ignore Requester Description ToActionDTO(src db.ActionDBResponse) dto.ActionDTOResponse ToActionDTOs(src []db.ActionDBResponse) []dto.ActionDTOResponse + // ActionRun + ToActionRunDTO(src db.ActionRunDBResponse) dto.ActionRunDTOResponse + ToActionRunDTOs(src []db.ActionRunDBResponse) []dto.ActionRunDTOResponse + // Instance ToInstanceDTO(src db.InstanceDBResponse) dto.InstanceDTOResponse ToInstanceDTOs(src []db.InstanceDBResponse) []dto.InstanceDTOResponse @@ -75,6 +79,14 @@ func NullString(s sql.NullString) string { return "" } +// NullStringPtr handles sql.NullString to *string conversion +func NullStringPtr(s sql.NullString) *string { + if s.Valid { + return &s.String + } + return nil +} + // StringArray handles pq.StringArray to []string conversion func StringArray(arr pq.StringArray) []string { return arr diff --git a/internal/models/db/action.go b/internal/models/db/action.go index a2a31000..6e5f1634 100644 --- a/internal/models/db/action.go +++ b/internal/models/db/action.go @@ -9,15 +9,22 @@ import ( // ActionDBResponse represents the database schema for action details, // linking each field to a corresponding column in the database. type ActionDBResponse struct { - ID string `db:"id"` - Type string `db:"type"` - Time sql.NullTime `db:"time"` - CronExp sql.NullString `db:"cron_exp"` - Operation string `db:"operation"` - Status string `db:"status"` - Enabled bool `db:"enabled"` - ClusterID string `db:"cluster_id"` - Region string `db:"region"` - AccountID string `db:"account_id"` - Instances pq.StringArray `db:"instances"` + ID string `db:"id"` + Type string `db:"type"` + Time sql.NullTime `db:"time"` + CronExp sql.NullString `db:"cron_exp"` + Operation string `db:"operation"` + Status string `db:"status"` + Enabled bool `db:"enabled"` + TargetType string `db:"target_type"` + SelectAll bool `db:"select_all"` + ClusterID sql.NullString `db:"cluster_id"` + ClusterName sql.NullString `db:"cluster_name"` + Region sql.NullString `db:"region"` + AccountID string `db:"account_id"` + Instances pq.StringArray `db:"instances"` + TargetAccountIDs pq.StringArray `db:"target_account_ids"` + TargetAccountNames pq.StringArray `db:"target_account_names"` + Requester sql.NullString `db:"requester"` + Description sql.NullString `db:"description"` } diff --git a/internal/models/db/action_run.go b/internal/models/db/action_run.go new file mode 100644 index 00000000..d66a5f6f --- /dev/null +++ b/internal/models/db/action_run.go @@ -0,0 +1,13 @@ +package db + +import "database/sql" + +// ActionRunDBResponse represents the database schema for action execution history. +type ActionRunDBResponse struct { + ID string `db:"id"` + ScheduleID string `db:"schedule_id"` + StartedAt sql.NullTime `db:"started_at"` + FinishedAt sql.NullTime `db:"finished_at"` + Status string `db:"status"` + ErrorMsg sql.NullString `db:"error_msg"` +} diff --git a/internal/models/db/action_test.go b/internal/models/db/action_test.go index 723a9cb7..2ef561b0 100644 --- a/internal/models/db/action_test.go +++ b/internal/models/db/action_test.go @@ -29,8 +29,8 @@ func testActionDBResponse_ToActionDTOResponse_WithValidFields(t *testing.T) { Operation: "START", Status: "Pending", Enabled: true, - ClusterID: "cluster-1", - Region: "eu-west-1", + ClusterID: sql.NullString{String: "cluster-1", Valid: true}, + Region: sql.NullString{String: "eu-west-1", Valid: true}, AccountID: "acc-1", Instances: pq.StringArray{"i-1", "i-2"}, } @@ -44,8 +44,8 @@ func testActionDBResponse_ToActionDTOResponse_WithValidFields(t *testing.T) { assert.Equal(t, model.Operation, dto.Operation) assert.Equal(t, model.Status, dto.Status) assert.Equal(t, model.Enabled, dto.Enabled) - assert.Equal(t, model.ClusterID, dto.ClusterID) - assert.Equal(t, model.Region, dto.Region) + assert.Equal(t, model.ClusterID.String, dto.ClusterID) + assert.Equal(t, model.Region.String, dto.Region) assert.Equal(t, model.AccountID, dto.AccountID) assert.Equal(t, []string{"i-1", "i-2"}, dto.Instances) } @@ -61,8 +61,8 @@ func testActionDBResponse_ToActionDTOResponse_WithInvalidFields(t *testing.T) { Operation: "STOP", Status: "Failed", Enabled: false, - ClusterID: "cluster-2", - Region: "us-east-1", + ClusterID: sql.NullString{String: "cluster-2", Valid: true}, + Region: sql.NullString{String: "us-east-1", Valid: true}, AccountID: "acc-2", Instances: pq.StringArray{"i-9"}, } @@ -79,8 +79,8 @@ func testActionDBResponse_ToActionDTOResponse_WithInvalidFields(t *testing.T) { assert.Equal(t, model.Operation, dto.Operation) assert.Equal(t, model.Status, dto.Status) assert.Equal(t, model.Enabled, dto.Enabled) - assert.Equal(t, model.ClusterID, dto.ClusterID) - assert.Equal(t, model.Region, dto.Region) + assert.Equal(t, model.ClusterID.String, dto.ClusterID) + assert.Equal(t, model.Region.String, dto.Region) assert.Equal(t, model.AccountID, dto.AccountID) assert.Equal(t, []string{"i-9"}, dto.Instances) } @@ -103,8 +103,8 @@ func testToActionDTOResponseList_Correct(t *testing.T) { Operation: "START", Status: "Pending", Enabled: true, - ClusterID: "cluster-1", - Region: "eu-west-1", + ClusterID: sql.NullString{String: "cluster-1", Valid: true}, + Region: sql.NullString{String: "eu-west-1", Valid: true}, AccountID: "acc-1", Instances: pq.StringArray{"i-1"}, }, @@ -116,8 +116,8 @@ func testToActionDTOResponseList_Correct(t *testing.T) { Operation: "STOP", Status: "Running", Enabled: false, - ClusterID: "cluster-2", - Region: "us-east-1", + ClusterID: sql.NullString{String: "cluster-2", Valid: true}, + Region: sql.NullString{String: "us-east-1", Valid: true}, AccountID: "acc-2", Instances: pq.StringArray{"i-2"}, }, diff --git a/internal/models/db/events.go b/internal/models/db/events.go index f787924c..0b1737a2 100644 --- a/internal/models/db/events.go +++ b/internal/models/db/events.go @@ -1,6 +1,7 @@ package db import ( + "database/sql" "time" "github.com/RHEcosystemAppEng/cluster-iq/internal/actions" @@ -11,19 +12,22 @@ import ( type ClusterEventDBResponse struct { ID int64 `db:"id"` EventTimestamp time.Time `db:"event_timestamp"` - TriggeredBy string `db:"triggered_by"` + Requester string `db:"requester"` Action string `db:"action"` ResourceID *string `db:"resource_id"` ResourceType string `db:"resource_type"` Result actions.ActionStatus `db:"result"` Description *string `db:"description,omitempty"` Severity string `db:"severity"` + ScheduleID *int64 `db:"schedule_id"` } // SystemEventDBResponse represents the database schema for system event details, // extending ClusterEventDBResponse with account and provider information. type SystemEventDBResponse struct { ClusterEventDBResponse - AccountID string `db:"account_id"` - Provider string `db:"provider"` + ResourceName sql.NullString `db:"resource_name"` + AccountID sql.NullString `db:"account_id"` + AccountName sql.NullString `db:"account_name"` + Provider sql.NullString `db:"provider"` } diff --git a/internal/models/db/events_test.go b/internal/models/db/events_test.go index fdd73855..d05bbbc0 100644 --- a/internal/models/db/events_test.go +++ b/internal/models/db/events_test.go @@ -1,6 +1,7 @@ package db_test import ( + "database/sql" "testing" "time" @@ -30,7 +31,7 @@ func testClusterEventDBResponse_ToClusterEventDTOResponse_Correct(t *testing.T) model := db.ClusterEventDBResponse{ ID: 1, EventTimestamp: now, - TriggeredBy: "api", + Requester: "api", Action: "START", ResourceID: &resID, ResourceType: inventory.ClusterResourceType, @@ -43,7 +44,7 @@ func testClusterEventDBResponse_ToClusterEventDTOResponse_Correct(t *testing.T) assert.Equal(t, model.ID, dto.ID) assert.Equal(t, model.EventTimestamp, dto.EventTimestamp) - assert.Equal(t, model.TriggeredBy, dto.TriggeredBy) + assert.Equal(t, model.Requester, dto.Requester) assert.Equal(t, model.Action, dto.Action) assert.Equal(t, model.ResourceID, dto.ResourceID) assert.Equal(t, model.ResourceType, dto.ResourceType) @@ -60,7 +61,7 @@ func testClusterEventDBResponse_ToClusterEventDTOResponse_NilDescription(t *test model := db.ClusterEventDBResponse{ ID: 2, EventTimestamp: now, - TriggeredBy: "scanner", + Requester: "scanner", Action: "STOP", ResourceID: &resID, ResourceType: inventory.ClusterResourceType, @@ -89,8 +90,8 @@ func testToClusterEventDTOResponseList_Correct(t *testing.T) { c1 := "c1" c2 := "c2" models := []db.ClusterEventDBResponse{ - {ID: 1, EventTimestamp: now, TriggeredBy: "api", Action: "START", ResourceID: &c1, ResourceType: inventory.ClusterResourceType, Result: "Success", Severity: "Info"}, - {ID: 2, EventTimestamp: now.Add(-time.Minute), TriggeredBy: "agent", Action: "STOP", ResourceID: &c2, ResourceType: inventory.ClusterResourceType, Result: "Failed", Severity: "Error"}, + {ID: 1, EventTimestamp: now, Requester: "api", Action: "START", ResourceID: &c1, ResourceType: inventory.ClusterResourceType, Result: "Success", Severity: "Info"}, + {ID: 2, EventTimestamp: now.Add(-time.Minute), Requester: "agent", Action: "STOP", ResourceID: &c2, ResourceType: inventory.ClusterResourceType, Result: "Failed", Severity: "Error"}, } dtos := conv.ToClusterEventDTOs(models) @@ -113,34 +114,41 @@ func testSystemEventDBResponse_ToSystemEventDTOResponse_Correct(t *testing.T) { resID := "cluster-10" conv := &convert.ConverterImpl{} + schedID := int64(42) model := db.SystemEventDBResponse{ ClusterEventDBResponse: db.ClusterEventDBResponse{ ID: 10, EventTimestamp: now, - TriggeredBy: "scheduler", + Requester: "scheduler", Action: "START", ResourceID: &resID, ResourceType: inventory.ClusterResourceType, Result: "Pending", Description: &desc, Severity: "Warning", + ScheduleID: &schedID, }, - AccountID: "acc-1", - Provider: "AWS", + ResourceName: sql.NullString{String: "my-cluster", Valid: true}, + AccountID: sql.NullString{String: "acc-1", Valid: true}, + AccountName: sql.NullString{String: "My Account", Valid: true}, + Provider: sql.NullString{String: "AWS", Valid: true}, } dto := conv.ToSystemEventDTO(model) assert.Equal(t, int64(10), dto.ID) assert.Equal(t, now, dto.EventTimestamp) - assert.Equal(t, "scheduler", dto.TriggeredBy) + assert.Equal(t, "scheduler", dto.Requester) assert.Equal(t, "START", dto.Action) assert.Equal(t, &resID, dto.ResourceID) assert.Equal(t, inventory.ClusterResourceType, dto.ResourceType) assert.Equal(t, actions.StatusPending, dto.Result) assert.Equal(t, &desc, dto.Description) assert.Equal(t, "Warning", dto.Severity) + assert.Equal(t, &schedID, dto.ScheduleID) + assert.Equal(t, "my-cluster", dto.ResourceName) assert.Equal(t, "acc-1", dto.AccountID) + assert.Equal(t, "My Account", dto.AccountName) assert.Equal(t, "AWS", dto.Provider) } @@ -162,29 +170,33 @@ func testToSystemEventDTOResponseList_Correct(t *testing.T) { ClusterEventDBResponse: db.ClusterEventDBResponse{ ID: 1, EventTimestamp: now, - TriggeredBy: "api", + Requester: "api", Action: "START", ResourceID: &sc1, ResourceType: inventory.ClusterResourceType, Result: "Success", Severity: "Info", }, - AccountID: "acc-1", - Provider: "AWS", + ResourceName: sql.NullString{String: "cluster-a", Valid: true}, + AccountID: sql.NullString{String: "acc-1", Valid: true}, + AccountName: sql.NullString{String: "Account 1", Valid: true}, + Provider: sql.NullString{String: "AWS", Valid: true}, }, { ClusterEventDBResponse: db.ClusterEventDBResponse{ ID: 2, EventTimestamp: now.Add(-time.Minute), - TriggeredBy: "agent", + Requester: "agent", Action: "STOP", ResourceID: &sc2, ResourceType: inventory.ClusterResourceType, Result: "Failed", Severity: "Error", }, - AccountID: "acc-2", - Provider: "GCP", + ResourceName: sql.NullString{String: "cluster-b", Valid: true}, + AccountID: sql.NullString{String: "acc-2", Valid: true}, + AccountName: sql.NullString{String: "Account 2", Valid: true}, + Provider: sql.NullString{String: "GCP", Valid: true}, }, } @@ -192,9 +204,13 @@ func testToSystemEventDTOResponseList_Correct(t *testing.T) { assert.Len(t, dtos, 2) assert.Equal(t, int64(1), dtos[0].ID) + assert.Equal(t, "cluster-a", dtos[0].ResourceName) assert.Equal(t, "acc-1", dtos[0].AccountID) + assert.Equal(t, "Account 1", dtos[0].AccountName) assert.Equal(t, "AWS", dtos[0].Provider) assert.Equal(t, int64(2), dtos[1].ID) + assert.Equal(t, "cluster-b", dtos[1].ResourceName) assert.Equal(t, "acc-2", dtos[1].AccountID) + assert.Equal(t, "Account 2", dtos[1].AccountName) assert.Equal(t, "GCP", dtos[1].Provider) } diff --git a/internal/models/db/instance.go b/internal/models/db/instance.go index cdb7f80f..291f4ec6 100644 --- a/internal/models/db/instance.go +++ b/internal/models/db/instance.go @@ -26,3 +26,9 @@ type InstanceDBResponse struct { CurrentMonthSoFarCost float64 `db:"current_month_so_far_cost"` Tags TagDBResponses `db:"tags_json"` } + +// InstancePendingExpenseDB maps the instances_pending_expense_update view. +type InstancePendingExpenseDB struct { + AccountID string `db:"account_id"` + InstanceID string `db:"instance_id"` +} diff --git a/internal/models/dto/action_dto.go b/internal/models/dto/action_dto.go index 37f4b770..fc950b52 100644 --- a/internal/models/dto/action_dto.go +++ b/internal/models/dto/action_dto.go @@ -32,6 +32,17 @@ func (a ActionDTORequest) ToModelAction() actions.Action { Instances: a.Instances, } + if actions.ActionOperation(a.Operation) == actions.Scan { + target.TargetType = "Account" + if a.AccountID != "" { + target.TargetAccountIDs = []string{a.AccountID} + } else { + target.SelectAll = true + } + } else { + target.TargetType = "Cluster" + } + switch actions.ActionType(a.Type) { case actions.ScheduledActionType: action := actions.NewScheduledAction( @@ -89,28 +100,36 @@ func ToModelActionList(dtos []ActionDTORequest) *[]actions.Action { // ActionDTOResponse represents the data transfer object for an action response, // containing action details including schedule, cron expression, and target resources. type ActionDTOResponse struct { - ID string `json:"id"` - Type string `json:"type"` - Time time.Time `json:"time"` - CronExp string `json:"cronExpression"` - Operation string `json:"operation"` - Status string `json:"status"` - Enabled bool `json:"enabled"` - ClusterID string `json:"clusterId"` - Region string `json:"region"` - AccountID string `json:"accountId"` - Instances []string `json:"instances"` - Requester string `json:"requester"` - Description *string `json:"description"` + ID string `json:"id"` + Type string `json:"type"` + Time time.Time `json:"time"` + CronExp string `json:"cronExpression"` + Operation string `json:"operation"` + Status string `json:"status"` + Enabled bool `json:"enabled"` + TargetType string `json:"targetType"` + SelectAll bool `json:"selectAll"` + ClusterID string `json:"clusterId"` + ClusterName string `json:"clusterName"` + Region string `json:"region"` + AccountID string `json:"accountId"` + Instances []string `json:"instances"` + TargetAccountIDs []string `json:"targetAccountIds"` + TargetAccountNames []string `json:"targetAccountNames"` + Requester string `json:"requester"` + Description *string `json:"description"` } // @name ActionResponse // ToModelAction converts ActionDTOResponse to actions.Action func (a ActionDTOResponse) ToModelAction() actions.Action { target := actions.ActionTarget{ - AccountID: a.AccountID, - Region: a.Region, - ClusterID: a.ClusterID, - Instances: a.Instances, + AccountID: a.AccountID, + Region: a.Region, + ClusterID: a.ClusterID, + Instances: a.Instances, + TargetType: a.TargetType, + SelectAll: a.SelectAll, + TargetAccountIDs: a.TargetAccountIDs, } switch actions.ActionType(a.Type) { diff --git a/internal/models/dto/action_run_dto.go b/internal/models/dto/action_run_dto.go new file mode 100644 index 00000000..02c9d801 --- /dev/null +++ b/internal/models/dto/action_run_dto.go @@ -0,0 +1,20 @@ +package dto + +import "time" + +// ActionRunDTORequest represents a request to create or update an action run. +type ActionRunDTORequest struct { + ScheduleID string `json:"scheduleId" binding:"required"` + Status string `json:"status"` + ErrorMsg string `json:"errorMsg"` +} // @name ActionRunRequest + +// ActionRunDTOResponse represents the response for an action execution record. +type ActionRunDTOResponse struct { + ID string `json:"id"` + ScheduleID string `json:"scheduleId"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt"` + Status string `json:"status"` + ErrorMsg string `json:"errorMsg"` +} // @name ActionRunResponse diff --git a/internal/models/dto/event_dto.go b/internal/models/dto/event_dto.go index 210fd143..7287d47b 100644 --- a/internal/models/dto/event_dto.go +++ b/internal/models/dto/event_dto.go @@ -16,7 +16,7 @@ type EventDTORequest struct { EventTimestamp time.Time `json:"timestamp"` Result string `json:"result"` Severity string `json:"severity"` - TriggeredBy string `json:"triggeredBy"` + Requester string `json:"requester"` Description *string `json:"description,omitempty"` } // @name EventRequest @@ -30,7 +30,7 @@ func (e EventDTORequest) ToModelEvent() *events.Event { ResourceType: e.ResourceType, Result: e.Result, Severity: e.Severity, - TriggeredBy: e.TriggeredBy, + Requester: e.Requester, } } // @name EventResponse @@ -43,13 +43,16 @@ type ClusterEventDTOResponse struct { EventTimestamp time.Time `json:"timestamp"` Result actions.ActionStatus `json:"result"` Severity string `json:"severity"` - TriggeredBy string `json:"triggeredBy"` + Requester string `json:"requester"` Description *string `json:"description,omitempty"` + ScheduleID *int64 `json:"scheduleId,omitempty"` } // @name ClusterEventResponse // SystemEvent represents a system-level event, extending a cluster event with account details. type SystemEventDTOResponse struct { ClusterEventDTOResponse - AccountID string `json:"accountId"` - Provider string `json:"provider"` + ResourceName string `json:"resourceName"` + AccountID string `json:"accountId"` + AccountName string `json:"accountName"` + Provider string `json:"provider"` } // @name SystemEventResponse diff --git a/internal/models/dto/event_dto_test.go b/internal/models/dto/event_dto_test.go index b6a90d7a..fccf8730 100644 --- a/internal/models/dto/event_dto_test.go +++ b/internal/models/dto/event_dto_test.go @@ -27,7 +27,7 @@ func testEventDTORequest_ToModelEvent_Correct(t *testing.T) { EventTimestamp: now, Result: "Success", Severity: "Info", - TriggeredBy: "scanner", + Requester: "scanner", Description: &desc, } @@ -42,7 +42,7 @@ func testEventDTORequest_ToModelEvent_Correct(t *testing.T) { assert.Equal(t, dto.ResourceType, event.ResourceType) assert.Equal(t, dto.Result, event.Result) assert.Equal(t, dto.Severity, event.Severity) - assert.Equal(t, dto.TriggeredBy, event.TriggeredBy) + assert.Equal(t, dto.Requester, event.Requester) } // TestEventDTORequest_ToModelEvent_NilDescription verifies nil description handling. @@ -61,7 +61,7 @@ func testEventDTORequest_ToModelEvent_NilDescription(t *testing.T) { EventTimestamp: now, Result: "Failed", Severity: "Error", - TriggeredBy: "agent", + Requester: "agent", Description: nil, } @@ -90,7 +90,7 @@ func testClusterEventDTOResponse_Struct(t *testing.T) { EventTimestamp: now, Result: "Success", Severity: "Info", - TriggeredBy: "api", + Requester: "api", Description: &desc, } @@ -101,7 +101,7 @@ func testClusterEventDTOResponse_Struct(t *testing.T) { assert.Equal(t, now, dto.EventTimestamp) assert.Equal(t, actions.StatusSuccess, dto.Result) assert.Equal(t, "Info", dto.Severity) - assert.Equal(t, "api", dto.TriggeredBy) + assert.Equal(t, "api", dto.Requester) assert.Equal(t, &desc, dto.Description) } @@ -119,7 +119,7 @@ func testSystemEventDTOResponse_Struct(t *testing.T) { EventTimestamp: now, Result: "Failed", Severity: "Error", - TriggeredBy: "scheduler", + Requester: "scheduler", Description: &desc, }, AccountID: "acc-1", @@ -133,7 +133,7 @@ func testSystemEventDTOResponse_Struct(t *testing.T) { assert.Equal(t, now, dto.EventTimestamp) assert.Equal(t, actions.StatusFailed, dto.Result) assert.Equal(t, "Error", dto.Severity) - assert.Equal(t, "scheduler", dto.TriggeredBy) + assert.Equal(t, "scheduler", dto.Requester) assert.Equal(t, &desc, dto.Description) assert.Equal(t, "acc-1", dto.AccountID) assert.Equal(t, "AWS", dto.Provider) diff --git a/internal/models/dto/wrappers.go b/internal/models/dto/wrappers.go index 5ae29bd7..14daf5a1 100644 --- a/internal/models/dto/wrappers.go +++ b/internal/models/dto/wrappers.go @@ -45,3 +45,9 @@ type SystemEventListResponse struct { type ClusterEventListResponse struct { responsetypes.ListResponse[ClusterEventDTOResponse] } // @name ClusterEventListResponse + +// ActionRunListResponse wraps a paginated list of ActionRunDTOResponse items +// for OpenAPI schema generation. +type ActionRunListResponse struct { + responsetypes.ListResponse[ActionRunDTOResponse] +} // @name ActionRunListResponse diff --git a/internal/repositories/account_repository.go b/internal/repositories/account_repository.go index 8d86995d..73384458 100644 --- a/internal/repositories/account_repository.go +++ b/internal/repositories/account_repository.go @@ -50,7 +50,7 @@ type AccountRepository interface { CountAccounts(ctx context.Context, opts models.ListOptions) (int, error) GetAccountByID(ctx context.Context, accountID string) (db.AccountDBResponse, error) GetAccountClustersByID(ctx context.Context, accountID string) ([]db.ClusterDBResponse, error) - GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstanceDBResponse, error) + GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstancePendingExpenseDB, error) GetScannerTimestamp(ctx context.Context) (time.Time, error) CreateAccount(ctx context.Context, accounts []inventory.Account) error UpdateAccount(ctx context.Context, accountID string, patch dto.AccountPatchRequest) error @@ -153,10 +153,6 @@ func (r *accountRepositoryImpl) GetAccountClustersByID(ctx context.Context, acco return clusters, err } - if len(clusters) == 0 { - return clusters, ErrNoClustersInAccount - } - return clusters, nil } @@ -167,8 +163,8 @@ func (r *accountRepositoryImpl) GetAccountClustersByID(ctx context.Context, acco // Returns: // - A slice of inventory.Instance objects. // - An error if the query fails. -func (r *accountRepositoryImpl) GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstanceDBResponse, error) { - instances := []db.InstanceDBResponse{} +func (r *accountRepositoryImpl) GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstancePendingExpenseDB, error) { + instances := []db.InstancePendingExpenseDB{} opts := models.ListOptions{ PageSize: 0, diff --git a/internal/repositories/action_repository.go b/internal/repositories/action_repository.go index d79771aa..6197ab8d 100644 --- a/internal/repositories/action_repository.go +++ b/internal/repositories/action_repository.go @@ -33,60 +33,31 @@ const ( enabled = false WHERE id = $1 ` - // InsertAction inserts a new action returning the ID - InsertActionsQuery = ` - INSERT INTO schedule ( - type, - time, - operation, - target, - status, - enabled - ) VALUES ( - :type, - (SELECT now()), - :operation, - (SELECT id FROM clusters WHERE cluster_id = :target.cluster_id), - :status, - :enabled - ) RETURNING id + + InsertTargetQuery = `INSERT INTO targets (target_type, select_all) VALUES ($1, $2) RETURNING id` + + LinkTargetClusterQuery = `INSERT INTO target_clusters (target_id, cluster_id) SELECT $1, id FROM clusters WHERE cluster_id = $2` + + LinkTargetAccountQuery = `INSERT INTO target_accounts (target_id, account_id) SELECT $1, id FROM accounts WHERE account_id = $2` + + InsertScheduledActionWithTargetQuery = ` + INSERT INTO schedule (type, time, operation, target, status, enabled, requester, description) + VALUES ('scheduled_action', $1, $2, $3, $4, $5, $6, $7) + RETURNING id ` - // InsertScheduledActionQuery inserts new scheduled actions on the DB - InsertScheduledActionsQuery = ` - INSERT INTO schedule ( - type, - time, - operation, - target, - status, - enabled - ) VALUES ( - 'scheduled_action', - :time, - :operation, - (SELECT id FROM clusters WHERE cluster_id=:target.cluster_id), - :status, - :enabled - ) + + InsertCronActionWithTargetQuery = ` + INSERT INTO schedule (type, cron_exp, operation, target, status, enabled, requester, description) + VALUES ('cron_action', $1, $2, $3, $4, $5, $6, $7) + RETURNING id ` - // InsertCronActionQuery inserts new Cron actions on the DB - InsertCronActionsQuery = ` - INSERT INTO schedule ( - type, - cron_exp, - operation, - target, - status, - enabled - ) VALUES ( - 'cron_action', - :cron_exp, - :operation, - (SELECT id FROM clusters WHERE cluster_id=:target.cluster_id), - :status, - :enabled - ) + + InsertInstantActionWithTargetQuery = ` + INSERT INTO schedule (type, time, operation, target, status, enabled, requester, description) + VALUES ('instant_action', NOW(), $1, $2, $3, $4, $5, $6) + RETURNING id ` + // UpdateActionQuery updates a single action on the DB UpdateActionQuery = ` UPDATE schedule @@ -192,11 +163,7 @@ func (r *actionRepositoryImpl) GetByID(ctx context.Context, actionID string) (db // // Returns: // - An error if the insert fails -// -// TODO: Temporal fix returning TX from DBClient to manage both insertions in the same sql transaction func (r *actionRepositoryImpl) Create(ctx context.Context, newActions []actions.Action) (err error) { - schedActions, cronActions := actions.SplitActionsByType(newActions) - tx, err := r.db.NewTx(ctx) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) @@ -207,33 +174,97 @@ func (r *actionRepositoryImpl) Create(ctx context.Context, newActions []actions. } }() - // Writing Scheduled Actions - if len(schedActions) > 0 { - if _, err := tx.NamedExecContext(ctx, InsertScheduledActionsQuery, schedActions); err != nil { - return fmt.Errorf("failed to insert scheduled actions: %w", err) + for _, action := range newActions { + targetID, targetErr := createTargetForAction(ctx, tx, action) + if targetErr != nil { + return fmt.Errorf("failed to create target: %w", targetErr) } - } - // Writing Cron Actions - if len(cronActions) > 0 { - if _, err := tx.NamedExecContext(ctx, InsertCronActionsQuery, cronActions); err != nil { - return fmt.Errorf("failed to insert cron actions: %w", err) + switch a := action.(type) { + case *actions.ScheduledAction: + _, err = tx.ExecContext(ctx, InsertScheduledActionWithTargetQuery, + a.When, a.Operation, targetID, a.Status, a.Enabled, a.Requester, a.Description) + case *actions.CronAction: + _, err = tx.ExecContext(ctx, InsertCronActionWithTargetQuery, + a.Expression, a.Operation, targetID, a.Status, a.Enabled, a.Requester, a.Description) + case *actions.InstantAction: + _, err = tx.ExecContext(ctx, InsertInstantActionWithTargetQuery, + a.Operation, targetID, a.Status, a.Enabled, a.Requester, a.Description) + default: + return fmt.Errorf("unsupported action type for batch create: %T", action) + } + if err != nil { + return fmt.Errorf("failed to insert schedule: %w", err) } } - // Commit the transaction return tx.Commit() } -// AddEvent inserts a new audit event into the database and returns the event ID. func (r *actionRepositoryImpl) CreateAction(ctx context.Context, action actions.Action) (int64, error) { - var returnedValue int64 - returnedValue, err := r.db.InsertWithReturnWithContext(ctx, InsertActionsQuery, action) + tx, err := r.db.NewTx(ctx) if err != nil { - return -1, err + return -1, fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + targetID, err := createTargetForAction(ctx, tx, action) + if err != nil { + return -1, fmt.Errorf("failed to create target: %w", err) + } + + var scheduleID int64 + err = tx.QueryRowContext(ctx, InsertInstantActionWithTargetQuery, + action.GetActionOperation(), targetID, action.(*actions.InstantAction).Status, action.(*actions.InstantAction).Enabled, + action.GetRequester(), action.GetDescription(), + ).Scan(&scheduleID) + if err != nil { + return -1, fmt.Errorf("failed to insert action: %w", err) + } + + if err = tx.Commit(); err != nil { + return -1, fmt.Errorf("failed to commit action: %w", err) + } + + return scheduleID, nil +} + +func createTargetForAction(ctx context.Context, tx interface { + QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row + ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) +}, action actions.Action) (int64, error) { + target := action.GetTarget() + + targetType := target.TargetType + if targetType == "" { + targetType = "Cluster" + } + + var targetID int64 + if err := tx.QueryRowContext(ctx, InsertTargetQuery, targetType, target.SelectAll).Scan(&targetID); err != nil { + return 0, fmt.Errorf("failed to insert target: %w", err) + } + + switch targetType { + case "Cluster": + if target.ClusterID != "" { + if _, err := tx.ExecContext(ctx, LinkTargetClusterQuery, targetID, target.ClusterID); err != nil { + return 0, fmt.Errorf("failed to link target cluster: %w", err) + } + } + case "Account": + for _, accountID := range target.TargetAccountIDs { + if _, err := tx.ExecContext(ctx, LinkTargetAccountQuery, targetID, accountID); err != nil { + return 0, fmt.Errorf("failed to link target account: %w", err) + } + } } - return returnedValue, nil + return targetID, nil } // Delete removes an actions.ScheduledAction action from the DB based on its ID diff --git a/internal/repositories/action_run_repository.go b/internal/repositories/action_run_repository.go new file mode 100644 index 00000000..1bed2316 --- /dev/null +++ b/internal/repositories/action_run_repository.go @@ -0,0 +1,105 @@ +package repositories + +import ( + "context" + "database/sql" + "errors" + "fmt" + + dbclient "github.com/RHEcosystemAppEng/cluster-iq/internal/db_client" + "github.com/RHEcosystemAppEng/cluster-iq/internal/models" + "github.com/RHEcosystemAppEng/cluster-iq/internal/models/db" +) + +const ( + ActionRunsTable = "action_runs" + + InsertActionRunQuery = ` + INSERT INTO action_runs (schedule_id) + VALUES ($1) + RETURNING id + ` + + UpdateActionRunQuery = ` + UPDATE action_runs + SET + status = $1, + finished_at = NOW(), + error_msg = $2 + WHERE id = $3 + ` +) + +var _ ActionRunRepository = (*actionRunRepositoryImpl)(nil) + +// ActionRunRepository defines the interface for action run data access operations. +type ActionRunRepository interface { + List(ctx context.Context, opts models.ListOptions) ([]db.ActionRunDBResponse, int, error) + GetByID(ctx context.Context, runID string) (db.ActionRunDBResponse, error) + Create(ctx context.Context, scheduleID string) (int64, error) + Update(ctx context.Context, runID string, status string, errorMsg string) error +} + +type actionRunRepositoryImpl struct { + db *dbclient.DBClient +} + +func NewActionRunRepository(db *dbclient.DBClient) ActionRunRepository { + return &actionRunRepositoryImpl{db: db} +} + +func (r *actionRunRepositoryImpl) List(ctx context.Context, opts models.ListOptions) ([]db.ActionRunDBResponse, int, error) { + runs := []db.ActionRunDBResponse{} + + if err := r.db.SelectWithContext(ctx, &runs, ActionRunsTable, opts, "id", "*"); err != nil { + return runs, 0, fmt.Errorf("failed to list action runs: %w", err) + } + + return runs, len(runs), nil +} + +func (r *actionRunRepositoryImpl) GetByID(ctx context.Context, runID string) (db.ActionRunDBResponse, error) { + var run db.ActionRunDBResponse + + opts := models.ListOptions{ + Filters: map[string]interface{}{ + "id": runID, + }, + } + + if err := r.db.GetWithContext(ctx, &run, ActionRunsTable, opts, "id", "*"); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return run, ErrNotFound + } + return run, err + } + + return run, nil +} + +func (r *actionRunRepositoryImpl) Create(ctx context.Context, scheduleID string) (int64, error) { + var id int64 + err := r.db.QueryRowContext(ctx, &id, InsertActionRunQuery, scheduleID) + if err != nil { + return -1, fmt.Errorf("failed to create action run: %w", err) + } + return id, nil +} + +func (r *actionRunRepositoryImpl) Update(ctx context.Context, runID string, status string, errorMsg string) error { + tx, err := r.db.NewTx(ctx) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + if _, err = tx.ExecContext(ctx, UpdateActionRunQuery, status, errorMsg, runID); err != nil { + return fmt.Errorf("failed to update action run: %w", err) + } + + return tx.Commit() +} diff --git a/internal/repositories/errors.go b/internal/repositories/errors.go index 5e014292..8eb3555c 100644 --- a/internal/repositories/errors.go +++ b/internal/repositories/errors.go @@ -4,6 +4,3 @@ import "errors" // ErrNotFound is returned when a resource is not found in the database. var ErrNotFound = errors.New("requested resource not found") - -// ErrNoClustersInAccount is returned when an account exists but has no associated clusters. -var ErrNoClustersInAccount = errors.New("no clusters found for this account") diff --git a/internal/repositories/event_repository.go b/internal/repositories/event_repository.go index 96373fb1..db08e9ad 100644 --- a/internal/repositories/event_repository.go +++ b/internal/repositories/event_repository.go @@ -21,16 +21,17 @@ const ( InsertEventQuery = ` INSERT INTO events( event_timestamp, - triggered_by, + requester, action, resource_id, resource_type, result, description, - severity + severity, + schedule_id ) VALUES ( CURRENT_TIMESTAMP, - :triggered_by, + :requester, :action, ( CASE @@ -38,12 +39,15 @@ const ( THEN (SELECT id FROM clusters c WHERE c.cluster_id = :resource_id) WHEN :resource_type = 'Instance' THEN (SELECT id FROM instances i WHERE i.instance_id = :resource_id) + WHEN :resource_type = 'Account' + THEN (SELECT id FROM accounts a WHERE a.account_id = :resource_id) END ), :resource_type, :result, :description, - :severity + :severity, + :schedule_id ) RETURNING id ` // UpdateEventStatusQuery updates the result status of an audit log entry based on its ID. diff --git a/internal/services/account_service.go b/internal/services/account_service.go index 4066da40..cb38f38d 100644 --- a/internal/services/account_service.go +++ b/internal/services/account_service.go @@ -16,7 +16,7 @@ type AccountService interface { List(ctx context.Context, options models.ListOptions) ([]db.AccountDBResponse, int, error) GetByID(ctx context.Context, accountID string) (db.AccountDBResponse, error) GetAccountClustersByID(ctx context.Context, accountID string) ([]db.ClusterDBResponse, error) - GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstanceDBResponse, error) + GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstancePendingExpenseDB, error) Create(ctx context.Context, accounts []inventory.Account) error Update(ctx context.Context, accountID string, patch dto.AccountPatchRequest) error Delete(ctx context.Context, accountID string) error @@ -57,7 +57,7 @@ func (s *accountServiceImpl) GetAccountClustersByID(ctx context.Context, account } // GetExpenseUpdateInstances retrieves instances with outdated billing information. -func (s *accountServiceImpl) GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstanceDBResponse, error) { +func (s *accountServiceImpl) GetExpenseUpdateInstances(ctx context.Context, accountID string) ([]db.InstancePendingExpenseDB, error) { instances, err := s.repo.GetExpenseUpdateInstances(ctx, accountID) if err != nil { return instances, fmt.Errorf("get expense update instances for account %s: %w", accountID, err) diff --git a/internal/services/action_run_service.go b/internal/services/action_run_service.go new file mode 100644 index 00000000..ed4b909e --- /dev/null +++ b/internal/services/action_run_service.go @@ -0,0 +1,55 @@ +package services + +import ( + "context" + "fmt" + + "github.com/RHEcosystemAppEng/cluster-iq/internal/models" + "github.com/RHEcosystemAppEng/cluster-iq/internal/models/db" + "github.com/RHEcosystemAppEng/cluster-iq/internal/repositories" +) + +// ActionRunService defines the interface for action run business logic. +type ActionRunService interface { + List(ctx context.Context, options models.ListOptions) ([]db.ActionRunDBResponse, int, error) + Get(ctx context.Context, runID string) (db.ActionRunDBResponse, error) + Create(ctx context.Context, scheduleID string) (int64, error) + Update(ctx context.Context, runID string, status string, errorMsg string) error +} + +var _ ActionRunService = (*actionRunServiceImpl)(nil) + +type actionRunServiceImpl struct { + repo repositories.ActionRunRepository +} + +func NewActionRunService(repo repositories.ActionRunRepository) ActionRunService { + return &actionRunServiceImpl{repo: repo} +} + +func (s *actionRunServiceImpl) List(ctx context.Context, options models.ListOptions) ([]db.ActionRunDBResponse, int, error) { + return s.repo.List(ctx, options) +} + +func (s *actionRunServiceImpl) Get(ctx context.Context, runID string) (db.ActionRunDBResponse, error) { + run, err := s.repo.GetByID(ctx, runID) + if err != nil { + return run, fmt.Errorf("get action run %s: %w", runID, err) + } + return run, nil +} + +func (s *actionRunServiceImpl) Create(ctx context.Context, scheduleID string) (int64, error) { + id, err := s.repo.Create(ctx, scheduleID) + if err != nil { + return -1, fmt.Errorf("create action run: %w", err) + } + return id, nil +} + +func (s *actionRunServiceImpl) Update(ctx context.Context, runID string, status string, errorMsg string) error { + if err := s.repo.Update(ctx, runID, status, errorMsg); err != nil { + return fmt.Errorf("update action run %s: %w", runID, err) + } + return nil +} diff --git a/internal/stocker/aws_billing_stocker.go b/internal/stocker/aws_billing_stocker.go index f8ae4a4f..23544ff7 100644 --- a/internal/stocker/aws_billing_stocker.go +++ b/internal/stocker/aws_billing_stocker.go @@ -19,19 +19,17 @@ type AWSBillingStocker struct { logger *zap.Logger // AWS connection interface conn *cp.AWSConnection - // List of instances to obtain its expenses - Instances []inventory.Instance + // List of instance IDs to obtain their expenses + InstanceIDs []string } // NewAWSBillingStocker create and returns a pointer to a new AWSBillingStocker instance -func NewAWSBillingStocker(account *inventory.Account, logger *zap.Logger, instances []inventory.Instance) *AWSBillingStocker { - // Check if there are instances to get billing information - if len(instances) == 0 { - logger.Error("No instances to get billing information") +func NewAWSBillingStocker(account *inventory.Account, logger *zap.Logger, instanceIDs []string) *AWSBillingStocker { + if len(instanceIDs) == 0 { + logger.Info("No instances pending billing update, skipping billing stocker") return nil } - // Leaving the region empty forces to the AWSConnection to use the default region until a new one is configured conn, err := cp.NewAWSConnection(account.User(), account.Password(), "", cp.WithCostExplorer()) if err != nil { logger.Error("Error creating a new AWSBillingStocker", zap.String("account", account.AccountName), zap.Error(err)) @@ -39,10 +37,10 @@ func NewAWSBillingStocker(account *inventory.Account, logger *zap.Logger, instan } return &AWSBillingStocker{ - Account: account, - logger: logger, - Instances: instances, - conn: conn, + Account: account, + logger: logger, + InstanceIDs: instanceIDs, + conn: conn, } } @@ -59,9 +57,9 @@ func (s *AWSBillingStocker) MakeStock() error { cluster := s.Account.Clusters[i] for j := range cluster.Instances { instance := &cluster.Instances[j] - for _, targetInstance := range s.Instances { - if targetInstance.InstanceID == instance.InstanceID { - s.logger.Info("Getting expenses for instance", zap.String("instance_id", targetInstance.InstanceID)) + for _, targetID := range s.InstanceIDs { + if targetID == instance.InstanceID { + s.logger.Info("Getting expenses for instance", zap.String("instance_id", targetID)) err := s.getInstanceExpenses(instance) if err != nil { s.logger.Error("Error querying billing info for an instance", @@ -69,7 +67,6 @@ func (s *AWSBillingStocker) MakeStock() error { zap.String("instance_id", instance.InstanceID), zap.String("error", err.Error()), ) - // Continue to the next region even if an error occurs continue } break diff --git a/test/integration/api_action_runs_integration_test.go b/test/integration/api_action_runs_integration_test.go new file mode 100644 index 00000000..ebb226fd --- /dev/null +++ b/test/integration/api_action_runs_integration_test.go @@ -0,0 +1,256 @@ +package integration + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + responsetypes "github.com/RHEcosystemAppEng/cluster-iq/internal/api/response_types" + "github.com/RHEcosystemAppEng/cluster-iq/internal/models/dto" +) + +const ( + APIActionRunsURL = APIBaseURL + "/action-runs" +) + +func TestAPIActionRuns(t *testing.T) { + waitForAPIReady(t) + + if err := refreshInventory(); err != nil { + t.Fatal("Error refreshing inventory") + } + + t.Run("List Action Runs", func(t *testing.T) { testListActionRuns(t) }) + t.Run("List Action Runs with Pagination", func(t *testing.T) { testListActionRunsWithPagination(t) }) + t.Run("List Action Runs filtered by Status", func(t *testing.T) { testListActionRunsFilteredByStatus(t) }) + t.Run("Get Action Run By ID Success", func(t *testing.T) { testGetActionRunByID_Exists(t) }) + t.Run("Get Action Run By ID Not Found", func(t *testing.T) { testGetActionRunByID_NoExists(t) }) + t.Run("Post Action Run", func(t *testing.T) { testPostActionRun(t) }) + t.Run("Update Action Run", func(t *testing.T) { testUpdateActionRun(t) }) + t.Run("Update Action Run Not Found", func(t *testing.T) { testUpdateActionRun_NoExists(t) }) +} + +func testListActionRuns(t *testing.T) { + expectedCount := 2 + expectedHTTPCode := http.StatusOK + + resp, err := http.Get(APIActionRunsURL) + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) + + var response responsetypes.ListResponse[dto.ActionRunDTOResponse] + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + if response.Count != expectedCount { + t.Fatalf("Expected Count: '%d', got: '%d'", expectedCount, response.Count) + } + + if len := len(response.Items); len != expectedCount { + t.Fatalf("Expected Items: '%d', got: '%d'", expectedCount, len) + } +} + +func testListActionRunsWithPagination(t *testing.T) { + expectedCount := 1 + expectedHTTPCode := http.StatusOK + + resp, err := http.Get(APIActionRunsURL + "?page=1&page_size=1") + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) + + var response responsetypes.ListResponse[dto.ActionRunDTOResponse] + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + if response.Count != expectedCount { + t.Fatalf("Expected Count: '%d', got: '%d'", expectedCount, response.Count) + } + + if len := len(response.Items); len != expectedCount { + t.Fatalf("Expected Items: '%d', got: '%d'", expectedCount, len) + } +} + +func testListActionRunsFilteredByStatus(t *testing.T) { + expectedCount := 1 + expectedHTTPCode := http.StatusOK + + resp, err := http.Get(APIActionRunsURL + "?status=Running") + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) + + var response responsetypes.ListResponse[dto.ActionRunDTOResponse] + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + if response.Count != expectedCount { + t.Fatalf("Expected Count: '%d', got: '%d'", expectedCount, response.Count) + } + + if len := len(response.Items); len != expectedCount { + t.Fatalf("Expected Items: '%d', got: '%d'", expectedCount, len) + } + + if response.Items[0].Status != "Running" { + t.Fatalf("Expected Status: 'Running', got: '%s'", response.Items[0].Status) + } +} + +func testGetActionRunByID_Exists(t *testing.T) { + expectedRunID := "1" + expectedHTTPCode := http.StatusOK + + resp, err := http.Get(APIActionRunsURL + "/" + expectedRunID) + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) + + var response dto.ActionRunDTOResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + if response.ID != expectedRunID { + t.Fatalf("Expected ID: '%s', got: '%s'", expectedRunID, response.ID) + } +} + +func testGetActionRunByID_NoExists(t *testing.T) { + expectedMsg := "Action run not found" + expectedHTTPCode := http.StatusNotFound + + resp, err := http.Get(APIActionRunsURL + "/" + "9999") + if err != nil { + t.Fatalf("Failed to make request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) + + var response responsetypes.GenericErrorResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + if response.Message != expectedMsg { + t.Fatalf("Expected Message: '%s', got: '%s'", expectedMsg, response.Message) + } +} + +func testPostActionRun(t *testing.T) { + expectedHTTPCode := http.StatusCreated + expectedCount := 1 + + payload := dto.ActionRunDTORequest{ + ScheduleID: "1", + } + b, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Failed to marshal data in post request: %v", err) + } + + resp, err := http.Post(APIActionRunsURL, "application/json", bytes.NewBuffer(b)) + if err != nil { + t.Fatalf("Failed to make POST request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) + + var response responsetypes.PostResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + if response.Count != expectedCount { + t.Fatalf("Expected Count: '%d', got '%d'", expectedCount, response.Count) + } +} + +func testUpdateActionRun(t *testing.T) { + expectedHTTPCode := http.StatusOK + + payload := dto.ActionRunDTORequest{ + ScheduleID: "1", + Status: "Failed", + ErrorMsg: "test error", + } + b, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Failed to marshal data in patch request: %v", err) + } + + req, err := http.NewRequest(http.MethodPatch, APIActionRunsURL+"/1", bytes.NewBuffer(b)) + if err != nil { + t.Fatalf("Failed to create PATCH request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to make PATCH request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) +} + +func testUpdateActionRun_NoExists(t *testing.T) { + expectedMsg := "Action run not found" + expectedHTTPCode := http.StatusNotFound + + payload := dto.ActionRunDTORequest{ + ScheduleID: "1", + Status: "Failed", + ErrorMsg: "test error", + } + b, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Failed to marshal data in patch request: %v", err) + } + + req, err := http.NewRequest(http.MethodPatch, APIActionRunsURL+"/9999", bytes.NewBuffer(b)) + if err != nil { + t.Fatalf("Failed to create PATCH request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to make PATCH request: %v", err) + } + defer resp.Body.Close() + + checkHTTPResponseCode(t, resp, expectedHTTPCode) + + var response responsetypes.GenericErrorResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + if response.Message != expectedMsg { + t.Fatalf("Expected Message: '%s', got: '%s'", expectedMsg, response.Message) + } +} diff --git a/test/integration/api_events_integration_test.go b/test/integration/api_events_integration_test.go index 34435187..ae3f1f27 100644 --- a/test/integration/api_events_integration_test.go +++ b/test/integration/api_events_integration_test.go @@ -102,7 +102,7 @@ func testPostEvents(t *testing.T) { EventTimestamp: time.Now(), Result: "Pending", Severity: "info", - TriggeredBy: "tester", + Requester: "tester", Description: nil, } b, err := json.Marshal(event) @@ -148,7 +148,7 @@ func testUpdateEvent(t *testing.T) { EventTimestamp: time.Now(), Result: "Success", Severity: "info", - TriggeredBy: "tester", + Requester: "tester", Description: nil, } b, err := json.Marshal(event)
TimeActionResultDateOperation ResourceTriggered ByStatusRequester
{event.timestamp ? new Date(event.timestamp).toLocaleString('es-ES') : '-'}{event.action}{parseScanTimestamp(event.timestamp)}{renderOperationLabel(event.action)} - {getResultIcon(event.result as ResultStatus)} {event.result} + {event.resourceId ? ( + <> + {renderResourceBadge(event.resourceType)}{' '} + + {event.resourceName || event.resourceId} + + + ) : event.action === ActionOperations.SCAN ? ( + <> + All Accounts + + ) : ( + '-' + )} - - {event.resourceId} - - {event.triggeredBy}{renderActionStatusLabel(event.result)}{event.requester}