diff --git a/apps/api-server/internal/handlers/platform.go b/apps/api-server/internal/handlers/platform.go new file mode 100644 index 0000000..a5c1706 --- /dev/null +++ b/apps/api-server/internal/handlers/platform.go @@ -0,0 +1,708 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" + "github.com/nutcas3/telecom-platform/apps/api-server/internal/monitoring" + "github.com/nutcas3/telecom-platform/apps/api-server/internal/services" +) + +// ErrorResponse is a shared error envelope used across platform handlers. +type ErrorResponse struct { + Error string `json:"error"` + Code string `json:"code,omitempty"` + Details string `json:"details,omitempty"` +} + +func badRequest(c *gin.Context, err error) { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid request", Details: err.Error(), Code: "BAD_REQUEST"}) +} + +func serverError(c *gin.Context, err error) { + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal error", Details: err.Error(), Code: "INTERNAL_ERROR"}) +} + +func notFound(c *gin.Context, err error) { + c.JSON(http.StatusNotFound, ErrorResponse{Error: "Not found", Details: err.Error(), Code: "NOT_FOUND"}) +} + +// parseUint helper +func parseUintParam(c *gin.Context, name string) (uint, bool) { + v, err := strconv.ParseUint(c.Param(name), 10, 32) + if err != nil { + badRequest(c, err) + return 0, false + } + return uint(v), true +} + +// parseBoolQuery returns *bool for optional tri-state filters. +func parseBoolQuery(c *gin.Context, key string) *bool { + raw := c.Query(key) + if raw == "" { + return nil + } + b, err := strconv.ParseBool(raw) + if err != nil { + return nil + } + return &b +} + +type ServicesHandler struct { + k8s *services.KubernetesService +} + +func NewServicesHandler(k8s *services.KubernetesService) *ServicesHandler { + return &ServicesHandler{k8s: k8s} +} + +func (h *ServicesHandler) List(c *gin.Context) { + if h.k8s == nil { + c.JSON(http.StatusServiceUnavailable, ErrorResponse{Error: "Kubernetes not configured", Code: "K8S_UNAVAILABLE"}) + return + } + deps, err := h.k8s.ListDeployments(c.Request.Context()) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"services": deps, "total": len(deps), "namespace": h.k8s.Namespace()}) +} + +func (h *ServicesHandler) Get(c *gin.Context) { + if h.k8s == nil { + c.JSON(http.StatusServiceUnavailable, ErrorResponse{Error: "Kubernetes not configured", Code: "K8S_UNAVAILABLE"}) + return + } + d, err := h.k8s.GetDeployment(c.Request.Context(), c.Param("id")) + if err != nil { + notFound(c, err) + return + } + c.JSON(http.StatusOK, d) +} + +func (h *ServicesHandler) Restart(c *gin.Context) { + if h.k8s == nil { + c.JSON(http.StatusServiceUnavailable, ErrorResponse{Error: "Kubernetes not configured", Code: "K8S_UNAVAILABLE"}) + return + } + if err := h.k8s.RestartDeployment(c.Request.Context(), c.Param("id")); err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"message": "restart triggered", "service": c.Param("id")}) +} + +func (h *ServicesHandler) Start(c *gin.Context) { + if h.k8s == nil { + c.JSON(http.StatusServiceUnavailable, ErrorResponse{Error: "Kubernetes not configured", Code: "K8S_UNAVAILABLE"}) + return + } + // Starting = scaling to >=1 replica (default 1). + if err := h.k8s.ScaleDeployment(c.Request.Context(), c.Param("id"), 1); err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"message": "service started", "service": c.Param("id")}) +} + +func (h *ServicesHandler) Stop(c *gin.Context) { + if h.k8s == nil { + c.JSON(http.StatusServiceUnavailable, ErrorResponse{Error: "Kubernetes not configured", Code: "K8S_UNAVAILABLE"}) + return + } + if err := h.k8s.ScaleDeployment(c.Request.Context(), c.Param("id"), 0); err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"message": "service stopped", "service": c.Param("id")}) +} + +func (h *ServicesHandler) Scale(c *gin.Context) { + if h.k8s == nil { + c.JSON(http.StatusServiceUnavailable, ErrorResponse{Error: "Kubernetes not configured", Code: "K8S_UNAVAILABLE"}) + return + } + var req struct { + Replicas int32 `json:"replicas" binding:"required,min=0,max=100"` + } + if err := c.ShouldBindJSON(&req); err != nil { + badRequest(c, err) + return + } + if err := h.k8s.ScaleDeployment(c.Request.Context(), c.Param("id"), req.Replicas); err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"message": "scaled", "service": c.Param("id"), "replicas": req.Replicas}) +} + +func (h *ServicesHandler) Logs(c *gin.Context) { + if h.k8s == nil { + c.JSON(http.StatusServiceUnavailable, ErrorResponse{Error: "Kubernetes not configured", Code: "K8S_UNAVAILABLE"}) + return + } + lines, _ := strconv.Atoi(c.DefaultQuery("lines", "100")) + logs, err := h.k8s.PodLogs(c.Request.Context(), c.Param("id"), lines) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"service": c.Param("id"), "logs": logs}) +} + +func (h *ServicesHandler) Health(c *gin.Context) { + if h.k8s == nil { + c.JSON(http.StatusServiceUnavailable, ErrorResponse{Error: "Kubernetes not configured", Code: "K8S_UNAVAILABLE"}) + return + } + d, err := h.k8s.GetDeployment(c.Request.Context(), c.Param("id")) + if err != nil { + notFound(c, err) + return + } + status := "healthy" + if d.ReadyReplicas < d.Replicas { + status = "degraded" + } + if d.ReadyReplicas == 0 { + status = "unhealthy" + } + c.JSON(http.StatusOK, gin.H{ + "service": c.Param("id"), + "status": status, + "replicas_desired": d.Replicas, + "replicas_ready": d.ReadyReplicas, + "replicas_available": d.AvailableReplicas, + }) +} + +func (h *ServicesHandler) PodStatus(c *gin.Context) { + if h.k8s == nil { + c.JSON(http.StatusServiceUnavailable, ErrorResponse{Error: "Kubernetes not configured", Code: "K8S_UNAVAILABLE"}) + return + } + status, err := h.k8s.GetPodStatus(c.Request.Context(), c.Param("id")) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, status) +} + +func (h *ServicesHandler) Events(c *gin.Context) { + if h.k8s == nil { + c.JSON(http.StatusServiceUnavailable, ErrorResponse{Error: "Kubernetes not configured", Code: "K8S_UNAVAILABLE"}) + return + } + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + events, err := h.k8s.GetEvents(c.Request.Context(), c.Param("id"), limit) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"events": events, "total": len(events)}) +} + +type MonitoringHandler struct { + prom *monitoring.PrometheusService +} + +func NewMonitoringHandler(prom *monitoring.PrometheusService) *MonitoringHandler { + return &MonitoringHandler{prom: prom} +} + +func (h *MonitoringHandler) Metrics(c *gin.Context) { + if h.prom == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "monitoring service not available"}) + return + } + q := c.DefaultQuery("query", "up") + samples, err := h.prom.Query(c.Request.Context(), q) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"query": q, "samples": samples}) +} + +func (h *MonitoringHandler) Alerts(c *gin.Context) { + if h.prom == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "monitoring service not available"}) + return + } + alerts, err := h.prom.Alerts(c.Request.Context()) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"alerts": alerts}) +} + +func (h *MonitoringHandler) Health(c *gin.Context) { + if h.prom == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "status": "degraded", + "prometheus_available": false, + "error": "monitoring service not available", + }) + return + } + available := h.prom.Available(c.Request.Context()) + status := "healthy" + if !available { + status = "degraded" + } + c.JSON(http.StatusOK, gin.H{ + "status": status, + "prometheus_available": available, + }) +} + +func (h *MonitoringHandler) Logs(c *gin.Context) { + // Logs are aggregated in Elasticsearch; expose a stub that points callers + // to /v1/services/:id/logs for live pod logs. + c.JSON(http.StatusOK, gin.H{ + "message": "Use /v1/services/:id/logs for live pod logs; aggregated logs are in Kibana", + }) +} + +type DeploymentsHandler struct { + svc *services.DeploymentService +} + +func NewDeploymentsHandler(svc *services.DeploymentService) *DeploymentsHandler { + return &DeploymentsHandler{svc: svc} +} + +func (h *DeploymentsHandler) Status(c *gin.Context) { + f := services.DeploymentFilter{ + Service: c.Query("service"), + Environment: c.Query("environment"), + Status: "in_progress", + Limit: 50, + } + items, total, err := h.svc.List(c.Request.Context(), f) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"deployments": items, "total": total}) +} + +func (h *DeploymentsHandler) Start(c *gin.Context) { + var in services.StartDeploymentInput + if err := c.ShouldBindJSON(&in); err != nil { + badRequest(c, err) + return + } + d, err := h.svc.Start(c.Request.Context(), in) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusCreated, d) +} + +func (h *DeploymentsHandler) Rollback(c *gin.Context) { + var in services.RollbackInput + if err := c.ShouldBindJSON(&in); err != nil { + badRequest(c, err) + return + } + d, err := h.svc.Rollback(c.Request.Context(), in) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusCreated, d) +} + +func (h *DeploymentsHandler) History(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if pageSize > 100 { + pageSize = 100 + } + f := services.DeploymentFilter{ + Service: c.Query("service"), + Environment: c.Query("environment"), + Status: c.Query("status"), + Limit: pageSize, + Offset: (page - 1) * pageSize, + } + items, total, err := h.svc.List(c.Request.Context(), f) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "deployments": items, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +type PluginsHandler struct { + svc *services.PluginService +} + +func NewPluginsHandler(svc *services.PluginService) *PluginsHandler { + return &PluginsHandler{svc: svc} +} + +func (h *PluginsHandler) List(c *gin.Context) { + items, err := h.svc.List(c.Request.Context(), services.PluginFilter{ + Enabled: parseBoolQuery(c, "enabled"), + Type: c.Query("type"), + Category: c.Query("category"), + }) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"plugins": items, "total": len(items)}) +} + +func (h *PluginsHandler) Get(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + + plugin, err := h.svc.Get(c.Request.Context(), id) + if err != nil { + notFound(c, err) + return + } + + c.JSON(http.StatusOK, plugin) +} + +func (h *PluginsHandler) Install(c *gin.Context) { + var in services.InstallPluginInput + if err := c.ShouldBindJSON(&in); err != nil { + badRequest(c, err) + return + } + p, err := h.svc.Install(c.Request.Context(), in) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusCreated, p) +} + +func (h *PluginsHandler) Uninstall(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + if err := h.svc.Uninstall(c.Request.Context(), id); err != nil { + notFound(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"message": "plugin uninstalled", "id": id}) +} + +func (h *PluginsHandler) Enable(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + p, err := h.svc.SetEnabled(c.Request.Context(), id, true) + if err != nil { + notFound(c, err) + return + } + c.JSON(http.StatusOK, p) +} + +func (h *PluginsHandler) Disable(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + p, err := h.svc.SetEnabled(c.Request.Context(), id, false) + if err != nil { + notFound(c, err) + return + } + c.JSON(http.StatusOK, p) +} + +type AutomationHandler struct { + svc *services.AutomationService +} + +func NewAutomationHandler(svc *services.AutomationService) *AutomationHandler { + return &AutomationHandler{svc: svc} +} + +func (h *AutomationHandler) List(c *gin.Context) { + items, err := h.svc.List(c.Request.Context(), services.AutomationFilter{ + Enabled: parseBoolQuery(c, "enabled"), + Type: c.Query("type"), + }) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"automations": items, "total": len(items)}) +} + +func (h *AutomationHandler) Create(c *gin.Context) { + var in services.CreateAutomationInput + if err := c.ShouldBindJSON(&in); err != nil { + badRequest(c, err) + return + } + a, err := h.svc.Create(c.Request.Context(), in) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusCreated, a) +} + +func (h *AutomationHandler) Run(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + r, err := h.svc.Run(c.Request.Context(), id) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, r) +} + +func (h *AutomationHandler) Schedule(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + var in services.ScheduleInput + if err := c.ShouldBindJSON(&in); err != nil { + badRequest(c, err) + return + } + a, err := h.svc.UpdateSchedule(c.Request.Context(), id, in) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, a) +} + +func (h *AutomationHandler) Logs(c *gin.Context) { + var automationID uint + if raw := c.Query("automation_id"); raw != "" { + v, err := strconv.ParseUint(raw, 10, 32) + if err != nil { + badRequest(c, err) + return + } + automationID = uint(v) + } + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100")) + runs, err := h.svc.ListRuns(c.Request.Context(), automationID, limit) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"runs": runs, "total": len(runs)}) +} + +type ConfigHandler struct { + svc *services.ConfigStoreService +} + +func NewConfigHandler(svc *services.ConfigStoreService) *ConfigHandler { + return &ConfigHandler{svc: svc} +} + +func (h *ConfigHandler) Get(c *gin.Context) { + entries, err := h.svc.List(c.Request.Context(), c.Query("section")) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"entries": entries, "total": len(entries)}) +} + +func (h *ConfigHandler) Update(c *gin.Context) { + var in services.UpsertConfigInput + if err := c.ShouldBindJSON(&in); err != nil { + badRequest(c, err) + return + } + entry, err := h.svc.Upsert(c.Request.Context(), in) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, entry) +} + +func (h *ConfigHandler) Validate(c *gin.Context) { + res, err := h.svc.Validate(c.Request.Context(), c.Query("section")) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, res) +} + +type ChaosHandler struct { + svc *services.ChaosService +} + +func NewChaosHandler(svc *services.ChaosService) *ChaosHandler { + return &ChaosHandler{svc: svc} +} + +type runExperimentRequest struct { + Type services.ExperimentType `json:"type" binding:"required"` + Target string `json:"target" binding:"required"` + DurationMS int64 `json:"duration_ms"` + Probability float64 `json:"probability"` + Amount int `json:"amount"` +} + +func (h *ChaosHandler) Run(c *gin.Context) { + var req runExperimentRequest + if err := c.ShouldBindJSON(&req); err != nil { + badRequest(c, err) + return + } + cfg := services.ExperimentConfig{ + Duration: millisToDuration(req.DurationMS), + Probability: req.Probability, + Amount: req.Amount, + Target: req.Target, + } + exp, err := h.svc.RunExperiment(c.Request.Context(), req.Type, cfg) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusCreated, exp) +} + +func (h *ChaosHandler) List(c *gin.Context) { + active := h.svc.GetActiveExperiments() + history, _ := h.svc.GetExperimentHistory() + c.JSON(http.StatusOK, gin.H{ + "active": active, + "history": history, + }) +} + +func (h *ChaosHandler) Status(c *gin.Context) { + id := c.Query("id") + if id == "" { + // No id: return summary of active experiments + active := h.svc.GetActiveExperiments() + c.JSON(http.StatusOK, gin.H{ + "active_count": len(active), + "active": active, + }) + return + } + exp, ok := h.svc.GetExperimentStatus(id) + if !ok { + c.JSON(http.StatusNotFound, ErrorResponse{Error: "experiment not found", Code: "NOT_FOUND"}) + return + } + c.JSON(http.StatusOK, exp) +} + +type BillingHandler struct { + invoice *services.InvoiceService + db *gorm.DB +} + +func NewBillingHandler(invoice *services.InvoiceService, db *gorm.DB) *BillingHandler { + return &BillingHandler{invoice: invoice, db: db} +} + +type generateInvoiceRequest struct { + SubscriberID uint `json:"subscriber_id" binding:"required"` + BillingPeriod string `json:"billing_period" binding:"required"` // e.g. "2024-01" +} + +func (h *BillingHandler) GenerateInvoice(c *gin.Context) { + var req generateInvoiceRequest + if err := c.ShouldBindJSON(&req); err != nil { + badRequest(c, err) + return + } + inv, err := h.invoice.GenerateMonthlyInvoice(c.Request.Context(), req.SubscriberID, req.BillingPeriod) + if err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusCreated, inv) +} + +func (h *BillingHandler) ListInvoices(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if pageSize > 100 { + pageSize = 100 + } + q := h.db.WithContext(c.Request.Context()).Model(&models.Invoice{}).Preload("LineItems") + if s := c.Query("status"); s != "" { + q = q.Where("status = ?", s) + } + if sid := c.Query("subscriber_id"); sid != "" { + q = q.Where("subscriber_id = ?", sid) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + serverError(c, err) + return + } + var items []models.Invoice + if err := q.Order("created_at DESC").Limit(pageSize).Offset((page - 1) * pageSize).Find(&items).Error; err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"invoices": items, "total": total, "page": page, "page_size": pageSize}) +} + +func (h *BillingHandler) ListPayments(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if pageSize > 100 { + pageSize = 100 + } + q := h.db.WithContext(c.Request.Context()).Model(&models.Transaction{}) + if s := c.Query("status"); s != "" { + q = q.Where("status = ?", s) + } + if sid := c.Query("subscriber_id"); sid != "" { + q = q.Where("subscriber_id = ?", sid) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + serverError(c, err) + return + } + var items []models.Transaction + if err := q.Order("created_at DESC").Limit(pageSize).Offset((page - 1) * pageSize).Find(&items).Error; err != nil { + serverError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"payments": items, "total": total, "page": page, "page_size": pageSize}) +}