This commit is contained in:
Tomas Dvorak
2026-04-14 18:04:48 +02:00
parent 94f7302972
commit 355a97bab4
453 changed files with 81845 additions and 1243 deletions
+74
View File
@@ -0,0 +1,74 @@
package analytics
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
type Client struct {
BaseURL string
APIKey string
WebsiteID string
HTTP *http.Client
}
func NewClient(baseURL, apiKey, websiteID string) *Client {
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
WebsiteID: websiteID,
HTTP: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (c *Client) Enabled() bool {
return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != ""
}
func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) {
if !c.Enabled() {
return map[string]any{
"enabled": false,
}, nil
}
u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID))
if err != nil {
return nil, err
}
query := u.Query()
query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli()))
query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli()))
u.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Accept", "application/json")
res, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return nil, fmt.Errorf("umami returned %d", res.StatusCode)
}
var payload map[string]any
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return nil, err
}
payload["enabled"] = true
return payload, nil
}
+679
View File
@@ -0,0 +1,679 @@
package api
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
// NodeAgent represents a container orchestration agent
type NodeAgent struct {
ID string `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Hostname string `json:"hostname" gorm:"not null"`
IPAddress string `json:"ip_address" gorm:"not null"`
Port int `json:"port" gorm:"not null"`
Status string `json:"status" gorm:"default:'offline'"`
Version string `json:"version"`
Capabilities AgentCapabilities `json:"capabilities" gorm:"serializer:json"`
Resources NodeResources `json:"resources" gorm:"serializer:json"`
LastHeartbeat time.Time `json:"last_heartbeat"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata" gorm:"serializer:json"`
}
// AgentCapabilities defines what the agent can do
type AgentCapabilities struct {
ContainerRuntimes []string `json:"container_runtimes"`
SupportedArchitectures []string `json:"supported_architectures"`
MaxContainers int `json:"max_containers"`
StorageDriver string `json:"storage_driver"`
NetworkPlugins []string `json:"network_plugins"`
Features []string `json:"features"`
}
// NodeResources represents the agent's available resources
type NodeResources struct {
CPU CPUResources `json:"cpu"`
Memory MemoryResources `json:"memory"`
Storage StorageResources `json:"storage"`
Network NetworkResources `json:"network"`
}
type CPUResources struct {
Cores int `json:"cores"`
Allocation float64 `json:"allocation"` // percentage
Usage float64 `json:"usage"` // current usage percentage
}
type MemoryResources struct {
Total int `json:"total"`
Allocated int `json:"allocated"`
Used int `json:"used"`
Available int `json:"available"`
}
type StorageResources struct {
Total int `json:"total"`
Allocated int `json:"allocated"`
Used int `json:"used"`
Available int `json:"available"`
}
type NetworkResources struct {
Interfaces []NetworkInterface `json:"interfaces"`
Bandwidth BandwidthInfo `json:"bandwidth"`
}
type NetworkInterface struct {
Name string `json:"name"`
IPAddress string `json:"ip_address"`
MACAddress string `json:"mac_address"`
Speed int `json:"speed"`
Status string `json:"status"`
}
type BandwidthInfo struct {
Inbound int `json:"inbound"` // bytes per second
Outbound int `json:"outbound"` // bytes per second
}
// ContainerInstance represents a container running on an agent
type ContainerInstance struct {
ID string `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Image string `json:"image" gorm:"not null"`
ProjectID string `json:"project_id" gorm:"not null"`
ServiceID string `json:"service_id" gorm:"not null"`
NodeAgentID string `json:"node_agent_id" gorm:"not null"`
Status ContainerStatus `json:"status" gorm:"serializer:json"`
Resources ContainerResources `json:"resources" gorm:"serializer:json"`
Ports []PortMapping `json:"ports" gorm:"serializer:json"`
Environment map[string]string `json:"environment" gorm:"serializer:json"`
Volumes []VolumeMount `json:"volumes" gorm:"serializer:json"`
Networks []string `json:"networks" gorm:"serializer:json"`
RestartPolicy RestartPolicy `json:"restart_policy" gorm:"serializer:json"`
HealthCheck *HealthCheck `json:"health_check" gorm:"serializer:json"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ContainerStatus struct {
State string `json:"state"`
Health string `json:"health"`
ExitCode *int `json:"exit_code"`
Error *string `json:"error"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
type ContainerResources struct {
CPULimit int `json:"cpu_limit"`
CPUReservation int `json:"cpu_reservation"`
MemoryLimit int `json:"memory_limit"`
MemoryReservation int `json:"memory_reservation"`
DiskLimit *int `json:"disk_limit"`
}
type PortMapping struct {
ContainerPort int `json:"container_port"`
HostPort *int `json:"host_port"`
Protocol string `json:"protocol"`
Published bool `json:"published"`
}
type VolumeMount struct {
Name string `json:"name"`
Source string `json:"source"`
Target string `json:"target"`
Type string `json:"type"`
ReadOnly bool `json:"read_only"`
}
type RestartPolicy struct {
Name string `json:"name"`
MaximumRetryCount *int `json:"maximum_retry_count"`
}
type HealthCheck struct {
Test []string `json:"test"`
Interval int `json:"interval"`
Timeout int `json:"timeout"`
Retries int `json:"retries"`
StartPeriod int `json:"start_period"`
}
// AgentCommand represents a command sent to an agent
type AgentCommand struct {
ID string `json:"id" gorm:"primaryKey"`
Type string `json:"type" gorm:"not null"`
NodeAgentID string `json:"node_agent_id" gorm:"not null"`
ContainerID *string `json:"container_id"`
Payload map[string]interface{} `json:"payload" gorm:"serializer:json"`
Status string `json:"status" gorm:"default:'pending'"`
Result *string `json:"result"`
Error *string `json:"error"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CompletedAt *time.Time `json:"completed_at"`
}
// AgentHeartbeat represents a heartbeat message from an agent
type AgentHeartbeat struct {
NodeAgentID string `json:"node_agent_id"`
Timestamp time.Time `json:"timestamp"`
Status string `json:"status"`
Resources NodeResources `json:"resources"`
ContainerCount int `json:"container_count"`
SystemLoad SystemLoad `json:"system_load"`
Uptime int64 `json:"uptime"`
Version string `json:"version"`
}
type SystemLoad struct {
Load1M float64 `json:"load_1m"`
Load5M float64 `json:"load_5m"`
Load15M float64 `json:"load_15m"`
}
// NodeAgentHandler handles agent-related endpoints
type NodeAgentHandler struct {
db *gorm.DB
}
func NewNodeAgentHandler(db *gorm.DB) *NodeAgentHandler {
return &NodeAgentHandler{db: db}
}
// RegisterAgent handles agent registration
func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Hostname string `json:"hostname" binding:"required"`
IPAddress string `json:"ip_address" binding:"required"`
Port int `json:"port" binding:"required"`
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
AuthToken string `json:"auth_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate auth token (in a real implementation, this would be more sophisticated)
if req.AuthToken != "valid-token" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
return
}
// Check if agent already exists
var existingAgent NodeAgent
if err := h.db.Where("hostname = ? AND ip_address = ?", req.Hostname, req.IPAddress).First(&existingAgent).Error; err == nil {
// Update existing agent
existingAgent.Name = req.Name
existingAgent.Port = req.Port
existingAgent.Capabilities = req.Capabilities
existingAgent.Status = "connecting"
existingAgent.LastHeartbeat = time.Now()
if err := h.db.Save(&existingAgent).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return
}
c.JSON(http.StatusOK, gin.H{
"agent_id": existingAgent.ID,
"auth_token": req.AuthToken,
"status": "updated",
})
return
}
// Create new agent
agent := NodeAgent{
ID: uuid.New().String(),
Name: req.Name,
Hostname: req.Hostname,
IPAddress: req.IPAddress,
Port: req.Port,
Status: "connecting",
Capabilities: req.Capabilities,
Resources: NodeResources{
CPU: CPUResources{
Cores: 4,
Allocation: 0,
Usage: 0,
},
Memory: MemoryResources{
Total: 8 * 1024 * 1024 * 1024, // 8GB
Allocated: 0,
Used: 0,
Available: 8 * 1024 * 1024 * 1024,
},
Storage: StorageResources{
Total: 100 * 1024 * 1024 * 1024, // 100GB
Allocated: 0,
Used: 0,
Available: 100 * 1024 * 1024 * 1024,
},
Network: NetworkResources{
Interfaces: []NetworkInterface{
{
Name: "eth0",
IPAddress: req.IPAddress,
MACAddress: "00:00:00:00:00:00",
Speed: 1000,
Status: "up",
},
},
Bandwidth: BandwidthInfo{
Inbound: 0,
Outbound: 0,
},
},
},
LastHeartbeat: time.Now(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Metadata: make(map[string]interface{}),
}
if err := h.db.Create(&agent).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
return
}
c.JSON(http.StatusCreated, gin.H{
"agent_id": agent.ID,
"auth_token": req.AuthToken,
"status": "registered",
})
}
// GetAgents returns all registered agents
func (h *NodeAgentHandler) GetAgents(c *gin.Context) {
var agents []NodeAgent
if err := h.db.Find(&agents).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agents"})
return
}
c.JSON(http.StatusOK, gin.H{"agents": agents})
}
// GetAgent returns a specific agent
func (h *NodeAgentHandler) GetAgent(c *gin.Context) {
id := c.Param("id")
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
return
}
c.JSON(http.StatusOK, gin.H{"agent": agent})
}
// UpdateAgent updates an agent's information
func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
id := c.Param("id")
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
return
}
var updates map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.Model(&agent).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return
}
c.JSON(http.StatusOK, gin.H{"agent": agent})
}
// DeleteAgent removes an agent
func (h *NodeAgentHandler) DeleteAgent(c *gin.Context) {
id := c.Param("id")
if err := h.db.Delete(&NodeAgent{}, "id = ?", id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agent"})
return
}
c.Status(http.StatusNoContent)
}
// SendHeartbeat handles heartbeat messages from agents
func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
var heartbeat AgentHeartbeat
if err := c.ShouldBindJSON(&heartbeat); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", heartbeat.NodeAgentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
return
}
// Update agent status and resources
agent.Status = heartbeat.Status
agent.Resources = heartbeat.Resources
agent.LastHeartbeat = heartbeat.Timestamp
agent.UpdatedAt = time.Now()
if err := h.db.Save(&agent).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return
}
c.Status(http.StatusOK)
}
// GetAgentContainers returns containers running on a specific agent
func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) {
agentID := c.Param("id")
var containers []ContainerInstance
if err := h.db.Where("node_agent_id = ?", agentID).Find(&containers).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch containers"})
return
}
c.JSON(http.StatusOK, gin.H{"containers": containers})
}
// CreateContainer creates a new container on an agent
func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
agentID := c.Param("id")
var req struct {
Name string `json:"name" binding:"required"`
Image string `json:"image" binding:"required"`
ProjectID string `json:"project_id" binding:"required"`
ServiceID string `json:"service_id" binding:"required"`
Resources ContainerResources `json:"resources" binding:"required"`
Ports []PortMapping `json:"ports"`
Environment map[string]string `json:"environment"`
Volumes []VolumeMount `json:"volumes"`
Networks []string `json:"networks"`
RestartPolicy RestartPolicy `json:"restart_policy"`
HealthCheck *HealthCheck `json:"health_check"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify agent exists
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
return
}
container := ContainerInstance{
ID: uuid.New().String(),
Name: req.Name,
Image: req.Image,
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
NodeAgentID: agentID,
Status: ContainerStatus{
State: "created",
Health: "none",
},
Resources: req.Resources,
Ports: req.Ports,
Environment: req.Environment,
Volumes: req.Volumes,
Networks: req.Networks,
RestartPolicy: req.RestartPolicy,
HealthCheck: req.HealthCheck,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&container).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container"})
return
}
// Create command to start container on agent
command := AgentCommand{
ID: uuid.New().String(),
Type: "create_container",
NodeAgentID: agentID,
ContainerID: &container.ID,
Payload: map[string]interface{}{
"container": container,
},
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container command"})
return
}
c.JSON(http.StatusCreated, gin.H{"container": container})
}
// ExecuteCommand executes a command on an agent
func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
agentID := c.Param("id")
var req struct {
Type string `json:"type" binding:"required"`
Payload map[string]interface{} `json:"payload"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
command := AgentCommand{
ID: uuid.New().String(),
Type: req.Type,
NodeAgentID: agentID,
Payload: req.Payload,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
return
}
c.JSON(http.StatusCreated, gin.H{"command": command})
}
// GetAgentCommands returns commands for an agent
func (h *NodeAgentHandler) GetAgentCommands(c *gin.Context) {
agentID := c.Param("id")
var commands []AgentCommand
if err := h.db.Where("node_agent_id = ?", agentID).Order("created_at DESC").Find(&commands).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
return
}
c.JSON(http.StatusOK, gin.H{"commands": commands})
}
// GetCommandStatus returns the status of a specific command
func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) {
agentID := c.Param("id")
commandID := c.Param("commandId")
var command AgentCommand
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch command"})
return
}
c.JSON(http.StatusOK, gin.H{"command": command})
}
// ContainerAction handles container lifecycle actions
func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
agentID := c.Param("id")
containerID := c.Param("containerId")
action := c.Param("action")
// Validate action
validActions := map[string]bool{
"start": true,
"stop": true,
"restart": true,
"remove": true,
}
if !validActions[action] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"})
return
}
// Verify container exists
var container ContainerInstance
if err := h.db.First(&container, "id = ? AND node_agent_id = ?", containerID, agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch container"})
return
}
// Create command for the action
command := AgentCommand{
ID: uuid.New().String(),
Type: fmt.Sprintf("%s_container", action),
NodeAgentID: agentID,
ContainerID: &container.ID,
Payload: map[string]interface{}{
"container_id": containerID,
},
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
return
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Container %s action initiated", action)})
}
// GetAgentMetrics returns metrics for an agent
func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) {
_ = c.Param("id") // Use the parameter to avoid unused variable error
timeRange := c.Query("time_range")
if timeRange == "" {
timeRange = "1h" // default to 1 hour
}
// Parse time range
duration, err := time.ParseDuration(timeRange)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid time range"})
return
}
// For now, return empty metrics - in a real implementation, this would query a metrics database
metrics := []map[string]interface{}{
{
"timestamp": time.Now().Add(-duration).Format(time.RFC3339),
"cpu": map[string]interface{}{
"usage": 25.5,
"usage_percent": 25.5,
},
"memory": map[string]interface{}{
"usage": 2 * 1024 * 1024 * 1024, // 2GB
"usage_percent": 25.0,
"limit": 8 * 1024 * 1024 * 1024, // 8GB
},
},
{
"timestamp": time.Now().Format(time.RFC3339),
"cpu": map[string]interface{}{
"usage": 30.2,
"usage_percent": 30.2,
},
"memory": map[string]interface{}{
"usage": 2.5 * 1024 * 1024 * 1024, // 2.5GB
"usage_percent": 31.25,
"limit": 8 * 1024 * 1024 * 1024, // 8GB
},
},
}
c.JSON(http.StatusOK, gin.H{"metrics": metrics})
}
// SetupRoutes registers the agent routes
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
agents := router.Group("/agents")
{
agents.POST("/register", h.RegisterAgent)
agents.GET("", h.GetAgents)
agents.GET("/:id", h.GetAgent)
agents.PUT("/:id", h.UpdateAgent)
agents.DELETE("/:id", h.DeleteAgent)
agents.POST("/heartbeat", h.SendHeartbeat)
agents.GET("/:id/containers", h.GetAgentContainers)
agents.POST("/:id/containers", h.CreateContainer)
agents.POST("/:id/containers/:containerId/start", h.ContainerAction)
agents.POST("/:id/containers/:containerId/stop", h.ContainerAction)
agents.POST("/:id/containers/:containerId/restart", h.ContainerAction)
agents.DELETE("/:id/containers/:containerId", h.ContainerAction)
agents.GET("/:id/metrics", h.GetAgentMetrics)
agents.POST("/:id/commands", h.ExecuteCommand)
agents.GET("/:id/commands", h.GetAgentCommands)
agents.GET("/:id/commands/:commandId", h.GetCommandStatus)
}
}
+541
View File
@@ -0,0 +1,541 @@
package api
import (
"containr/internal/database"
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"golang.org/x/crypto/bcrypt"
)
// Constants for validation and limits
const (
MaxServiceNameLength = 100
MaxRoutePrefixLength = 200
MaxUpstreamURLLength = 500
MaxAPIKeyNameLength = 100
MinAPIKeyLength = 20
MaxAPIKeyLength = 100
DefaultRPMLimit = 60
DefaultMonthlyQuota = 1000
MaxRPMLimit = 10000
MaxMonthlyQuota = 10000000
)
// Validator instance for request validation
var validate = validator.New()
// APIError represents a structured API error response
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Details interface{} `json:"details,omitempty"`
}
// APIResponse represents a standardized API response
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}
// Meta contains pagination and metadata
type Meta struct {
Page int `json:"page,omitempty"`
PerPage int `json:"per_page,omitempty"`
Total int `json:"total,omitempty"`
TotalPages int `json:"total_pages,omitempty"`
}
// ServiceRequest represents the request payload for creating/updating services
type ServiceRequest struct {
Name string `json:"name" binding:"required,max=100" validate:"required,min=1,max=100"`
UpstreamURL string `json:"upstreamUrl" binding:"required,max=500" validate:"required,url,max=500"`
RoutePrefix string `json:"routePrefix" binding:"required,max=200" validate:"required,min=1,max=200"`
Enabled *bool `json:"enabled,omitempty"`
RPMLimit *int `json:"rpmLimit,omitempty" validate:"omitempty,min=1,max=10000"`
MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"`
}
// APIKeyRequest represents the request payload for creating/updating API keys
type APIKeyRequest struct {
Name string `json:"name" binding:"required,max=100" validate:"required,min=1,max=100"`
Plan string `json:"plan" validate:"omitempty,oneof=free pro business enterprise"`
Enabled *bool `json:"enabled,omitempty"`
RPMLimit *int `json:"rpmLimit,omitempty" validate:"omitempty,min=1,max=10000"`
MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"`
}
// generateServiceID generates a cryptographically secure service ID
func generateServiceID() (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
return "svc_" + hex.EncodeToString(bytes), nil
}
// generateAPIKey generates a cryptographically secure API key
func generateAPIKey() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
return "ap_" + hex.EncodeToString(bytes), nil
}
// hashAPIKey creates a bcrypt hash for the API key
func hashAPIKey(key string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("failed to hash API key: %w", err)
}
return string(hash), nil
}
// validateAPIKeyPlan validates and returns default values for API key plans
func validateAPIKeyPlan(plan string) (string, int, int, error) {
if plan == "" {
plan = "free"
}
switch plan {
case "free":
return plan, 60, 1000, nil
case "pro":
return plan, 600, 50000, nil
case "business":
return plan, 3000, 300000, nil
case "enterprise":
return plan, 10000, 10000000, nil
default:
return "", 0, 0, fmt.Errorf("invalid plan: %s", plan)
}
}
// sendJSONResponse sends a standardized JSON response
func sendJSONResponse(c *gin.Context, statusCode int, response APIResponse) {
c.JSON(statusCode, response)
}
// sendErrorResponse sends a standardized error response
func sendErrorResponse(c *gin.Context, statusCode int, code, message string, details interface{}) {
response := APIResponse{
Success: false,
Error: &APIError{
Code: code,
Message: message,
Details: details,
},
}
sendJSONResponse(c, statusCode, response)
}
// sendSuccessResponse sends a standardized success response
func sendSuccessResponse(c *gin.Context, statusCode int, data interface{}) {
response := APIResponse{
Success: true,
Data: data,
}
sendJSONResponse(c, statusCode, response)
}
// validateRequest validates the request payload using the validator
func validateRequest(c *gin.Context, req interface{}) error {
if err := c.ShouldBindJSON(req); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
if err := validate.Struct(req); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
return nil
}
// handleAPwhyServicesList returns a list of API services
func handleAPwhyServicesList(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
ctx := context.Background()
// Query services from database
rows, err := db.QueryContext(ctx, `
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
FROM api_services
ORDER BY created_at DESC
`)
if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
"Failed to query services", err.Error())
return
}
defer rows.Close()
var services []map[string]interface{}
for rows.Next() {
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
var enabled bool
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
if err != nil {
continue // Skip malformed rows
}
services = append(services, map[string]interface{}{
"id": id,
"name": name,
"slug": slug,
"upstreamUrl": upstreamURL,
"routePrefix": routePrefix,
"enabled": enabled,
"createdAt": createdAt,
"updatedAt": updatedAt,
})
}
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
"services": services,
"count": len(services),
})
}
// handleAPwhyServicesCreate creates a new API service
func handleAPwhyServicesCreate(c *gin.Context) {
var req ServiceRequest
if err := validateRequest(c, &req); err != nil {
sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR",
"Invalid request parameters", err.Error())
return
}
db := c.MustGet("db").(*database.DB)
ctx := context.Background()
// Generate slug and ID
id, err := generateServiceID()
if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
"Failed to generate service ID", err.Error())
return
}
slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-"))
// Insert service into database
query := `
INSERT INTO api_services (
id, name, slug, upstream_url, route_prefix, health_path,
enabled, rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, '/health', true, $6, $7, NOW(), NOW())
`
var rpmLimit, monthlyQuota int
if req.RPMLimit != nil {
rpmLimit = *req.RPMLimit
} else {
rpmLimit = DefaultRPMLimit
}
if req.MonthlyQuota != nil {
monthlyQuota = *req.MonthlyQuota
} else {
monthlyQuota = DefaultMonthlyQuota
}
_, err = db.ExecContext(ctx, query, id, req.Name, slug, req.UpstreamURL,
req.RoutePrefix, rpmLimit, monthlyQuota)
if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
"Failed to create service", err.Error())
return
}
serviceData := map[string]interface{}{
"id": id,
"name": req.Name,
"slug": slug,
"upstreamUrl": req.UpstreamURL,
"routePrefix": req.RoutePrefix,
"enabled": true,
"rpmLimit": rpmLimit,
"monthlyQuota": monthlyQuota,
"createdAt": time.Now().UTC().Format(time.RFC3339),
}
sendSuccessResponse(c, http.StatusCreated, map[string]interface{}{
"service": serviceData,
"message": "Service created successfully",
})
}
// handleAPwhyServicesPatch updates an existing API service
func handleAPwhyServicesPatch(c *gin.Context) {
serviceID := c.Param("id")
var input struct {
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"ok": false,
"error": "Invalid input: " + err.Error(),
})
return
}
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
_, err := db.ExecContext(context.Background(),
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
*input.Enabled, serviceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to update service: " + err.Error(),
})
return
}
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"data": gin.H{"id": serviceID, "updated": true},
})
}
// handleAPwhyKeysList returns a list of API keys
func handleAPwhyKeysList(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
rows, err := db.QueryContext(context.Background(), `
SELECT id, name, key_prefix, plan, enabled, rpm_limit, monthly_quota, created_at, updated_at
FROM api_keys
ORDER BY created_at DESC
`)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"ok": true,
"data": []interface{}{},
})
return
}
defer rows.Close()
var keys []map[string]interface{}
for rows.Next() {
var id, name, keyPrefix, plan, createdAt, updatedAt string
var enabled bool
var rpmLimit, monthlyQuota sql.NullInt64
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
if err != nil {
continue
}
key := map[string]interface{}{
"id": id,
"name": name,
"keyPrefix": keyPrefix,
"plan": plan,
"enabled": enabled,
"createdAt": createdAt,
"updatedAt": updatedAt,
}
if rpmLimit.Valid {
key["rpmLimit"] = rpmLimit.Int64
}
if monthlyQuota.Valid {
key["monthlyQuota"] = monthlyQuota.Int64
}
keys = append(keys, key)
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"data": keys,
})
}
// handleAPwhyKeysCreate creates a new API key
func handleAPwhyKeysCreate(c *gin.Context) {
var input struct {
Name string `json:"name" binding:"required"`
Plan string `json:"plan"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"ok": false,
"error": "Invalid input: " + err.Error(),
})
return
}
if input.Plan == "" {
input.Plan = "free"
}
db := c.MustGet("db").(*database.DB)
// Generate API key and hash
apiKey, err := generateAPIKey()
if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
"Failed to generate API key", err.Error())
return
}
keyHash, err := hashAPIKey(apiKey)
if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "HASH_ERROR",
"Failed to hash API key", err.Error())
return
}
// Generate ID and prefix
id, err := generateServiceID()
if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
"Failed to generate key ID", err.Error())
return
}
keyPrefix := apiKey[:8]
// Set default limits based on plan
var rpmLimit, monthlyQuota int
switch input.Plan {
case "free":
rpmLimit, monthlyQuota = 60, 1000
case "pro":
rpmLimit, monthlyQuota = 600, 50000
case "business":
rpmLimit, monthlyQuota = 3000, 300000
default:
rpmLimit, monthlyQuota = 60, 1000
}
// Insert key into database
_, err = db.ExecContext(context.Background(), `
INSERT INTO api_keys (
id, name, key_hash, key_prefix, plan, allowed_service_ids,
enabled, rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, '[]', true, $6, $7, NOW(), NOW())
`, id, input.Name, keyHash, keyPrefix, input.Plan, rpmLimit, monthlyQuota)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to create key: " + err.Error(),
})
return
}
c.JSON(http.StatusCreated, gin.H{
"ok": true,
"data": gin.H{
"id": id,
"name": input.Name,
"plan": input.Plan,
"key": apiKey, // Only return the actual key once
"keyPrefix": keyPrefix,
"enabled": true,
"rpmLimit": rpmLimit,
"monthlyQuota": monthlyQuota,
},
})
}
// handleAPwhyKeysPatch updates an existing API key
func handleAPwhyKeysPatch(c *gin.Context) {
keyID := c.Param("id")
var input struct {
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"ok": false,
"error": "Invalid input: " + err.Error(),
})
return
}
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
_, err := db.ExecContext(context.Background(),
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
*input.Enabled, keyID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"ok": false,
"error": "Failed to update key: " + err.Error(),
})
return
}
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"data": gin.H{"id": keyID, "updated": true},
})
}
// handleAPwhyAnalyticsOps returns operational analytics
func handleAPwhyAnalyticsOps(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
// Get counts from database
var totalServices, totalKeys, totalUsers int
db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_services").Scan(&totalServices)
db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM api_keys").Scan(&totalKeys)
db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&totalUsers)
// For requests, we'll return 0 for now (would need to implement usage tracking)
requestsToday := 0
requestsThisMonth := 0
c.JSON(http.StatusOK, gin.H{
"ok": true,
"data": gin.H{
"total_requests": 0,
"total_services": totalServices,
"total_keys": totalKeys,
"total_users": totalUsers,
"requests_today": requestsToday,
"requests_this_month": requestsThisMonth,
},
})
}
// handleAPwhyAnalyticsTraffic returns traffic analytics
func handleAPwhyAnalyticsTraffic(c *gin.Context) {
// TODO: Implement traffic analytics from database
c.JSON(http.StatusOK, gin.H{
"ok": true,
"data": gin.H{
"top_services": []interface{}{},
"requests_by_day": []interface{}{},
"status_codes": []interface{}{},
},
})
}
+177
View File
@@ -0,0 +1,177 @@
package api
import (
"containr/internal/database"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AuditLog struct {
ID string `json:"id" db:"id"`
UserID string `json:"user_id" db:"user_id"`
UserEmail string `json:"user_email" db:"user_email"`
Resource string `json:"resource" db:"resource"`
ResourceID string `json:"resource_id" db:"resource_id"`
Action string `json:"action" db:"action"`
Details string `json:"details" db:"details"`
IPAddress string `json:"ip_address" db:"ip_address"`
UserAgent string `json:"user_agent" db:"user_agent"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type AuditLogDetail struct {
OldValue interface{} `json:"old_value,omitempty"`
NewValue interface{} `json:"new_value,omitempty"`
Message string `json:"message,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
func LogAudit(userID, resource, resourceID, action string, details map[string]interface{}) {
db := GetAuditDB()
if db == nil {
return
}
detailsJSON, _ := json.Marshal(details)
auditID := uuid.New().String()
_, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
auditID, userID, resource, resourceID, action, string(detailsJSON), time.Now(),
)
if err != nil {
}
}
func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) {
userID, _ := c.Get("user_id")
userEmail, _ := c.Get("user_email")
details["ip_address"] = c.ClientIP()
details["user_agent"] = c.GetHeader("User-Agent")
detailsJSON, _ := json.Marshal(details)
db := c.MustGet("db").(*database.DB)
auditID := uuid.New().String()
_, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, user_email, resource, resource_id, action, details, ip_address, user_agent, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
auditID, userID, userEmail, resource, resourceID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), time.Now(),
)
if err != nil {
}
}
var auditDB *database.DB
func GetAuditDB() *database.DB {
return auditDB
}
func SetAuditDB(db *database.DB) {
auditDB = db
}
func handleGetAuditLogs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
resource := c.Query("resource")
action := c.Query("action")
page := c.DefaultQuery("page", "1")
limit := c.DefaultQuery("limit", "50")
query := `SELECT id, user_id, COALESCE(user_email, '') as user_email, resource, resource_id, action, details,
COALESCE(ip_address, '') as ip_address, COALESCE(user_agent, '') as user_agent, created_at
FROM audit_logs WHERE user_id = $1`
args := []interface{}{userID}
argNum := 2
if resource != "" {
query += " AND resource = $" + string(rune('0'+argNum))
args = append(args, resource)
argNum++
}
if action != "" {
query += " AND action = $" + string(rune('0'+argNum))
args = append(args, action)
argNum++
}
query += " ORDER BY created_at DESC LIMIT $" + string(rune('0'+argNum)) + " OFFSET $" + string(rune('0'+argNum+1))
args = append(args, limit, (atoi(page)-1)*atoi(limit))
rows, err := db.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
return
}
defer rows.Close()
var logs []AuditLog
for rows.Next() {
var log AuditLog
err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
if err != nil {
continue
}
logs = append(logs, log)
}
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
}
func handleGetResourceAuditLogs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
resource := c.Param("resource")
resourceID := c.Param("id")
rows, err := db.Query(
`SELECT id, user_id, COALESCE(user_email, '') as user_email, resource, resource_id, action, details,
COALESCE(ip_address, '') as ip_address, COALESCE(user_agent, '') as user_agent, created_at
FROM audit_logs
WHERE user_id = $1 AND resource = $2 AND resource_id = $3
ORDER BY created_at DESC
LIMIT 100`,
userID, resource, resourceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit logs"})
return
}
defer rows.Close()
var logs []AuditLog
for rows.Next() {
var log AuditLog
err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
if err != nil {
continue
}
logs = append(logs, log)
}
c.JSON(http.StatusOK, gin.H{"audit_logs": logs})
}
func atoi(s string) int {
var result int
for _, c := range s {
if c >= '0' && c <= '9' {
result = result*10 + int(c-'0')
}
}
return result
}
+220
View File
@@ -0,0 +1,220 @@
package api
import (
"containr/internal/database"
"database/sql"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Name string `json:"name" binding:"required,min=2"`
}
type AuthResponse struct {
Token string `json:"token"`
User interface{} `json:"user"`
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url,omitempty"`
CreatedAt string `json:"created_at"`
}
func handleLogin(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
db := c.MustGet("db").(*database.DB)
jwtSecret := c.MustGet("jwt_secret").(string)
// Find user by email
var user User
var hashedPassword string
err := db.QueryRow(`
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
FROM users
WHERE email = $1
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt)
if err == sql.ErrNoRows {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
// Check password
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
// Generate JWT token
token, err := generateJWT(user.ID, user.Email, jwtSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, AuthResponse{
Token: token,
User: user,
})
}
func handleRegister(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
db := c.MustGet("db").(*database.DB)
jwtSecret := c.MustGet("jwt_secret").(string)
// Check if user already exists
var count int
err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
return
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
// Create user
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
// Generate JWT token
token, err := generateJWT(user.ID, user.Email, jwtSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusCreated, AuthResponse{
Token: token,
User: user,
})
}
func handleGetProfile(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var user User
err := db.QueryRow(`
SELECT id, email, name, COALESCE(avatar_url, ''), created_at
FROM users
WHERE id = $1
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
c.JSON(http.StatusOK, user)
}
func handleUpdateProfile(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req struct {
Name string `json:"name,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update user profile
_, err := db.Exec(`
UPDATE users
SET name = COALESCE($1, name), avatar_url = COALESCE($2, avatar_url)
WHERE id = $3
`, req.Name, req.AvatarURL, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
return
}
// Return updated user
handleGetProfile(c)
}
func generateJWT(userID, email, secret string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": userID,
"email": email,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
})
return token.SignedString([]byte(secret))
}
// ValidateJWT validates a JWT token and returns the claims
func ValidateJWT(tokenString, secret string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
return claims, nil
}
return nil, jwt.ErrInvalidKey
}
+368
View File
@@ -0,0 +1,368 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"containr/internal/build"
"containr/internal/docker"
"containr/internal/types"
"github.com/gin-gonic/gin"
)
// BuildHandler handles build-related API endpoints
type BuildHandler struct {
buildManager *build.BuildManager
dockerClient *docker.Client
}
func (h *BuildHandler) buildUnavailable(c *gin.Context) bool {
if h.buildManager != nil {
return false
}
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Build service is unavailable: Docker client not initialized",
})
return true
}
// NewBuildHandler creates a new build handler
func NewBuildHandler(buildManager *build.BuildManager, dockerClient *docker.Client) *BuildHandler {
return &BuildHandler{
buildManager: buildManager,
dockerClient: dockerClient,
}
}
// BuildRequest represents the request body for starting a build
type BuildRequest struct {
BuildType string `json:"build_type" binding:"required"`
SourcePath string `json:"source_path"`
PrebuiltImage string `json:"prebuilt_image"`
ImageName string `json:"image_name" binding:"required"`
ImageTag string `json:"image_tag" binding:"required"`
RegistryURL string `json:"registry_url"`
BuildCommand string `json:"build_command"`
StartCommand string `json:"start_command"`
Environment map[string]string `json:"environment"`
BuildArgs map[string]string `json:"build_args"`
Labels map[string]string `json:"labels"`
ProjectID string `json:"project_id"`
ServiceID string `json:"service_id"`
Branch string `json:"branch"`
Commit string `json:"commit"`
}
// BuildResponse represents the response for a build operation
type BuildResponse struct {
ID string `json:"id"`
Status string `json:"status"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Size int64 `json:"size"`
Digest string `json:"digest"`
BuildTime time.Time `json:"build_time"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// BuildStatusResponse represents the response for build status
type BuildStatusResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
ServiceID string `json:"service_id"`
Status string `json:"status"`
Progress int `json:"progress"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
Size int64 `json:"size"`
Error string `json:"error,omitempty"`
Log string `json:"log"`
Metadata map[string]string `json:"metadata"`
}
// BuildListResponse represents the response for listing builds
type BuildListResponse struct {
Builds []BuildStatusResponse `json:"builds"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// StartBuild starts a new build
// @Summary Start a build
// @Description Starts a new build process for the given configuration
// @Tags builds
// @Accept json
// @Produce json
// @Param request body BuildRequest true "Build request"
// @Success 200 {object} BuildResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/builds [post]
func (h *BuildHandler) StartBuild(c *gin.Context) {
if h.buildUnavailable(c) {
return
}
var req BuildRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Convert to internal build request
buildReq := &types.BuildRequest{
BuildType: req.BuildType,
SourcePath: req.SourcePath,
PrebuiltImage: req.PrebuiltImage,
ImageName: req.ImageName,
ImageTag: req.ImageTag,
RegistryURL: req.RegistryURL,
BuildCommand: req.BuildCommand,
StartCommand: req.StartCommand,
Environment: req.Environment,
BuildArgs: req.BuildArgs,
Labels: req.Labels,
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
TriggeredBy: "api",
Branch: req.Branch,
Commit: req.Commit,
}
// Validate build request
if err := h.buildManager.ValidateBuildRequest(c.Request.Context(), buildReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Start build (this would be async in production)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
_, err := h.buildManager.Build(ctx, buildReq)
if err != nil {
// Log error or update build status in database
return
}
}()
// For now, return a mock response
// In production, this would return the actual build ID and status
c.JSON(http.StatusOK, BuildResponse{
ID: "build-" + strconv.FormatInt(time.Now().Unix(), 10),
Status: "pending",
ImageName: req.ImageName,
ImageTag: req.ImageTag,
Success: true,
})
}
// GetBuildStatus gets the status of a build
// @Summary Get build status
// @Description Gets the current status of a build
// @Tags builds
// @Produce json
// @Param id path string true "Build ID"
// @Success 200 {object} BuildStatusResponse
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/builds/{id} [get]
func (h *BuildHandler) GetBuildStatus(c *gin.Context) {
buildID := c.Param("id")
// For now, return a mock response
// In production, this would query the database for the actual build status
c.JSON(http.StatusOK, BuildStatusResponse{
ID: buildID,
Status: "completed",
Progress: 100,
StartedAt: time.Now().Add(-10 * time.Minute),
ImageName: "example-app",
ImageTag: "latest",
Size: 1024 * 1024 * 100, // 100MB
})
}
// ListBuilds lists all builds
// @Summary List builds
// @Description Lists all builds with optional filtering
// @Tags builds
// @Produce json
// @Param project_id query string false "Filter by project ID"
// @Param service_id query string false "Filter by service ID"
// @Param status query string false "Filter by status"
// @Param page query int false "Page number" default(1)
// @Param limit query int false "Items per page" default(20)
// @Success 200 {object} BuildListResponse
// @Failure 500 {object} map[string]string
// @Router /api/v1/builds [get]
func (h *BuildHandler) ListBuilds(c *gin.Context) {
projectID := c.Query("project_id")
serviceID := c.Query("service_id")
status := c.Query("status")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
// For now, return mock data
// In production, this would query the database with filters
builds := []BuildStatusResponse{
{
ID: "build-1",
ProjectID: projectID,
ServiceID: serviceID,
Status: status,
Progress: 100,
StartedAt: time.Now().Add(-1 * time.Hour),
ImageName: "example-app",
ImageTag: "latest",
Size: 1024 * 1024 * 100,
},
}
c.JSON(http.StatusOK, BuildListResponse{
Builds: builds,
Total: len(builds),
Page: page,
Limit: limit,
})
}
// CancelBuild cancels a running build
// @Summary Cancel build
// @Description Cancels a running build
// @Tags builds
// @Produce json
// @Param id path string true "Build ID"
// @Success 200 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/builds/{id}/cancel [post]
func (h *BuildHandler) CancelBuild(c *gin.Context) {
buildID := c.Param("id")
// For now, just return success
// In production, this would actually cancel the build process
c.JSON(http.StatusOK, gin.H{
"message": "Build " + buildID + " cancelled",
})
}
// GetBuildLogs gets the logs for a build
// @Summary Get build logs
// @Description Gets the build logs for a specific build
// @Tags builds
// @Produce text
// @Param id path string true "Build ID"
// @Param follow query bool false "Follow logs" default(false)
// @Success 200 {string} string "Build logs"
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/builds/{id}/logs [get]
func (h *BuildHandler) GetBuildLogs(c *gin.Context) {
buildID := c.Param("id")
follow := c.DefaultQuery("follow", "false") == "true"
// For now, return mock logs
// In production, this would stream actual build logs
logs := "Build " + buildID + " started\n"
logs += "Detecting runtime...\n"
logs += "Runtime detected: node\n"
logs += "Building image...\n"
logs += "Build completed successfully\n"
if follow {
// In production, this would use Server-Sent Events to stream logs
c.Header("Content-Type", "text/plain")
c.String(http.StatusOK, logs)
} else {
c.Header("Content-Type", "text/plain")
c.String(http.StatusOK, logs)
}
}
// GetBuildPlan gets the build plan for a service
// @Summary Get build plan
// @Description Gets the build plan for a service without actually building
// @Tags builds
// @Produce json
// @Param request body BuildRequest true "Build request"
// @Success 200 {object} build.BuildPlan
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/builds/plan [post]
func (h *BuildHandler) GetBuildPlan(c *gin.Context) {
if h.buildUnavailable(c) {
return
}
var req BuildRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Convert to internal build request
buildReq := &types.BuildRequest{
BuildType: req.BuildType,
SourcePath: req.SourcePath,
PrebuiltImage: req.PrebuiltImage,
ImageName: req.ImageName,
ImageTag: req.ImageTag,
BuildCommand: req.BuildCommand,
StartCommand: req.StartCommand,
Environment: req.Environment,
BuildArgs: req.BuildArgs,
Labels: req.Labels,
}
// Get build plan
plan, err := h.buildManager.GetBuildPlan(c.Request.Context(), buildReq)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, plan)
}
// DetectBuildType detects the build type for a given repository
// @Summary Detect build type
// @Description Detects the build type based on repository contents
// @Tags builds
// @Produce json
// @Param source_path query string true "Source path"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/builds/detect [get]
func (h *BuildHandler) DetectBuildType(c *gin.Context) {
if h.buildUnavailable(c) {
return
}
sourcePath := c.Query("source_path")
if sourcePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "source_path is required"})
return
}
buildType, err := h.buildManager.DetectBuildType(c.Request.Context(), sourcePath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"build_type": string(buildType),
})
}
+416
View File
@@ -0,0 +1,416 @@
package api
import (
"containr/internal/database"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type CronJob struct {
ID string `json:"id" db:"id"`
ProjectID string `json:"project_id" db:"project_id"`
ServiceID string `json:"service_id" db:"service_id"`
Name string `json:"name" db:"name"`
Schedule string `json:"schedule" db:"schedule"`
Command string `json:"command" db:"command"`
Timezone string `json:"timezone" db:"timezone"`
Enabled bool `json:"enabled" db:"enabled"`
LastRunAt *time.Time `json:"last_run_at" db:"last_run_at"`
NextRunAt *time.Time `json:"next_run_at" db:"next_run_at"`
LastStatus string `json:"last_status" db:"last_status"`
LastOutput string `json:"last_output" db:"last_output"`
Retention int `json:"retention" db:"retention"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CronExecution struct {
ID string `json:"id" db:"id"`
CronJobID string `json:"cron_job_id" db:"cron_job_id"`
StartedAt time.Time `json:"started_at" db:"started_at"`
FinishedAt *time.Time `json:"finished_at" db:"finished_at"`
Status string `json:"status" db:"status"`
Output string `json:"output" db:"output"`
Error string `json:"error" db:"error"`
}
type CreateCronJobRequest struct {
ProjectID string `json:"project_id" binding:"required"`
ServiceID string `json:"service_id" binding:"required"`
Name string `json:"name" binding:"required"`
Schedule string `json:"schedule" binding:"required"`
Command string `json:"command" binding:"required"`
Timezone string `json:"timezone"`
Enabled bool `json:"enabled"`
Retention int `json:"retention"`
}
type UpdateCronJobRequest struct {
Name string `json:"name"`
Schedule string `json:"schedule"`
Command string `json:"command"`
Timezone string `json:"timezone"`
Enabled *bool `json:"enabled"`
Retention int `json:"retention"`
}
func handleGetCronJobs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
projectID := c.Query("project_id")
query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE p.owner_id = $1`
args := []interface{}{userID}
if projectID != "" {
query += " AND cj.project_id = $2"
args = append(args, projectID)
}
query += " ORDER BY cj.created_at DESC"
rows, err := db.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch cron jobs"})
return
}
defer rows.Close()
var jobs []CronJob
for rows.Next() {
var job CronJob
err := rows.Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
&job.Retention, &job.CreatedAt, &job.UpdatedAt)
if err != nil {
continue
}
jobs = append(jobs, job)
}
c.JSON(http.StatusOK, gin.H{"cron_jobs": jobs})
}
func handleCreateCronJob(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req CreateCronJobRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM projects p
JOIN services s ON s.project_id = p.id
WHERE s.id = $1`,
req.ServiceID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
if req.Timezone == "" {
req.Timezone = "UTC"
}
if req.Retention == 0 {
req.Retention = 30
}
nextRun := calculateNextRun(req.Schedule, req.Timezone)
job := CronJob{
ID: uuid.New().String(),
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
Name: req.Name,
Schedule: req.Schedule,
Command: req.Command,
Timezone: req.Timezone,
Enabled: req.Enabled,
NextRunAt: nextRun,
Retention: req.Retention,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err = db.Exec(
`INSERT INTO cron_jobs (id, project_id, service_id, name, schedule, command, timezone, enabled, next_run_at, retention, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
job.ID, job.ProjectID, job.ServiceID, job.Name, job.Schedule, job.Command, job.Timezone, job.Enabled, job.NextRunAt, job.Retention, job.CreatedAt, job.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cron job"})
return
}
LogAudit(userID, "cron_job", job.ID, "create", map[string]interface{}{
"name": job.Name,
"schedule": job.Schedule,
})
c.JSON(http.StatusCreated, gin.H{"cron_job": job})
}
func handleGetCronJob(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
jobID := c.Param("id")
var job CronJob
var ownerCheck string
err := db.QueryRow(
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at, p.owner_id
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
&job.Retention, &job.CreatedAt, &job.UpdatedAt, &ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
if ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
c.JSON(http.StatusOK, gin.H{"cron_job": job})
}
func handleUpdateCronJob(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
jobID := c.Param("id")
var req UpdateCronJobRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
updates := make(map[string]interface{})
if req.Name != "" {
updates["name"] = req.Name
}
if req.Schedule != "" {
updates["schedule"] = req.Schedule
updates["next_run_at"] = calculateNextRun(req.Schedule, "UTC")
}
if req.Command != "" {
updates["command"] = req.Command
}
if req.Timezone != "" {
updates["timezone"] = req.Timezone
}
if req.Enabled != nil {
updates["enabled"] = *req.Enabled
}
if req.Retention > 0 {
updates["retention"] = req.Retention
}
updates["updated_at"] = time.Now()
updateQuery := "UPDATE cron_jobs SET "
args := []interface{}{}
argNum := 1
for key, value := range updates {
if argNum > 1 {
updateQuery += ", "
}
updateQuery += key + " = $" + string(rune('0'+argNum))
args = append(args, value)
argNum++
}
updateQuery += " WHERE id = $" + string(rune('0'+argNum))
args = append(args, jobID)
_, err = db.Exec(updateQuery, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cron job"})
return
}
LogAudit(userID, "cron_job", jobID, "update", updates)
c.JSON(http.StatusOK, gin.H{"message": "Cron job updated successfully"})
}
func handleDeleteCronJob(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
jobID := c.Param("id")
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
_, err = db.Exec("DELETE FROM cron_jobs WHERE id = $1", jobID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete cron job"})
return
}
LogAudit(userID, "cron_job", jobID, "delete", nil)
c.JSON(http.StatusOK, gin.H{"message": "Cron job deleted successfully"})
}
func handleGetCronExecutions(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
jobID := c.Param("id")
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
rows, err := db.Query(
`SELECT id, cron_job_id, started_at, finished_at, status, output, error
FROM cron_executions
WHERE cron_job_id = $1
ORDER BY started_at DESC
LIMIT 100`,
jobID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch executions"})
return
}
defer rows.Close()
var executions []CronExecution
for rows.Next() {
var exec CronExecution
err := rows.Scan(&exec.ID, &exec.CronJobID, &exec.StartedAt, &exec.FinishedAt, &exec.Status, &exec.Output, &exec.Error)
if err != nil {
continue
}
executions = append(executions, exec)
}
c.JSON(http.StatusOK, gin.H{"executions": executions})
}
func handleTriggerCronJob(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
jobID := c.Param("id")
var job CronJob
var ownerCheck string
err := db.QueryRow(
`SELECT cj.command, p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&job.Command, &ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
execID := uuid.New().String()
now := time.Now()
_, err = db.Exec(
`INSERT INTO cron_executions (id, cron_job_id, started_at, status)
VALUES ($1, $2, $3, $4)`,
execID, jobID, now, "running",
)
go executeCronJob(jobID, execID, job.Command)
LogAudit(userID, "cron_job", jobID, "trigger", map[string]interface{}{
"execution_id": execID,
})
c.JSON(http.StatusOK, gin.H{
"message": "Cron job triggered",
"execution_id": execID,
})
}
func calculateNextRun(schedule, timezone string) *time.Time {
now := time.Now()
next := now.Add(1 * time.Hour)
return &next
}
func executeCronJob(jobID, execID, command string) {
db := auditDB
if db == nil {
return
}
time.Sleep(2 * time.Second)
now := time.Now()
db.Exec(
`UPDATE cron_executions SET finished_at = $1, status = $2, output = $3 WHERE id = $4`,
now, "success", "Job completed successfully", execID,
)
db.Exec(
`UPDATE cron_jobs SET last_run_at = $1, last_status = $2, next_run_at = $3 WHERE id = $4`,
now, "success", time.Now().Add(1*time.Hour), jobID,
)
}
func init() {
cronJobsData, _ := json.Marshal([]CronJob{})
_ = cronJobsData
}
+543
View File
@@ -0,0 +1,543 @@
package api
import (
"database/sql"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
_ "github.com/lib/pq"
)
// DatabaseService represents a managed database service
type DatabaseService struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Type string `json:"type" db:"type"` // postgresql, redis, mysql
Status string `json:"status" db:"status"` // running, stopped, building, error
Version string `json:"version" db:"version"`
Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business
Region string `json:"region" db:"region"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
ConnectionURL string `json:"connection_url"`
Metrics DatabaseMetrics `json:"metrics"`
Backups DatabaseBackupConfig `json:"backups"`
Settings DatabaseSettings `json:"settings"`
}
// DatabaseMetrics represents database performance metrics
type DatabaseMetrics struct {
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Storage float64 `json:"storage"`
Connections int `json:"connections"`
ReadIOPS int `json:"read_iops"`
WriteIOPS int `json:"write_iops"`
NetworkIn float64 `json:"network_in"`
NetworkOut float64 `json:"network_out"`
}
// DatabaseBackupConfig represents backup configuration
type DatabaseBackupConfig struct {
Enabled bool `json:"enabled"`
LastBackup *time.Time `json:"last_backup,omitempty"`
Retention int `json:"retention"` // days
NextBackup *time.Time `json:"next_backup,omitempty"`
Backups []DatabaseBackup `json:"backups"`
}
// DatabaseBackup represents a single backup
type DatabaseBackup struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Size string `json:"size"`
Status string `json:"status"` // completed, failed, in_progress
}
// DatabaseSettings represents database configuration
type DatabaseSettings struct {
MaxConnections int `json:"max_connections"`
Timeout int `json:"timeout"` // seconds
SSL bool `json:"ssl"`
Logging bool `json:"logging"`
}
// DatabaseCreateRequest represents a request to create a new database
type DatabaseCreateRequest struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required,oneof=postgresql redis mysql"`
Plan string `json:"plan" binding:"required,oneof=hobby starter standard business"`
Region string `json:"region" binding:"required"`
}
// DatabaseUpdateRequest represents a request to update a database
type DatabaseUpdateRequest struct {
Name string `json:"name,omitempty"`
Plan string `json:"plan,omitempty"`
}
// DatabaseActionRequest represents a request to perform database actions
type DatabaseActionRequest struct {
Action string `json:"action" binding:"required,oneof=start stop restart"`
}
// DatabaseBackupRequest represents a request to create a backup
type DatabaseBackupRequest struct {
DatabaseID string `json:"database_id" binding:"required"`
}
// DatabaseRestoreRequest represents a request to restore from backup
type DatabaseRestoreRequest struct {
DatabaseID string `json:"database_id" binding:"required"`
BackupID string `json:"backup_id" binding:"required"`
}
// DatabaseHandler handles database service operations
type DatabaseHandler struct {
db *sql.DB
}
// NewDatabaseHandler creates a new database handler
func NewDatabaseHandler(db *sql.DB) *DatabaseHandler {
return &DatabaseHandler{db: db}
}
// GetDatabases returns all database services for a user
func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
userID := c.GetString("userID")
query := `
SELECT id, name, type, status, version, plan, region, created_at, updated_at
FROM database_services
WHERE user_id = $1
ORDER BY created_at DESC
`
rows, err := h.db.Query(query, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch databases"})
return
}
defer rows.Close()
var databases []DatabaseService
for rows.Next() {
var db DatabaseService
err := rows.Scan(
&db.ID, &db.Name, &db.Type, &db.Status, &db.Version,
&db.Plan, &db.Region, &db.CreatedAt, &db.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan database"})
return
}
// Add mock metrics and configuration
db.Metrics = h.generateMockMetrics()
db.Backups = h.generateMockBackupConfig()
db.Settings = h.generateMockSettings()
db.ConnectionURL = h.generateConnectionURL(db)
databases = append(databases, db)
}
c.JSON(http.StatusOK, gin.H{"databases": databases})
}
// GetDatabase returns a specific database service
func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
userID := c.GetString("userID")
databaseID := c.Param("id")
query := `
SELECT id, name, type, status, version, plan, region, created_at, updated_at
FROM database_services
WHERE id = $1 AND user_id = $2
`
var db DatabaseService
err := h.db.QueryRow(query, databaseID, userID).Scan(
&db.ID, &db.Name, &db.Type, &db.Status, &db.Version,
&db.Plan, &db.Region, &db.CreatedAt, &db.UpdatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
return
}
// Add detailed metrics and configuration
db.Metrics = h.generateMockMetrics()
db.Backups = h.generateMockBackupConfig()
db.Settings = h.generateMockSettings()
db.ConnectionURL = h.generateConnectionURL(db)
c.JSON(http.StatusOK, db)
}
// CreateDatabase creates a new database service
func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
userID := c.GetString("userID")
var req DatabaseCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Generate database ID
databaseID := fmt.Sprintf("db_%d_%s", time.Now().Unix(), req.Name)
// Insert database into database
query := `
INSERT INTO database_services (id, user_id, name, type, status, version, plan, region, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
now := time.Now()
version := h.getDefaultVersion(req.Type)
_, err := h.db.Exec(query, databaseID, userID, req.Name, req.Type, "building", version, req.Plan, req.Region, now, now)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create database"})
return
}
// In a real implementation, this would trigger the actual database provisioning
// For now, we'll simulate it by updating the status to "running"
go h.provisionDatabase(databaseID)
c.JSON(http.StatusCreated, gin.H{
"id": databaseID,
"message": "Database provisioning started",
"status": "building",
})
}
// UpdateDatabase updates a database service
func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
userID := c.GetString("userID")
databaseID := c.Param("id")
var req DatabaseUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Build dynamic update query
setParts := []string{}
args := []interface{}{}
argIndex := 1
if req.Name != "" {
setParts = append(setParts, fmt.Sprintf("name = $%d", argIndex))
args = append(args, req.Name)
argIndex++
}
if req.Plan != "" {
setParts = append(setParts, fmt.Sprintf("plan = $%d", argIndex))
args = append(args, req.Plan)
argIndex++
}
if len(setParts) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
return
}
setParts = append(setParts, fmt.Sprintf("updated_at = $%d", argIndex))
args = append(args, time.Now())
argIndex++
args = append(args, databaseID, userID)
query := fmt.Sprintf(`
UPDATE database_services
SET %s
WHERE id = $%d AND user_id = $%d
`, fmt.Sprintf("%s", setParts), argIndex, argIndex+1)
_, err := h.db.Exec(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Database updated successfully"})
}
// DeleteDatabase deletes a database service
func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
userID := c.GetString("userID")
databaseID := c.Param("id")
// Check if database exists and belongs to user
var exists bool
checkQuery := "SELECT EXISTS(SELECT 1 FROM database_services WHERE id = $1 AND user_id = $2)"
err := h.db.QueryRow(checkQuery, databaseID, userID).Scan(&exists)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
// In a real implementation, this would trigger the actual database deprovisioning
// For now, we'll just delete the record
deleteQuery := "DELETE FROM database_services WHERE id = $1 AND user_id = $2"
_, err = h.db.Exec(deleteQuery, databaseID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete database"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Database deleted successfully"})
}
// PerformDatabaseAction performs actions on a database (start, stop, restart)
func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
userID := c.GetString("userID")
databaseID := c.Param("id")
var req DatabaseActionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if database exists and belongs to user
var exists bool
checkQuery := "SELECT EXISTS(SELECT 1 FROM database_services WHERE id = $1 AND user_id = $2)"
err := h.db.QueryRow(checkQuery, databaseID, userID).Scan(&exists)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
// Update database status based on action
var newStatus string
switch req.Action {
case "start":
newStatus = "running"
case "stop":
newStatus = "stopped"
case "restart":
newStatus = "building" // Will be updated to running after restart
go h.restartDatabase(databaseID)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"})
return
}
updateQuery := "UPDATE database_services SET status = $1, updated_at = $2 WHERE id = $3 AND user_id = $4"
_, err = h.db.Exec(updateQuery, newStatus, time.Now(), databaseID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database status"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("Database %s initiated", req.Action),
"status": newStatus,
})
}
// CreateBackup creates a backup of a database
func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
userID := c.GetString("userID")
databaseID := c.Param("id")
// Check if database exists and belongs to user
var exists bool
checkQuery := "SELECT EXISTS(SELECT 1 FROM database_services WHERE id = $1 AND user_id = $2)"
err := h.db.QueryRow(checkQuery, databaseID, userID).Scan(&exists)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
// Generate backup ID
backupID := fmt.Sprintf("backup_%d_%s", time.Now().Unix(), databaseID)
// In a real implementation, this would trigger the actual backup process
// For now, we'll simulate it
go h.createBackupProcess(databaseID, backupID)
c.JSON(http.StatusCreated, gin.H{
"backup_id": backupID,
"message": "Backup creation started",
"status": "in_progress",
})
}
// RestoreBackup restores a database from a backup
func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
userID := c.GetString("userID")
databaseID := c.Param("id")
var req DatabaseRestoreRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if database exists and belongs to user
var exists bool
checkQuery := "SELECT EXISTS(SELECT 1 FROM database_services WHERE id = $1 AND user_id = $2)"
err := h.db.QueryRow(checkQuery, databaseID, userID).Scan(&exists)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check database"})
return
}
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
return
}
// In a real implementation, this would trigger the actual restore process
// For now, we'll simulate it
go h.restoreBackupProcess(databaseID, req.BackupID)
c.JSON(http.StatusOK, gin.H{
"message": "Database restore started",
"status": "in_progress",
})
}
// Helper functions for mock data generation
func (h *DatabaseHandler) generateMockMetrics() DatabaseMetrics {
return DatabaseMetrics{
CPU: 25.0 + (float64(time.Now().Unix() % 50)),
Memory: 60.0 + (float64(time.Now().Unix() % 30)),
Storage: 45.0 + (float64(time.Now().Unix() % 40)),
Connections: 10 + (int(time.Now().Unix() % 20)),
ReadIOPS: 150 + (int(time.Now().Unix() % 100)),
WriteIOPS: 80 + (int(time.Now().Unix() % 50)),
NetworkIn: 2.5 + (float64(time.Now().Unix()%10))/10,
NetworkOut: 1.8 + (float64(time.Now().Unix()%8))/10,
}
}
func (h *DatabaseHandler) generateMockBackupConfig() DatabaseBackupConfig {
return DatabaseBackupConfig{
Enabled: true,
LastBackup: &time.Time{},
Retention: 30,
NextBackup: &time.Time{},
Backups: []DatabaseBackup{
{
ID: "backup_1",
CreatedAt: time.Now().Add(-6 * time.Hour),
Size: "245 MB",
Status: "completed",
},
{
ID: "backup_2",
CreatedAt: time.Now().Add(-24 * time.Hour),
Size: "238 MB",
Status: "completed",
},
{
ID: "backup_3",
CreatedAt: time.Now().Add(-48 * time.Hour),
Size: "241 MB",
Status: "completed",
},
},
}
}
func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
return DatabaseSettings{
MaxConnections: 100,
Timeout: 30,
SSL: true,
Logging: true,
}
}
func (h *DatabaseHandler) generateConnectionURL(db DatabaseService) string {
switch db.Type {
case "postgresql":
return fmt.Sprintf("postgresql://user:password@%s.containr.local:5432/%s", db.Name, db.Name)
case "redis":
return fmt.Sprintf("redis://%s.containr.local:6379", db.Name)
case "mysql":
return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", db.Name, db.Name)
default:
return ""
}
}
func (h *DatabaseHandler) getDefaultVersion(dbType string) string {
switch dbType {
case "postgresql":
return "15.4"
case "redis":
return "7.2"
case "mysql":
return "8.0"
default:
return "latest"
}
}
// Mock provisioning functions (in real implementation, these would interact with container orchestration)
func (h *DatabaseHandler) provisionDatabase(databaseID string) {
// Simulate provisioning time
time.Sleep(30 * time.Second)
// Update status to running
query := "UPDATE database_services SET status = 'running', updated_at = $1 WHERE id = $2"
h.db.Exec(query, time.Now(), databaseID)
}
func (h *DatabaseHandler) restartDatabase(databaseID string) {
// Simulate restart time
time.Sleep(10 * time.Second)
// Update status to running
query := "UPDATE database_services SET status = 'running', updated_at = $1 WHERE id = $2"
h.db.Exec(query, time.Now(), databaseID)
}
func (h *DatabaseHandler) createBackupProcess(databaseID, backupID string) {
// Simulate backup creation time
time.Sleep(5 * time.Minute)
// In a real implementation, this would store backup metadata
// For now, we'll just log it
fmt.Printf("Backup %s created for database %s\n", backupID, databaseID)
}
func (h *DatabaseHandler) restoreBackupProcess(databaseID, backupID string) {
// Simulate restore time
time.Sleep(10 * time.Minute)
// In a real implementation, this would restore the database
// For now, we'll just log it
fmt.Printf("Database %s restored from backup %s\n", databaseID, backupID)
}
+569
View File
@@ -0,0 +1,569 @@
package api
import (
"containr/internal/database"
"containr/internal/deployment"
"context"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type DeploymentModel struct {
ID uuid.UUID `json:"id" db:"id"`
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
CommitHash *string `json:"commit_hash" db:"commit_hash"`
Status string `json:"status" db:"status"`
ImageName string `json:"image_name" db:"image_name"`
ImageTag string `json:"image_tag" db:"image_tag"`
BuildLog string `json:"build_log" db:"build_log"`
RuntimeLog string `json:"runtime_log" db:"runtime_log"`
Error *string `json:"error" db:"error"`
StartedAt *time.Time `json:"started_at" db:"started_at"`
CompletedAt *time.Time `json:"completed_at" db:"completed_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CreateDeploymentRequest struct {
CommitHash string `json:"commit_hash"`
Branch string `json:"branch"`
Trigger string `json:"trigger"`
EnvVars map[string]string `json:"env_vars"`
}
type DeploymentResponse struct {
ID uuid.UUID `json:"id"`
ServiceID uuid.UUID `json:"service_id"`
CommitHash *string `json:"commit_hash"`
Status string `json:"status"`
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
Error *string `json:"error,omitempty"`
}
func handleGetDeployments(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
LIMIT 50`,
serviceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
return
}
defer rows.Close()
var deployments []DeploymentModel
for rows.Next() {
var d DeploymentModel
err := rows.Scan(
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
&d.CreatedAt, &d.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"})
return
}
deployments = append(deployments, d)
}
c.JSON(http.StatusOK, gin.H{"deployments": deployments})
}
func handleCreateDeployment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
var req CreateDeploymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Trigger == "" {
req.Trigger = "manual"
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var service Service
var projectOwner string
err = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
s.created_at, s.updated_at, p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.CreatedAt, &service.UpdatedAt, &projectOwner,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if projectOwner != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
if req.Branch == "" {
req.Branch = service.GitBranch
}
now := time.Now()
var commitHash *string
if trimmed := strings.TrimSpace(req.CommitHash); trimmed != "" {
commitHash = &trimmed
}
d := DeploymentModel{
ID: uuid.New(),
ServiceID: serviceID,
CommitHash: commitHash,
Status: "pending",
ImageName: "",
ImageTag: "",
CreatedAt: now,
UpdatedAt: now,
}
_, err = db.(*database.DB).Exec(
`INSERT INTO deployments
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment"})
return
}
engine, exists := c.Get("deployment_engine")
if !exists || engine == nil {
unavailableErr := "Deployment engine unavailable. Docker may not be configured on this server."
completedAt := time.Now()
_, _ = db.(*database.DB).Exec(
`UPDATE deployments
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
WHERE id = $3`,
unavailableErr, completedAt, d.ID,
)
d.Status = "failed"
d.Error = &unavailableErr
d.CompletedAt = &completedAt
} else {
_, err = db.(*database.DB).Exec(
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service status"})
return
}
engineInstance := engine.(*deployment.DeploymentEngine)
go runDeploymentAndSync(context.Background(), db.(*database.DB), engineInstance, &d, service, req, userID.(string))
}
c.JSON(http.StatusCreated, DeploymentResponse{
ID: d.ID,
ServiceID: d.ServiceID,
CommitHash: d.CommitHash,
Status: d.Status,
Error: d.Error,
CompletedAt: d.CompletedAt,
CreatedAt: d.CreatedAt,
})
}
func runDeploymentAndSync(
parentCtx context.Context,
db *database.DB,
engine *deployment.DeploymentEngine,
dbDeployment *DeploymentModel,
service Service,
req CreateDeploymentRequest,
userID string,
) {
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
defer cancel()
sourcePath := strings.TrimSpace(service.BuildPath)
if sourcePath == "" {
sourcePath = "."
}
deployReq := &deployment.DeploymentRequest{
ProjectID: service.ProjectID.String(),
ServiceID: service.ID.String(),
Environment: service.Environment,
Config: deployment.ServiceConfig{
Name: service.Name,
Image: service.Image,
Environment: req.EnvVars,
Replicas: 1,
},
BuildConfig: &deployment.BuildConfig{
BuildType: "nixpacks",
SourcePath: sourcePath,
Branch: req.Branch,
Commit: req.CommitHash,
},
Trigger: deployment.TriggerConfig{
Type: req.Trigger,
Source: "api",
User: userID,
Timestamp: time.Now(),
},
}
engineDeployment, err := engine.Deploy(ctx, deployReq)
if err != nil {
failedAt := time.Now()
failure := "Failed to start deployment engine: " + err.Error()
_, _ = db.Exec(
`UPDATE deployments
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
WHERE id = $3`,
failure, failedAt, dbDeployment.ID,
)
_, _ = db.Exec(
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
failedAt, service.ID,
)
return
}
syncTicker := time.NewTicker(1 * time.Second)
defer syncTicker.Stop()
for {
select {
case <-ctx.Done():
failedAt := time.Now()
timeoutErr := "Deployment timed out before completion"
_, _ = db.Exec(
`UPDATE deployments
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
WHERE id = $3`,
timeoutErr, failedAt, dbDeployment.ID,
)
_, _ = db.Exec(
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
failedAt, service.ID,
)
return
case <-syncTicker.C:
current, getErr := engine.GetDeployment(engineDeployment.ID)
if getErr != nil {
continue
}
dbStatus := mapEngineStatusToDBStatus(current.Status)
imageName, imageTag := splitImageReference(current.ImageName, dbDeployment.ImageTag)
var dbError interface{}
if current.Error != "" {
dbError = current.Error
}
_, _ = db.Exec(
`UPDATE deployments
SET status = $1,
image_name = $2,
image_tag = $3,
build_log = $4,
runtime_log = $5,
error = $6,
started_at = $7,
completed_at = $8,
updated_at = $9
WHERE id = $10`,
dbStatus,
imageName,
imageTag,
current.BuildLog,
current.DeployLog,
dbError,
current.StartedAt,
current.CompletedAt,
time.Now(),
dbDeployment.ID,
)
switch dbStatus {
case "deployed":
_, _ = db.Exec(
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
return
case "failed":
_, _ = db.Exec(
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
return
}
}
}
}
func mapEngineStatusToDBStatus(status string) string {
switch status {
case "running":
return "deployed"
case "cancelled":
return "failed"
default:
return status
}
}
func splitImageReference(image, fallbackTag string) (string, string) {
if image == "" {
return "", fallbackTag
}
lastSlash := strings.LastIndex(image, "/")
lastColon := strings.LastIndex(image, ":")
if lastColon > lastSlash && !strings.Contains(image[lastColon:], "@") {
return image[:lastColon], image[lastColon+1:]
}
return image, fallbackTag
}
func handleGetDeployment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
deploymentIDStr := c.Param("id")
deploymentID, err := uuid.Parse(deploymentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var d DeploymentModel
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
&d.CreatedAt, &d.UpdatedAt, &ownerCheck,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
c.JSON(http.StatusOK, gin.H{"deployment": d})
}
func handleRollbackDeployment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
deploymentIDStr := c.Param("id")
deploymentID, err := uuid.Parse(deploymentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var targetDeployment DeploymentModel
var serviceID uuid.UUID
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(
&targetDeployment.ID, &serviceID, &targetDeployment.CommitHash, &targetDeployment.Status,
&targetDeployment.ImageName, &targetDeployment.ImageTag, &targetDeployment.BuildLog,
&targetDeployment.RuntimeLog, &targetDeployment.Error, &targetDeployment.StartedAt,
&targetDeployment.CompletedAt, &targetDeployment.CreatedAt, &targetDeployment.UpdatedAt,
&ownerCheck,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
if targetDeployment.Status != "deployed" && targetDeployment.Status != "failed" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Can only rollback completed or failed deployments"})
return
}
now := time.Now()
rollbackID := uuid.New()
rollback := DeploymentModel{
ID: rollbackID,
ServiceID: serviceID,
CommitHash: targetDeployment.CommitHash,
Status: "rolling_back",
ImageName: targetDeployment.ImageName,
ImageTag: targetDeployment.ImageTag,
CreatedAt: now,
UpdatedAt: now,
}
_, err = db.(*database.DB).Exec(
`INSERT INTO deployments
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
rollback.ID, rollback.ServiceID, rollback.CommitHash, rollback.Status,
rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create rollback deployment"})
return
}
_, err = db.(*database.DB).Exec(
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
go func() {
time.Sleep(2 * time.Second)
db.(*database.DB).Exec(
`UPDATE deployments SET status = 'deployed', completed_at = $1, updated_at = $1 WHERE id = $2`,
time.Now(), rollbackID,
)
db.(*database.DB).Exec(
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
time.Now(), serviceID,
)
}()
c.JSON(http.StatusCreated, gin.H{
"deployment": DeploymentResponse{
ID: rollback.ID,
ServiceID: rollback.ServiceID,
CommitHash: rollback.CommitHash,
Status: rollback.Status,
ImageName: rollback.ImageName,
ImageTag: rollback.ImageTag,
CreatedAt: rollback.CreatedAt,
},
"message": "Rollback initiated",
})
}
+784
View File
@@ -0,0 +1,784 @@
package api
import (
"bytes"
"containr/internal/database"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// GitProvider represents a Git provider (GitHub, GitLab, Bitbucket)
type GitProvider struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"` // github, gitlab, bitbucket
DisplayName string `json:"display_name" db:"display_name"`
APIUrl string `json:"api_url" db:"api_url"`
WebhookUrl string `json:"webhook_url" db:"webhook_url"`
UserID string `json:"user_id" db:"user_id"`
AccessToken string `json:"-" db:"access_token"` // Hidden in JSON responses
CreatedAt string `json:"created_at" db:"created_at"`
UpdatedAt string `json:"updated_at" db:"updated_at"`
}
// GitRepository represents a connected Git repository
type GitRepository struct {
ID string `json:"id" db:"id"`
ProviderID string `json:"provider_id" db:"provider_id"`
Name string `json:"name" db:"name"`
FullName string `json:"full_name" db:"full_name"`
Description string `json:"description" db:"description"`
CloneURL string `json:"clone_url" db:"clone_url"`
WebhookURL string `json:"webhook_url" db:"webhook_url"`
DefaultBranch string `json:"default_branch" db:"default_branch"`
IsPrivate bool `json:"is_private" db:"is_private"`
UserID string `json:"user_id" db:"user_id"`
CreatedAt string `json:"created_at" db:"created_at"`
UpdatedAt string `json:"updated_at" db:"updated_at"`
}
// GitWebhook represents a webhook configuration
type GitWebhook struct {
ID string `json:"id" db:"id"`
RepoID string `json:"repo_id" db:"repo_id"`
ProviderID string `json:"provider_id" db:"provider_id"`
Events string `json:"events" db:"events"` // JSON array of events
Secret string `json:"-" db:"webhook_secret"` // Hidden in JSON responses
RemoteWebhookID string `json:"remote_webhook_id" db:"remote_webhook_id"`
Active bool `json:"active" db:"active"`
BranchFilter string `json:"branch_filter,omitempty" db:"branch_filter"`
CreatedAt string `json:"created_at" db:"created_at"`
UpdatedAt string `json:"updated_at" db:"updated_at"`
}
// CreateGitProviderRequest represents a request to create a Git provider
type CreateGitProviderRequest struct {
Name string `json:"name" binding:"required,oneof=github gitlab bitbucket"`
DisplayName string `json:"display_name" binding:"required"`
AccessToken string `json:"access_token" binding:"required"`
}
// CreateGitRepoRequest represents a request to connect a Git repository
type CreateGitRepoRequest struct {
ProviderID string `json:"provider_id" binding:"required"`
RepoFullName string `json:"repo_full_name" binding:"required"`
}
// CreateWebhookRequest represents a request to create a webhook
type CreateWebhookRequest struct {
RepoID string `json:"repo_id" binding:"required"`
Events []string `json:"events" binding:"required"`
Branch string `json:"branch"`
}
type GitBranch struct {
Name string `json:"name"`
CommitHash string `json:"commit_hash"`
IsDefault bool `json:"is_default"`
}
type githubRepo struct {
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
CloneURL string `json:"clone_url"`
HTMLURL string `json:"html_url"`
DefaultBranch string `json:"default_branch"`
Private bool `json:"private"`
}
type githubBranch struct {
Name string `json:"name"`
Commit struct {
SHA string `json:"sha"`
} `json:"commit"`
}
type githubUser struct {
Login string `json:"login"`
}
func handleGetGitProviders(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
rows, err := db.Query(`
SELECT id, name, display_name, api_url, webhook_url, user_id, created_at, updated_at
FROM git_providers
WHERE user_id = $1
ORDER BY created_at DESC
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
defer rows.Close()
var providers []GitProvider
for rows.Next() {
var provider GitProvider
if err := rows.Scan(&provider.ID, &provider.Name, &provider.DisplayName, &provider.APIUrl,
&provider.WebhookUrl, &provider.UserID, &provider.CreatedAt, &provider.UpdatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
providers = append(providers, provider)
}
c.JSON(http.StatusOK, gin.H{"providers": providers})
}
func handleCreateGitProvider(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req CreateGitProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate the access token by making a test API call
if !validateGitToken(req.Name, req.AccessToken) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid access token for " + req.Name})
return
}
provider := GitProvider{
ID: uuid.New().String(),
Name: req.Name,
DisplayName: req.DisplayName,
AccessToken: req.AccessToken,
UserID: userID,
}
// Set provider-specific URLs
switch req.Name {
case "github":
provider.APIUrl = "https://api.github.com"
provider.WebhookUrl = "https://api.github.com"
case "gitlab":
provider.APIUrl = "https://gitlab.com/api/v4"
provider.WebhookUrl = "https://gitlab.com"
case "bitbucket":
provider.APIUrl = "https://api.bitbucket.org/2.0"
provider.WebhookUrl = "https://api.bitbucket.org/2.0"
}
_, err := db.Exec(`
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
`, provider.ID, provider.Name, provider.DisplayName, provider.APIUrl,
provider.WebhookUrl, provider.AccessToken, provider.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Git provider"})
return
}
// Return provider without access token
provider.AccessToken = ""
c.JSON(http.StatusCreated, provider)
}
func handleGetGitRepositories(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
providerID := c.Param("providerId")
// Validate UUID
if _, err := uuid.Parse(providerID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
return
}
// Get provider info
var provider GitProvider
err := db.QueryRow(`
SELECT id, name, access_token, api_url
FROM git_providers
WHERE id = $1 AND user_id = $2
`, providerID, userID).Scan(&provider.ID, &provider.Name, &provider.AccessToken, &provider.APIUrl)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Git provider not found"})
return
}
// Fetch repositories from the Git provider
repos, err := fetchGitRepositories(provider.Name, provider.AccessToken, provider.APIUrl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repositories"})
return
}
c.JSON(http.StatusOK, gin.H{"repositories": repos})
}
func handleConnectGitRepository(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req CreateGitRepoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate UUID
if _, err := uuid.Parse(req.ProviderID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
return
}
// Get provider info
var provider GitProvider
err := db.QueryRow(`
SELECT id, name, access_token, api_url
FROM git_providers
WHERE id = $1 AND user_id = $2
`, req.ProviderID, userID).Scan(&provider.ID, &provider.Name, &provider.AccessToken, &provider.APIUrl)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Git provider not found"})
return
}
// Fetch repository details from Git provider
repoDetails, err := fetchGitRepositoryDetails(provider.Name, req.RepoFullName, provider.AccessToken, provider.APIUrl)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repository details"})
return
}
// Check if repository is already connected
var existingID string
err = db.QueryRow(`
SELECT id FROM git_repositories
WHERE provider_id = $1 AND full_name = $2
`, req.ProviderID, req.RepoFullName).Scan(&existingID)
if err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "Repository already connected", "repository_id": existingID})
return
}
// Create repository record
repo := GitRepository{
ID: uuid.New().String(),
ProviderID: req.ProviderID,
Name: repoDetails["name"].(string),
FullName: req.RepoFullName,
Description: repoDetails["description"].(string),
CloneURL: repoDetails["clone_url"].(string),
DefaultBranch: repoDetails["default_branch"].(string),
IsPrivate: repoDetails["private"].(bool),
UserID: userID,
}
_, err = db.Exec(`
INSERT INTO git_repositories (id, provider_id, name, full_name, description, clone_url,
default_branch, is_private, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
`, repo.ID, repo.ProviderID, repo.Name, repo.FullName, repo.Description,
repo.CloneURL, repo.DefaultBranch, repo.IsPrivate, repo.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect repository"})
return
}
c.JSON(http.StatusCreated, repo)
}
func handleCreateWebhook(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req CreateWebhookRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate UUIDs
if _, err := uuid.Parse(req.RepoID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository ID"})
return
}
// Get repository and provider info
var repo GitRepository
var provider GitProvider
err := db.QueryRow(`
SELECT r.id, r.provider_id, r.full_name, r.user_id,
p.id, p.name, p.access_token, p.api_url, p.webhook_url
FROM git_repositories r
JOIN git_providers p ON r.provider_id = p.id
WHERE r.id = $1 AND r.user_id = $2
`, req.RepoID, userID).Scan(&repo.ID, &repo.ProviderID, &repo.FullName, &repo.UserID,
&provider.ID, &provider.Name, &provider.AccessToken, &provider.APIUrl, &provider.WebhookUrl)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
return
}
// Convert events to JSON
eventsJSON, _ := json.Marshal(req.Events)
webhookSecret := generateWebhookSecret()
// Create webhook on Git provider
webhookURL := fmt.Sprintf("%s/api/v1/webhooks/git/%s", publicBaseURL(c), req.RepoID)
remoteWebhookID, err := createGitWebhook(provider.Name, repo.FullName, provider.AccessToken,
provider.APIUrl, webhookURL, req.Events, webhookSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create webhook on Git provider: " + err.Error()})
return
}
// Create webhook record
webhook := GitWebhook{
ID: uuid.New().String(),
RepoID: req.RepoID,
ProviderID: provider.ID,
Events: string(eventsJSON),
Secret: webhookSecret,
RemoteWebhookID: remoteWebhookID,
Active: true,
BranchFilter: req.Branch,
}
_, err = db.Exec(`
INSERT INTO git_webhooks (id, repo_id, provider_id, events, webhook_secret, remote_webhook_id, active, branch_filter, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
`, webhook.ID, webhook.RepoID, webhook.ProviderID, webhook.Events, webhook.Secret, webhook.RemoteWebhookID, webhook.Active, nullableString(webhook.BranchFilter))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create webhook"})
return
}
c.JSON(http.StatusCreated, gin.H{
"webhook": webhook,
"remote_webhook_id": remoteWebhookID,
})
}
func handleGetConnectedRepositories(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
offset := (page - 1) * limit
rows, err := db.Query(`
SELECT r.id, r.provider_id, r.name, r.full_name, r.description, r.clone_url,
r.default_branch, r.is_private, r.user_id, r.created_at, r.updated_at,
p.name as provider_name, p.display_name
FROM git_repositories r
JOIN git_providers p ON r.provider_id = p.id
WHERE r.user_id = $1
ORDER BY r.updated_at DESC
LIMIT $2 OFFSET $3
`, userID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
defer rows.Close()
var repositories []map[string]interface{}
for rows.Next() {
var repo GitRepository
var providerName, providerDisplayName string
if err := rows.Scan(&repo.ID, &repo.ProviderID, &repo.Name, &repo.FullName, &repo.Description,
&repo.CloneURL, &repo.DefaultBranch, &repo.IsPrivate, &repo.UserID, &repo.CreatedAt, &repo.UpdatedAt,
&providerName, &providerDisplayName); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
repositories = append(repositories, map[string]interface{}{
"id": repo.ID,
"provider_id": repo.ProviderID,
"name": repo.Name,
"full_name": repo.FullName,
"description": repo.Description,
"clone_url": repo.CloneURL,
"default_branch": repo.DefaultBranch,
"is_private": repo.IsPrivate,
"created_at": repo.CreatedAt,
"updated_at": repo.UpdatedAt,
"provider": map[string]string{
"name": providerName,
"display_name": providerDisplayName,
},
})
}
// Get total count
var total int
err = db.QueryRow(`
SELECT COUNT(*) FROM git_repositories WHERE user_id = $1
`, userID).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
c.JSON(http.StatusOK, gin.H{
"repositories": repositories,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
},
})
}
func handleGetRepositoryBranches(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
repoID := c.Param("repoId")
if _, err := uuid.Parse(repoID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository ID"})
return
}
var repo GitRepository
var provider GitProvider
err := db.QueryRow(`
SELECT r.id, r.full_name, r.default_branch,
p.id, p.name, p.access_token, p.api_url
FROM git_repositories r
JOIN git_providers p ON r.provider_id = p.id
WHERE r.id = $1 AND r.user_id = $2
`, repoID, userID).Scan(&repo.ID, &repo.FullName, &repo.DefaultBranch,
&provider.ID, &provider.Name, &provider.AccessToken, &provider.APIUrl)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
return
}
branches, err := fetchGitBranches(provider.Name, provider.AccessToken, provider.APIUrl, repo.FullName, repo.DefaultBranch)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch branches: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"branches": branches})
}
// Helper functions (these would need to be implemented with actual Git provider API calls)
func validateGitToken(provider, token string) bool {
if strings.TrimSpace(token) == "" {
return false
}
if provider != "github" {
return true
}
body, status, err := gitProviderRequest("GET", "https://api.github.com", token, "/user", nil)
if err != nil || status < 200 || status >= 300 {
return false
}
var user githubUser
return json.Unmarshal(body, &user) == nil && user.Login != ""
}
func fetchGitRepositories(provider, token, apiUrl string) ([]map[string]interface{}, error) {
if provider != "github" {
return nil, fmt.Errorf("%s repository listing is not implemented yet", provider)
}
body, status, err := gitProviderRequest(
"GET",
apiUrl,
token,
"/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member",
nil,
)
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, providerStatusError(status, body)
}
var repos []githubRepo
if err := json.Unmarshal(body, &repos); err != nil {
return nil, err
}
result := make([]map[string]interface{}, 0, len(repos))
for _, repo := range repos {
result = append(result, map[string]interface{}{
"name": repo.Name,
"full_name": repo.FullName,
"description": repo.Description,
"clone_url": repo.CloneURL,
"default_branch": repo.DefaultBranch,
"private": repo.Private,
"html_url": repo.HTMLURL,
})
}
return result, nil
}
func fetchGitRepositoryDetails(provider, repoFullName, token, apiUrl string) (map[string]interface{}, error) {
if provider != "github" {
return nil, fmt.Errorf("%s repository details are not implemented yet", provider)
}
path, err := githubRepoPath(repoFullName, "")
if err != nil {
return nil, err
}
body, status, err := gitProviderRequest("GET", apiUrl, token, path, nil)
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, providerStatusError(status, body)
}
var repo githubRepo
if err := json.Unmarshal(body, &repo); err != nil {
return nil, err
}
return map[string]interface{}{
"name": repo.Name,
"description": repo.Description,
"clone_url": repo.CloneURL,
"default_branch": repo.DefaultBranch,
"private": repo.Private,
"html_url": repo.HTMLURL,
}, nil
}
func fetchGitBranches(provider, token, apiUrl, repoFullName, defaultBranch string) ([]GitBranch, error) {
if provider != "github" {
return nil, fmt.Errorf("%s branch listing is not implemented yet", provider)
}
path, err := githubRepoPath(repoFullName, "/branches?per_page=100")
if err != nil {
return nil, err
}
body, status, err := gitProviderRequest("GET", apiUrl, token, path, nil)
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, providerStatusError(status, body)
}
var rawBranches []githubBranch
if err := json.Unmarshal(body, &rawBranches); err != nil {
return nil, err
}
branches := make([]GitBranch, 0, len(rawBranches))
for _, branch := range rawBranches {
branches = append(branches, GitBranch{
Name: branch.Name,
CommitHash: branch.Commit.SHA,
IsDefault: branch.Name == defaultBranch,
})
}
return branches, nil
}
func createGitWebhook(provider, repoFullName, token, apiUrl, targetURL string, events []string, secret string) (string, error) {
if provider != "github" {
return "", fmt.Errorf("%s webhooks are not implemented yet", provider)
}
path, err := githubRepoPath(repoFullName, "/hooks")
if err != nil {
return "", err
}
payload := map[string]interface{}{
"name": "web",
"active": true,
"events": events,
"config": map[string]string{
"url": targetURL,
"content_type": "json",
"secret": secret,
"insecure_ssl": "0",
},
}
data, _ := json.Marshal(payload)
body, status, err := gitProviderRequest("POST", apiUrl, token, path, bytes.NewReader(data))
if err != nil {
return "", err
}
if status < 200 || status >= 300 {
return "", providerStatusError(status, body)
}
var response struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(body, &response); err != nil {
return "", err
}
if response.ID == 0 {
return "", fmt.Errorf("GitHub returned an empty webhook ID")
}
return strconv.FormatInt(response.ID, 10), nil
}
func generateWebhookSecret() string {
return "webhook-secret-" + uuid.New().String()
}
func gitProviderRequest(method, apiUrl, token, path string, body io.Reader) ([]byte, int, error) {
base := strings.TrimRight(apiUrl, "/")
if base == "" {
base = "https://api.github.com"
}
req, err := http.NewRequest(method, base+path, body)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/vnd.github+json")
if strings.TrimSpace(token) != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
return data, resp.StatusCode, nil
}
func githubRepoPath(repoFullName, suffix string) (string, error) {
parts := strings.Split(repoFullName, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", fmt.Errorf("repository must use owner/name format")
}
return "/repos/" + url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1]) + suffix, nil
}
func providerStatusError(status int, body []byte) error {
var response struct {
Message string `json:"message"`
}
if err := json.Unmarshal(body, &response); err == nil && response.Message != "" {
return fmt.Errorf("provider returned %d: %s", status, response.Message)
}
return fmt.Errorf("provider returned %d", status)
}
func publicBaseURL(c *gin.Context) string {
for _, key := range []string{"CONTAINR_PUBLIC_URL", "PUBLIC_URL", "APP_URL"} {
if value := strings.TrimRight(os.Getenv(key), "/"); value != "" {
return value
}
}
scheme := "http"
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := c.Request.Host
if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
}
return scheme + "://" + host
}
func nullableString(value string) any {
if strings.TrimSpace(value) == "" {
return nil
}
return value
}
func fetchGitHubFile(provider, token, apiUrl, repoFullName, branch, filePath string) ([]byte, error) {
if provider != "github" {
return nil, fmt.Errorf("%s file fetch is not implemented yet", provider)
}
suffix := "/contents/" + strings.TrimLeft(filePath, "/")
if branch != "" {
suffix += "?ref=" + url.QueryEscape(branch)
}
path, err := githubRepoPath(repoFullName, suffix)
if err != nil {
return nil, err
}
body, status, err := gitProviderRequest("GET", apiUrl, token, path, nil)
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, providerStatusError(status, body)
}
var response struct {
Content string `json:"content"`
Encoding string `json:"encoding"`
}
if err := json.Unmarshal(body, &response); err != nil {
return nil, err
}
if response.Encoding != "base64" {
return nil, fmt.Errorf("unsupported GitHub content encoding: %s", response.Encoding)
}
decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(response.Content, "\n", ""))
if err != nil {
return nil, err
}
return decoded, nil
}
+607
View File
@@ -0,0 +1,607 @@
package api
import (
"net/http"
"strconv"
"time"
"containr/internal/ha"
"github.com/gin-gonic/gin"
)
// HAManager handles high availability API endpoints
type HAManager struct {
haManager *ha.HighAvailabilityManager
}
// NewHAManager creates a new HA manager handler
func NewHAManager(haManager *ha.HighAvailabilityManager) *HAManager {
return &HAManager{
haManager: haManager,
}
}
// RegisterRoutes registers HA routes
func (h *HAManager) RegisterRoutes(router *gin.RouterGroup) {
ha := router.Group("/ha")
{
ha.GET("/status", h.GetHAStatus)
ha.POST("/enable", h.EnableHA)
ha.POST("/disable", h.DisableHA)
ha.POST("/failover", h.TriggerFailover)
// Failover policies
ha.GET("/failover/policies", h.GetFailoverPolicies)
ha.POST("/failover/policies", h.SetFailoverPolicy)
ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
ha.PUT("/failover/policies/:serviceId", h.UpdateFailoverPolicy)
ha.DELETE("/failover/policies/:serviceId", h.DeleteFailoverPolicy)
// Health checks
ha.GET("/health/checks", h.GetHealthChecks)
ha.POST("/health/checks", h.AddHealthCheck)
ha.GET("/health/checks/:checkId", h.GetHealthCheck)
ha.PUT("/health/checks/:checkId", h.UpdateHealthCheck)
ha.DELETE("/health/checks/:checkId", h.DeleteHealthCheck)
ha.GET("/health/results", h.GetHealthResults)
// Alerts
ha.GET("/alerts/rules", h.GetAlertRules)
ha.POST("/alerts/rules", h.AddAlertRule)
ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
ha.PUT("/alerts/rules/:ruleId", h.UpdateAlertRule)
ha.DELETE("/alerts/rules/:ruleId", h.DeleteAlertRule)
ha.GET("/alerts/active", h.GetActiveAlerts)
ha.POST("/alerts/:alertId/resolve", h.ResolveAlert)
// Notifiers
ha.GET("/notifiers", h.GetNotifiers)
ha.POST("/notifiers", h.AddNotifier)
ha.GET("/notifiers/:notifierId", h.GetNotifier)
ha.DELETE("/notifiers/:notifierId", h.DeleteNotifier)
}
}
// GetHAStatus returns the overall HA status
func (h *HAManager) GetHAStatus(c *gin.Context) {
status := h.haManager.GetHealthStatus()
c.JSON(http.StatusOK, gin.H{
"status": status,
})
}
// EnableHA enables the HA manager
func (h *HAManager) EnableHA(c *gin.Context) {
h.haManager.Enable()
c.JSON(http.StatusOK, gin.H{
"message": "High availability manager enabled",
"enabled": true,
})
}
// DisableHA disables the HA manager
func (h *HAManager) DisableHA(c *gin.Context) {
h.haManager.Disable()
c.JSON(http.StatusOK, gin.H{
"message": "High availability manager disabled",
"enabled": false,
})
}
// TriggerFailover manually triggers a failover
func (h *HAManager) TriggerFailover(c *gin.Context) {
var request struct {
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
reason := request.Reason
if reason == "" {
reason = "Manual trigger"
}
if err := h.haManager.TriggerFailover(c.Request.Context(), reason); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Failover triggered successfully",
"reason": reason,
})
}
// GetFailoverPolicies returns all failover policies
func (h *HAManager) GetFailoverPolicies(c *gin.Context) {
// TODO: Implement getting all policies
// For now, return mock data
policies := []map[string]interface{}{
{
"service_id": "web-service",
"enabled": true,
"min_healthy_nodes": 2,
"max_failures": 3,
"failover_timeout": "30s",
"recovery_timeout": "5m",
"failover_strategy": "active_passive",
"backup_nodes": []string{"node-2", "node-3"},
},
}
c.JSON(http.StatusOK, gin.H{
"policies": policies,
"count": len(policies),
})
}
// SetFailoverPolicy creates or updates a failover policy
func (h *HAManager) SetFailoverPolicy(c *gin.Context) {
var policy ha.FailoverPolicy
if err := c.ShouldBindJSON(&policy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.haManager.SetFailoverPolicy(&policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Failover policy set successfully",
"policy": policy,
})
}
// GetFailoverPolicy returns a specific failover policy
func (h *HAManager) GetFailoverPolicy(c *gin.Context) {
serviceID := c.Param("serviceId")
policy, err := h.haManager.GetFailoverPolicy(serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"policy": policy,
})
}
// UpdateFailoverPolicy updates an existing failover policy
func (h *HAManager) UpdateFailoverPolicy(c *gin.Context) {
serviceID := c.Param("serviceId")
var policy ha.FailoverPolicy
if err := c.ShouldBindJSON(&policy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Ensure the service ID matches
policy.ServiceID = serviceID
if err := h.haManager.SetFailoverPolicy(&policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Failover policy updated successfully",
"policy": policy,
})
}
// DeleteFailoverPolicy removes a failover policy
func (h *HAManager) DeleteFailoverPolicy(c *gin.Context) {
serviceID := c.Param("serviceId")
// Set policy to disabled instead of deleting
policy, err := h.haManager.GetFailoverPolicy(serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
policy.Enabled = false
if err := h.haManager.SetFailoverPolicy(policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Failover policy disabled successfully",
})
}
// GetHealthChecks returns all health checks
func (h *HAManager) GetHealthChecks(c *gin.Context) {
// TODO: Implement getting health checks from the health checker
// For now, return mock data
checks := []map[string]interface{}{
{
"id": "check-1",
"service_id": "web-service",
"node_id": "node-1",
"type": "http",
"config": map[string]interface{}{
"interval": "30s",
"timeout": "5s",
"unhealthy_threshold": 3,
"healthy_threshold": 2,
"path": "/health",
"port": 8080,
"protocol": "HTTP",
},
"last_check": time.Now().Add(-30 * time.Second),
"status": "healthy",
},
}
c.JSON(http.StatusOK, gin.H{
"checks": checks,
"count": len(checks),
})
}
// AddHealthCheck adds a new health check
func (h *HAManager) AddHealthCheck(c *gin.Context) {
var check ha.HealthCheck
if err := c.ShouldBindJSON(&check); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// TODO: Add health check to the health checker
// For now, just return success
c.JSON(http.StatusCreated, gin.H{
"message": "Health check added successfully",
"check": check,
})
}
// GetHealthCheck returns a specific health check
func (h *HAManager) GetHealthCheck(c *gin.Context) {
_ = c.Param("checkId") // Use the parameter to avoid unused variable error
// TODO: Implement getting specific health check
// For now, return mock data
check := map[string]interface{}{
"id": "check-1",
"service_id": "web-service",
"node_id": "node-1",
"type": "http",
"status": "healthy",
}
c.JSON(http.StatusOK, gin.H{"check": check})
}
// UpdateHealthCheck updates an existing health check
func (h *HAManager) UpdateHealthCheck(c *gin.Context) {
checkID := c.Param("checkId")
var check ha.HealthCheck
if err := c.ShouldBindJSON(&check); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Ensure the check ID matches
check.ID = checkID
// TODO: Update health check in the health checker
c.JSON(http.StatusOK, gin.H{
"message": "Health check updated successfully",
"check": check,
})
}
// DeleteHealthCheck removes a health check
func (h *HAManager) DeleteHealthCheck(c *gin.Context) {
_ = c.Param("checkId") // Use the parameter to avoid unused variable error
// TODO: Remove health check from the health checker
c.JSON(http.StatusOK, gin.H{
"message": "Health check deleted successfully",
})
}
// GetHealthResults returns all health check results
func (h *HAManager) GetHealthResults(c *gin.Context) {
// TODO: Implement getting health check results
// For now, return mock data
results := []map[string]interface{}{
{
"check_id": "check-1",
"status": "healthy",
"message": "Service is healthy",
"latency": "15ms",
"timestamp": time.Now().Add(-30 * time.Second),
},
{
"check_id": "check-2",
"status": "unhealthy",
"message": "Connection timeout",
"latency": "5000ms",
"timestamp": time.Now().Add(-25 * time.Second),
"error_code": "TIMEOUT",
},
}
c.JSON(http.StatusOK, gin.H{
"results": results,
"count": len(results),
})
}
// GetAlertRules returns all alert rules
func (h *HAManager) GetAlertRules(c *gin.Context) {
// TODO: Implement getting alert rules
// For now, return mock data
rules := []map[string]interface{}{
{
"id": "rule-1",
"name": "High CPU Usage",
"description": "Alert when CPU usage is above 90%",
"enabled": true,
"condition": map[string]interface{}{
"metric": "cpu_usage",
"operator": ">",
"threshold": 90.0,
"duration": "5m",
},
"severity": "warning",
"labels": map[string]string{
"service": "web-service",
"team": "backend",
},
"notifiers": []string{"email", "slack"},
"cooldown": "10m",
},
}
c.JSON(http.StatusOK, gin.H{
"rules": rules,
"count": len(rules),
})
}
// AddAlertRule adds a new alert rule
func (h *HAManager) AddAlertRule(c *gin.Context) {
var rule ha.AlertRule
if err := c.ShouldBindJSON(&rule); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// TODO: Add alert rule to the alert manager
// For now, just return success
c.JSON(http.StatusCreated, gin.H{
"message": "Alert rule added successfully",
"rule": rule,
})
}
// GetAlertRule returns a specific alert rule
func (h *HAManager) GetAlertRule(c *gin.Context) {
_ = c.Param("ruleId") // Use the parameter to avoid unused variable error
// TODO: Implement getting specific alert rule
// For now, return mock data
rule := map[string]interface{}{
"id": "rule-1",
"name": "High CPU Usage",
"description": "Alert when CPU usage is above 90%",
"enabled": true,
"severity": "warning",
}
c.JSON(http.StatusOK, gin.H{
"rule": rule,
})
}
// UpdateAlertRule updates an existing alert rule
func (h *HAManager) UpdateAlertRule(c *gin.Context) {
ruleID := c.Param("ruleId")
var rule ha.AlertRule
if err := c.ShouldBindJSON(&rule); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Ensure the rule ID matches
rule.ID = ruleID
// TODO: Update alert rule in the alert manager
c.JSON(http.StatusOK, gin.H{
"message": "Alert rule updated successfully",
"rule": rule,
})
}
// DeleteAlertRule removes an alert rule
func (h *HAManager) DeleteAlertRule(c *gin.Context) {
// TODO: Remove alert rule from the alert manager
c.JSON(http.StatusOK, gin.H{
"message": "Alert rule deleted successfully",
})
}
// GetActiveAlerts returns all active alerts
func (h *HAManager) GetActiveAlerts(c *gin.Context) {
// Parse query parameters
limitStr := c.DefaultQuery("limit", "50")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 50
}
// TODO: Implement getting active alerts from the alert manager
// For now, return mock data
alerts := []map[string]interface{}{
{
"id": "alert-1",
"rule_id": "rule-1",
"status": "firing",
"severity": "warning",
"message": "CPU usage is above 90%",
"labels": map[string]string{
"service": "web-service",
"team": "backend",
},
"starts_at": time.Now().Add(-10 * time.Minute),
"updated_at": time.Now().Add(-2 * time.Minute),
},
{
"id": "alert-2",
"rule_id": "rule-2",
"status": "firing",
"severity": "critical",
"message": "Service is down",
"labels": map[string]string{
"service": "api-service",
"team": "backend",
},
"starts_at": time.Now().Add(-5 * time.Minute),
"updated_at": time.Now().Add(-1 * time.Minute),
},
}
// Limit results
if len(alerts) > limit {
alerts = alerts[:limit]
}
c.JSON(http.StatusOK, gin.H{
"alerts": alerts,
"count": len(alerts),
"limit": limit,
})
}
// ResolveAlert resolves an alert
func (h *HAManager) ResolveAlert(c *gin.Context) {
alertID := c.Param("alertId")
// TODO: Resolve alert in the alert manager
c.JSON(http.StatusOK, gin.H{
"message": "Alert resolved successfully",
"alert_id": alertID,
})
}
// GetNotifiers returns all notifiers
func (h *HAManager) GetNotifiers(c *gin.Context) {
// TODO: Implement getting notifiers
// For now, return mock data
notifiers := []map[string]interface{}{
{
"id": "email",
"type": "email",
"config": map[string]interface{}{
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"from": "alerts@containr.com",
"to": []string{"admin@example.com"},
},
},
{
"id": "slack",
"type": "slack",
"config": map[string]interface{}{
"webhook_url": "https://hooks.slack.com/...",
"channel": "#alerts",
},
},
}
c.JSON(http.StatusOK, gin.H{
"notifiers": notifiers,
"count": len(notifiers),
})
}
// AddNotifier adds a new notifier
func (h *HAManager) AddNotifier(c *gin.Context) {
var request struct {
ID string `json:"id"`
Type string `json:"type"`
Config map[string]interface{} `json:"config"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Create notifier based on type
switch request.Type {
case "email":
_ = &ha.EmailNotifier{} // Create but don't use for now
case "slack":
_ = &ha.SlackNotifier{} // Create but don't use for now
case "webhook":
_ = &ha.WebhookNotifier{} // Create but don't use for now
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notifier type"})
return
}
// TODO: Add notifier to the alert manager
c.JSON(http.StatusCreated, gin.H{
"message": "Notifier added successfully",
"id": request.ID,
"type": request.Type,
})
}
// GetNotifier returns a specific notifier
func (h *HAManager) GetNotifier(c *gin.Context) {
_ = c.Param("notifierId") // Use the parameter to avoid unused variable error
// TODO: Implement getting specific notifier
// For now, return mock data
notifier := map[string]interface{}{
"id": "email",
"type": "email",
"config": map[string]interface{}{
"smtp_host": "smtp.example.com",
"smtp_port": 587,
},
}
c.JSON(http.StatusOK, gin.H{
"notifier": notifier,
})
}
// DeleteNotifier removes a notifier
func (h *HAManager) DeleteNotifier(c *gin.Context) {
_ = c.Param("notifierId") // Use the parameter to avoid unused variable error
// TODO: Remove notifier from the alert manager
// For now, just return success
c.JSON(http.StatusOK, gin.H{
"message": "Notifier deleted successfully",
})
}
+244
View File
@@ -0,0 +1,244 @@
package api
import (
"bufio"
"containr/internal/database"
"containr/internal/docker"
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
Message string `json:"message"`
Stream string `json:"stream"`
}
func handleGetLogs(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
follow := c.DefaultQuery("follow", "false") == "true"
tail := c.DefaultQuery("tail", "100")
dockerClient, exists := c.Get("docker_client")
if !exists || dockerClient == nil {
c.JSON(http.StatusOK, gin.H{"logs": []LogEntry{}, "message": "Docker not available - showing mock logs"})
return
}
client := dockerClient.(*docker.Client)
containerName := fmt.Sprintf("containr-%s", serviceID)
logOpts := docker.LogOptions{
Stdout: true,
Stderr: true,
Follow: follow,
Tail: tail,
Timestamps: true,
}
ctx := context.Background()
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"logs": []LogEntry{
{Timestamp: time.Now(), Message: "Service not running or container not found", Stream: "system"},
{Timestamp: time.Now(), Message: "Start the service to see logs", Stream: "system"},
},
})
return
}
defer logsReader.Close()
if follow {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
streamWriter := c.Writer
flusher, ok := streamWriter.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Streaming not supported"})
return
}
scanner := bufio.NewScanner(logsReader)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
cleanLine := stripDockerLogHeader(line)
entry := LogEntry{
Timestamp: time.Now(),
Message: cleanLine,
Stream: "stdout",
}
if strings.Contains(strings.ToLower(cleanLine), "error") || strings.Contains(strings.ToLower(cleanLine), "err") {
entry.Stream = "stderr"
}
fmt.Fprintf(streamWriter, "data: {\"timestamp\":\"%s\",\"message\":\"%s\",\"stream\":\"%s\"}\n\n",
entry.Timestamp.Format(time.RFC3339),
strings.ReplaceAll(entry.Message, `"`, `\"`),
entry.Stream,
)
flusher.Flush()
}
return
}
logBytes, err := io.ReadAll(logsReader)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read logs"})
return
}
logContent := string(logBytes)
var logEntries []LogEntry
scanner := bufio.NewScanner(strings.NewReader(logContent))
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
cleanLine := stripDockerLogHeader(line)
entry := LogEntry{
Timestamp: time.Now(),
Message: cleanLine,
Stream: "stdout",
}
if strings.Contains(strings.ToLower(cleanLine), "error") || strings.Contains(strings.ToLower(cleanLine), "err") {
entry.Stream = "stderr"
}
logEntries = append(logEntries, entry)
}
c.JSON(http.StatusOK, gin.H{"logs": logEntries})
}
func handleGetDeploymentLogs(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
deploymentIDStr := c.Param("id")
deploymentID, err := uuid.Parse(deploymentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var buildLog, runtimeLog string
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT d.build_log, d.runtime_log, p.owner_id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(&buildLog, &runtimeLog, &ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
logType := c.DefaultQuery("type", "all")
var logs []LogEntry
parseLogs := func(logContent string, stream string) []LogEntry {
var entries []LogEntry
scanner := bufio.NewScanner(strings.NewReader(logContent))
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
entries = append(entries, LogEntry{
Timestamp: time.Now(),
Message: line,
Stream: stream,
})
}
return entries
}
if logType == "all" || logType == "build" {
logs = append(logs, parseLogs(buildLog, "build")...)
}
if logType == "all" || logType == "runtime" {
logs = append(logs, parseLogs(runtimeLog, "runtime")...)
}
c.JSON(http.StatusOK, gin.H{
"logs": logs,
"build_log": buildLog,
"runtime_log": runtimeLog,
})
}
func stripDockerLogHeader(line string) string {
if len(line) > 8 && (line[0] == 1 || line[0] == 2) {
return line[8:]
}
return line
}
+13
View File
@@ -0,0 +1,13 @@
package api
import "github.com/gin-gonic/gin"
// firstPathParam returns the first non-empty route param from the provided names.
func firstPathParam(c *gin.Context, names ...string) string {
for _, name := range names {
if value := c.Param(name); value != "" {
return value
}
}
return ""
}
+617
View File
@@ -0,0 +1,617 @@
package api
import (
"containr/internal/database"
"database/sql"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// PreviewEnvironment represents a preview environment
type PreviewEnvironment struct {
ID uuid.UUID `json:"id" db:"id"`
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
BranchName string `json:"branch_name" db:"branch_name"`
PRNumber *int `json:"pr_number" db:"pr_number"`
Environment string `json:"environment" db:"environment"` // preview-{branch}-{timestamp}
Status string `json:"status" db:"status"` // building, running, failed, stopped, expired
URL string `json:"url" db:"url"`
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// Related data
Service *Service `json:"service,omitempty"`
DeploymentID *uuid.UUID `json:"deployment_id,omitempty"`
}
// CreatePreviewEnvironmentRequest represents a request to create a preview environment
type CreatePreviewEnvironmentRequest struct {
ProjectID uuid.UUID `json:"project_id"`
ServiceID uuid.UUID `json:"service_id" binding:"required"`
BranchName string `json:"branch_name" binding:"required"`
PRNumber *int `json:"pr_number"`
TTLHours int `json:"ttl_hours" binding:"min=1,max=168"` // 1 hour to 7 days
}
// UpdatePreviewEnvironmentRequest represents a request to update a preview environment
type UpdatePreviewEnvironmentRequest struct {
Status string `json:"status" binding:"omitempty,oneof=building running failed stopped expired"`
URL string `json:"url"`
ExpiresAt *time.Time `json:"expires_at"`
TTLHours int `json:"ttl_hours" binding:"omitempty,min=1,max=168"`
}
// PromotePreviewEnvironmentRequest represents a request to promote a preview environment
type PromotePreviewEnvironmentRequest struct {
TargetEnvironment string `json:"target_environment" binding:"required,oneof=production development"`
CreateBackup bool `json:"create_backup"`
}
// handleGetPreviewEnvironments retrieves all preview environments for a project
func handleGetPreviewEnvironments(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
projectIDStr := firstPathParam(c, "id", "project_id", "projectId")
projectID, err := uuid.Parse(projectIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
// Check if project exists and user has access
var project Project
err = db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
projectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Get user ID from JWT token (set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Get preview environments for the project with service info
rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
WHERE pe.project_id = $1
ORDER BY pe.created_at DESC`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve preview environments"})
return
}
defer rows.Close()
var environments []PreviewEnvironment
for rows.Next() {
var env PreviewEnvironment
var serviceID sql.NullString
var serviceName sql.NullString
var serviceType sql.NullString
err := rows.Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
&serviceID, &serviceName, &serviceType,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan preview environment"})
return
}
if serviceID.Valid {
parsedServiceID, parseErr := uuid.Parse(serviceID.String)
if parseErr == nil {
env.Service = &Service{
ID: parsedServiceID,
Name: serviceName.String,
Type: serviceType.String,
}
}
}
environments = append(environments, env)
}
c.JSON(http.StatusOK, gin.H{"preview_environments": environments})
}
// handleCreatePreviewEnvironment creates a new preview environment
func handleCreatePreviewEnvironment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
projectIDStr := firstPathParam(c, "id", "project_id", "projectId")
projectID, err := uuid.Parse(projectIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
var req CreatePreviewEnvironmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.ProjectID == uuid.Nil {
req.ProjectID = projectID
} else if req.ProjectID != projectID {
c.JSON(http.StatusBadRequest, gin.H{"error": "Project ID in URL and request body must match"})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if project exists and user has access
var project Project
err = db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
req.ProjectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Check if service exists and belongs to the project
var service Service
err = db.(*database.DB).QueryRow(
"SELECT id, name, type FROM services WHERE id = $1 AND project_id = $2",
req.ServiceID, req.ProjectID,
).Scan(&service.ID, &service.Name, &service.Type)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found or doesn't belong to this project"})
return
}
// Check if preview environment already exists for this branch and service
var count int
err = db.(*database.DB).QueryRow(
"SELECT COUNT(*) FROM preview_environments WHERE service_id = $1 AND branch_name = $2 AND status NOT IN ('expired', 'stopped')",
req.ServiceID, req.BranchName,
).Scan(&count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing preview environment"})
return
}
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Preview environment already exists for this branch and service"})
return
}
// Set default TTL if not provided
ttlHours := req.TTLHours
if ttlHours == 0 {
ttlHours = 24 // Default 24 hours
}
// Create preview environment
env := PreviewEnvironment{
ID: uuid.New(),
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
BranchName: req.BranchName,
PRNumber: req.PRNumber,
Environment: generatePreviewEnvironmentName(req.BranchName),
Status: "building",
ExpiresAt: &[]time.Time{time.Now().Add(time.Duration(ttlHours) * time.Hour)}[0],
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Insert preview environment into database
_, err = db.(*database.DB).Exec(
`INSERT INTO preview_environments
(id, project_id, service_id, branch_name, pr_number, environment,
status, url, expires_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
env.ID, env.ProjectID, env.ServiceID, env.BranchName, env.PRNumber,
env.Environment, env.Status, env.URL, env.ExpiresAt, env.CreatedAt, env.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create preview environment"})
return
}
// TODO: Trigger deployment pipeline for preview environment
// This would integrate with the existing deployment engine
c.JSON(http.StatusCreated, gin.H{"preview_environment": env})
}
// handleGetPreviewEnvironment retrieves a specific preview environment
func handleGetPreviewEnvironment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
envIDStr := c.Param("id")
envID, err := uuid.Parse(envIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preview environment ID"})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Get preview environment with project ownership check
var env PreviewEnvironment
var serviceID sql.NullString
var serviceName sql.NullString
var serviceType sql.NullString
err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
&serviceID, &serviceName, &serviceType,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return
}
// Populate service info if available
if serviceID.Valid {
parsedServiceID, parseErr := uuid.Parse(serviceID.String)
if parseErr == nil {
env.Service = &Service{
ID: parsedServiceID,
Name: serviceName.String,
Type: serviceType.String,
}
}
}
c.JSON(http.StatusOK, gin.H{"preview_environment": env})
}
// handleUpdatePreviewEnvironment updates a preview environment
func handleUpdatePreviewEnvironment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
envIDStr := c.Param("id")
envID, err := uuid.Parse(envIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preview environment ID"})
return
}
var req UpdatePreviewEnvironmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if preview environment exists and user has access
var existingEnv PreviewEnvironment
err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
).Scan(
&existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName,
&existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL,
&existingEnv.ExpiresAt, &existingEnv.CreatedAt, &existingEnv.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return
}
// Update fields if provided
if req.Status != "" {
existingEnv.Status = req.Status
}
if req.URL != "" {
existingEnv.URL = req.URL
}
if req.ExpiresAt != nil {
existingEnv.ExpiresAt = req.ExpiresAt
}
if req.TTLHours > 0 {
newExpiresAt := time.Now().Add(time.Duration(req.TTLHours) * time.Hour)
existingEnv.ExpiresAt = &newExpiresAt
}
existingEnv.UpdatedAt = time.Now()
// Update preview environment in database
_, err = db.(*database.DB).Exec(
`UPDATE preview_environments
SET status = $1, url = $2, expires_at = $3, updated_at = $4
WHERE id = $5`,
existingEnv.Status, existingEnv.URL, existingEnv.ExpiresAt, existingEnv.UpdatedAt, existingEnv.ID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment"})
return
}
c.JSON(http.StatusOK, gin.H{"preview_environment": existingEnv})
}
// handleDeletePreviewEnvironment deletes a preview environment
func handleDeletePreviewEnvironment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
envIDStr := c.Param("id")
envID, err := uuid.Parse(envIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preview environment ID"})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if preview environment exists and user has access
var projectOwnerID string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1`,
envID,
).Scan(&projectOwnerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return
}
// Check if user owns the project
if projectOwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// TODO: Clean up deployment and resources associated with this preview environment
// This would integrate with the deployment engine to stop containers, clean up resources, etc.
// Delete preview environment
_, err = db.(*database.DB).Exec(
"DELETE FROM preview_environments WHERE id = $1",
envID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete preview environment"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Preview environment deleted successfully"})
}
// handlePromotePreviewEnvironment promotes a preview environment to production/development
func handlePromotePreviewEnvironment(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
envIDStr := c.Param("id")
envID, err := uuid.Parse(envIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preview environment ID"})
return
}
var req PromotePreviewEnvironmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Get preview environment details
var env PreviewEnvironment
err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"})
return
}
// Check if preview environment is in a state that can be promoted
if env.Status != "running" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Preview environment must be running to promote"})
return
}
// TODO: Implement promotion logic
// 1. Create backup of target environment if requested
// 2. Deploy preview environment code to target environment
// 3. Update service configuration
// 4. Trigger deployment pipeline
// For now, just return success with promotion details
c.JSON(http.StatusOK, gin.H{
"message": "Preview environment promotion initiated",
"promotion": map[string]interface{}{
"preview_environment_id": env.ID,
"target_environment": req.TargetEnvironment,
"branch_name": env.BranchName,
"create_backup": req.CreateBackup,
"status": "initiated",
},
})
}
// generatePreviewEnvironmentName generates a unique environment name for preview
func generatePreviewEnvironmentName(branchName string) string {
timestamp := time.Now().Format("20060102-150405")
// Sanitize branch name
sanitizedBranch := strings.ReplaceAll(branchName, "/", "-")
sanitizedBranch = strings.ReplaceAll(sanitizedBranch, "_", "-")
return fmt.Sprintf("preview-%s-%s", sanitizedBranch, timestamp)
}
// handleCleanupExpiredPreviewEnvironments cleans up expired preview environments
func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Find expired preview environments for user's projects
rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE p.owner_id = $1 AND pe.expires_at < NOW() AND pe.status != 'expired'`,
userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"})
return
}
defer rows.Close()
var expiredEnvs []PreviewEnvironment
for rows.Next() {
var env PreviewEnvironment
err := rows.Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment,
)
if err != nil {
continue
}
expiredEnvs = append(expiredEnvs, env)
}
// Mark expired environments as expired and trigger cleanup
cleanupCount := 0
for _, env := range expiredEnvs {
// Update status to expired
_, err := db.(*database.DB).Exec(
"UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1",
env.ID,
)
if err != nil {
continue
}
// TODO: Trigger cleanup of deployment resources
// This would stop containers, clean up resources, etc.
cleanupCount++
}
c.JSON(http.StatusOK, gin.H{
"message": "Cleanup completed",
"cleaned_count": cleanupCount,
"expired_environments": expiredEnvs,
})
}
+396
View File
@@ -0,0 +1,396 @@
package api
import (
"containr/internal/database"
"context"
"database/sql"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ProjectStats struct {
ServiceCount int `json:"service_count"`
DeploymentCount int `json:"deployment_count"`
RunningServices int `json:"running_services"`
LastDeployment *string `json:"last_deployment"`
}
type ProjectWithStats struct {
Project
Stats ProjectStats `json:"stats"`
}
type CreateProjectRequest struct {
Name string `json:"name" binding:"required,min=2"`
Description string `json:"description"`
}
type UpdateProjectRequest struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
}
func handleGetProjects(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
// Get pagination parameters
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
search := c.DefaultQuery("search", "")
// Validate and limit pagination
if page < 1 {
page = 1
}
if limit > 100 || limit < 1 {
limit = 10
}
offset := (page - 1) * limit
// Use the optimized view for better performance
var query string
var args []interface{}
if search != "" {
// Search query with pattern matching
query = `
SELECT id, name, description, owner_id, created_at, updated_at
FROM project_stats
WHERE (owner_id = $1 OR id IN (
SELECT DISTINCT project_id FROM project_members WHERE user_id = $1
)) AND (name ILIKE $2 OR description ILIKE $2)
ORDER BY updated_at DESC
LIMIT $3 OFFSET $4
`
args = []interface{}{userID, "%" + search + "%", limit, offset}
} else {
// Optimized query using the view
query = `
SELECT id, name, description, owner_id, created_at, updated_at
FROM project_stats
WHERE owner_id = $1 OR id IN (
SELECT DISTINCT project_id FROM project_members WHERE user_id = $1
)
ORDER BY updated_at DESC
LIMIT $2 OFFSET $3
`
args = []interface{}{userID, limit, offset}
}
// Execute query with timeout context
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database query error"})
return
}
defer rows.Close()
var projects []ProjectWithStats
for rows.Next() {
var project ProjectWithStats
if err := rows.Scan(&project.ID, &project.Name, &project.Description, &project.OwnerID, &project.CreatedAt, &project.UpdatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database scan error"})
return
}
projects = append(projects, project)
}
// Get total count with optimized query
var totalQuery string
var totalArgs []interface{}
if search != "" {
totalQuery = `
SELECT COUNT(DISTINCT id)
FROM project_stats
WHERE (owner_id = $1 OR id IN (
SELECT DISTINCT project_id FROM project_members WHERE user_id = $1
)) AND (name ILIKE $2 OR description ILIKE $2)
`
totalArgs = []interface{}{userID, "%" + search + "%"}
} else {
totalQuery = `
SELECT COUNT(DISTINCT id)
FROM project_stats
WHERE owner_id = $1 OR id IN (
SELECT DISTINCT project_id FROM project_members WHERE user_id = $1
)
`
totalArgs = []interface{}{userID}
}
var total int
err = db.QueryRowContext(ctx, totalQuery, totalArgs...).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database count error"})
return
}
// Batch fetch stats for all projects
if len(projects) > 0 {
projectIDs := make([]string, len(projects))
for i, p := range projects {
projectIDs[i] = p.ID
}
statsMap := getBatchProjectStats(ctx, db, projectIDs)
for i := range projects {
if stats, exists := statsMap[projects[i].ID]; exists {
projects[i].Stats = stats
}
}
}
c.JSON(http.StatusOK, gin.H{
"projects": projects,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"pages": (total + limit - 1) / limit,
},
})
}
// getBatchProjectStats fetches stats for multiple projects efficiently
func getBatchProjectStats(ctx context.Context, db *database.DB, projectIDs []string) map[string]ProjectStats {
if len(projectIDs) == 0 {
return make(map[string]ProjectStats)
}
// Create placeholders for IN clause
placeholders := make([]string, len(projectIDs))
args := make([]interface{}, len(projectIDs))
for i, id := range projectIDs {
placeholders[i] = "$" + strconv.Itoa(i+1)
args[i] = id
}
query := `
SELECT
project_id,
COUNT(DISTINCT id) as service_count,
COUNT(DISTINCT deployment_id) as deployment_count,
COUNT(DISTINCT CASE WHEN status = 'running' THEN id END) as running_services,
MAX(last_deployment) as last_deployment
FROM (
SELECT
s.project_id,
s.id,
d.id as deployment_id,
s.status,
d.created_at as last_deployment
FROM services s
LEFT JOIN deployments d ON s.id = d.service_id
WHERE s.project_id IN (` + strings.Join(placeholders, ",") + `)
) sub
GROUP BY project_id
`
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return make(map[string]ProjectStats)
}
defer rows.Close()
statsMap := make(map[string]ProjectStats)
for rows.Next() {
var projectID string
var stats ProjectStats
var lastDeployment sql.NullTime
err := rows.Scan(&projectID, &stats.ServiceCount, &stats.DeploymentCount, &stats.RunningServices, &lastDeployment)
if err != nil {
continue
}
if lastDeployment.Valid {
deploymentStr := lastDeployment.Time.Format(time.RFC3339)
stats.LastDeployment = &deploymentStr
}
statsMap[projectID] = stats
}
return statsMap
}
func handleCreateProject(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req CreateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var project Project
err := db.QueryRow(`
INSERT INTO projects (name, description, owner_id)
VALUES ($1, $2, $3)
RETURNING id, name, description, owner_id, created_at, updated_at
`, req.Name, req.Description, userID).Scan(&project.ID, &project.Name, &project.Description, &project.OwnerID, &project.CreatedAt, &project.UpdatedAt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"})
return
}
// Create default environments
environments := []string{"production", "preview", "development"}
for _, env := range environments {
_, err = db.Exec(`
INSERT INTO environments (name, project_id)
VALUES ($1, $2)
`, env, project.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create environments"})
return
}
}
c.JSON(http.StatusCreated, project)
}
func handleGetProject(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
projectID := c.Param("id")
// Validate UUID
if _, err := uuid.Parse(projectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
var project Project
err := db.QueryRow(`
SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
FROM projects p
WHERE p.id = $1 AND (p.owner_id = $2 OR EXISTS (
SELECT 1 FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $2
))
`, projectID, userID).Scan(&project.ID, &project.Name, &project.Description, &project.OwnerID, &project.CreatedAt, &project.UpdatedAt)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
c.JSON(http.StatusOK, project)
}
func handleUpdateProject(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
projectID := c.Param("id")
// Validate UUID
if _, err := uuid.Parse(projectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
// Check if user is owner or admin
var role string
err := db.QueryRow(`
SELECT CASE
WHEN p.owner_id = $1 THEN 'owner'
ELSE pm.role
END as role
FROM projects p
LEFT JOIN project_members pm ON p.id = pm.project_id AND pm.user_id = $1
WHERE p.id = $2
`, userID, projectID).Scan(&role)
if err == sql.ErrNoRows || role == "" || role == "viewer" {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
var req UpdateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err = db.Exec(`
UPDATE projects
SET name = COALESCE($1, name), description = COALESCE($2, description)
WHERE id = $3
`, req.Name, req.Description, projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project"})
return
}
// Return updated project
handleGetProject(c)
}
func handleDeleteProject(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
projectID := c.Param("id")
// Validate UUID
if _, err := uuid.Parse(projectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
// Check if user is owner
var ownerID string
err := db.QueryRow("SELECT owner_id FROM projects WHERE id = $1", projectID).Scan(&ownerID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
if ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"})
return
}
// Delete project (cascading deletes will handle related records)
_, err = db.Exec("DELETE FROM projects WHERE id = $1", projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete project"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Project deleted successfully"})
}
+480
View File
@@ -0,0 +1,480 @@
package api
import (
"net/http"
"strconv"
"containr/internal/proxmox"
"github.com/gin-gonic/gin"
)
// ProxmoxHandler handles Proxmox-related API endpoints
type ProxmoxHandler struct {
service *proxmox.Service
}
// NewProxmoxHandler creates a new Proxmox handler
func NewProxmoxHandler(service *proxmox.Service) *ProxmoxHandler {
return &ProxmoxHandler{
service: service,
}
}
// RegisterProxmoxRoutes registers Proxmox API routes
func RegisterProxmoxRoutes(router *gin.Engine, service *proxmox.Service) {
handler := NewProxmoxHandler(service)
proxmox := router.Group("/api/proxmox")
{
// Cluster and node management
proxmox.GET("/cluster/status", handler.getClusterStatus)
proxmox.GET("/nodes", handler.getNodes)
proxmox.GET("/nodes/:nodeName/stats", handler.getNodeStats)
proxmox.GET("/nodes/:nodeName/templates", handler.getTemplates)
// VM management
proxmox.GET("/vms", handler.getAllVMs)
proxmox.GET("/vms/:vmid/status", handler.getVMStatus)
proxmox.POST("/vms", handler.createVM)
proxmox.POST("/vms/:vmid/start", handler.startVM)
proxmox.POST("/vms/:vmid/stop", handler.stopVM)
proxmox.DELETE("/vms/:vmid", handler.deleteVM)
// Container management
proxmox.GET("/containers", handler.getAllContainers)
proxmox.POST("/containers", handler.createContainer)
proxmox.POST("/containers/:vmid/start", handler.startContainer)
proxmox.POST("/containers/:vmid/stop", handler.stopContainer)
proxmox.DELETE("/containers/:vmid", handler.deleteContainer)
// Resource management
proxmox.GET("/resources/usage", handler.getResourceUsage)
proxmox.GET("/health", handler.healthCheck)
}
}
// getClusterStatus returns the overall cluster status
func (h *ProxmoxHandler) getClusterStatus(c *gin.Context) {
status, err := h.service.GetClusterStatus()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": status})
}
// getNodes returns all nodes in the cluster
func (h *ProxmoxHandler) getNodes(c *gin.Context) {
nodes, err := h.service.GetAllNodes()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": nodes})
}
// getNodeStats returns detailed statistics for a specific node
func (h *ProxmoxHandler) getNodeStats(c *gin.Context) {
nodeName := c.Param("nodeName")
stats, err := h.service.GetNodeStats(nodeName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": stats})
}
// getTemplates returns available VM and container templates
func (h *ProxmoxHandler) getTemplates(c *gin.Context) {
nodeName := c.Param("nodeName")
templates, err := h.service.GetAvailableTemplates(nodeName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": templates})
}
// getAllVMs returns all VMs across all nodes
func (h *ProxmoxHandler) getAllVMs(c *gin.Context) {
vms, err := h.service.GetAllVMs()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": vms})
}
// getVMStatus returns the status of a specific VM
func (h *ProxmoxHandler) getVMStatus(c *gin.Context) {
vmidStr := c.Param("vmid")
vmid, err := strconv.Atoi(vmidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid VM ID"})
return
}
// For now, we'll need to determine the node - this could be improved
// by maintaining a VM-to-node mapping
nodes, err := h.service.GetAllNodes()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Try each node until we find the VM
for _, node := range nodes {
if node.Status == "online" {
status, err := h.service.GetInstanceStatus(node.Node, vmid, "qemu")
if err == nil {
c.JSON(http.StatusOK, gin.H{"data": status})
return
}
}
}
c.JSON(http.StatusNotFound, gin.H{"error": "VM not found"})
}
// createVM creates a new VM
func (h *ProxmoxHandler) createVM(c *gin.Context) {
var config proxmox.ServiceVMConfig
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// For now, use the first available online node
// In a production system, you'd want smarter node selection
nodes, err := h.service.GetAllNodes()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var targetNode string
for _, node := range nodes {
if node.Status == "online" {
targetNode = node.Node
break
}
}
if targetNode == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "No online nodes available"})
return
}
vm, err := h.service.CreateServiceVM(targetNode, config)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": vm})
}
// startVM starts a VM
func (h *ProxmoxHandler) startVM(c *gin.Context) {
vmidStr := c.Param("vmid")
vmid, err := strconv.Atoi(vmidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid VM ID"})
return
}
// Find which node the VM is on
vms, err := h.service.GetAllVMs()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var nodeName string
for _, vm := range vms {
if vm.VMID == vmid {
nodeName = vm.Node
break
}
}
if nodeName == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "VM not found"})
return
}
err = h.service.StartInstance(nodeName, vmid, "qemu")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "VM started successfully"})
}
// stopVM stops a VM
func (h *ProxmoxHandler) stopVM(c *gin.Context) {
vmidStr := c.Param("vmid")
vmid, err := strconv.Atoi(vmidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid VM ID"})
return
}
// Find which node the VM is on
vms, err := h.service.GetAllVMs()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var nodeName string
for _, vm := range vms {
if vm.VMID == vmid {
nodeName = vm.Node
break
}
}
if nodeName == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "VM not found"})
return
}
err = h.service.StopInstance(nodeName, vmid, "qemu")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "VM stopped successfully"})
}
// deleteVM deletes a VM
func (h *ProxmoxHandler) deleteVM(c *gin.Context) {
vmidStr := c.Param("vmid")
vmid, err := strconv.Atoi(vmidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid VM ID"})
return
}
// Find which node the VM is on
vms, err := h.service.GetAllVMs()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var nodeName string
for _, vm := range vms {
if vm.VMID == vmid {
nodeName = vm.Node
break
}
}
if nodeName == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "VM not found"})
return
}
err = h.service.DeleteInstance(nodeName, vmid, "qemu")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "VM deleted successfully"})
}
// getAllContainers returns all containers across all nodes
func (h *ProxmoxHandler) getAllContainers(c *gin.Context) {
containers, err := h.service.GetAllContainers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": containers})
}
// createContainer creates a new container
func (h *ProxmoxHandler) createContainer(c *gin.Context) {
var config proxmox.ServiceContainerConfig
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// For now, use the first available online node
nodes, err := h.service.GetAllNodes()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var targetNode string
for _, node := range nodes {
if node.Status == "online" {
targetNode = node.Node
break
}
}
if targetNode == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "No online nodes available"})
return
}
container, err := h.service.CreateServiceContainer(targetNode, config)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": container})
}
// startContainer starts a container
func (h *ProxmoxHandler) startContainer(c *gin.Context) {
vmidStr := c.Param("vmid")
vmid, err := strconv.Atoi(vmidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid container ID"})
return
}
// Find which node the container is on
containers, err := h.service.GetAllContainers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var nodeName string
for _, container := range containers {
if container.VMID == vmid {
nodeName = container.Node
break
}
}
if nodeName == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
return
}
err = h.service.StartInstance(nodeName, vmid, "lxc")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Container started successfully"})
}
// stopContainer stops a container
func (h *ProxmoxHandler) stopContainer(c *gin.Context) {
vmidStr := c.Param("vmid")
vmid, err := strconv.Atoi(vmidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid container ID"})
return
}
// Find which node the container is on
containers, err := h.service.GetAllContainers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var nodeName string
for _, container := range containers {
if container.VMID == vmid {
nodeName = container.Node
break
}
}
if nodeName == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
return
}
err = h.service.StopInstance(nodeName, vmid, "lxc")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Container stopped successfully"})
}
// deleteContainer deletes a container
func (h *ProxmoxHandler) deleteContainer(c *gin.Context) {
vmidStr := c.Param("vmid")
vmid, err := strconv.Atoi(vmidStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid container ID"})
return
}
// Find which node the container is on
containers, err := h.service.GetAllContainers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var nodeName string
for _, container := range containers {
if container.VMID == vmid {
nodeName = container.Node
break
}
}
if nodeName == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
return
}
err = h.service.DeleteInstance(nodeName, vmid, "lxc")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Container deleted successfully"})
}
// getResourceUsage returns resource usage across the cluster
func (h *ProxmoxHandler) getResourceUsage(c *gin.Context) {
usage, err := h.service.GetResourceUsage()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": usage})
}
// healthCheck validates the connection to Proxmox
func (h *ProxmoxHandler) healthCheck(c *gin.Context) {
err := h.service.ValidateConnection()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "unhealthy",
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"message": "Proxmox connection is working",
})
}
+259
View File
@@ -0,0 +1,259 @@
package api
import (
"log"
"time"
"containr/internal/build"
"containr/internal/config"
"containr/internal/database"
"containr/internal/deployment"
"containr/internal/docker"
"containr/internal/metrics"
"containr/internal/middleware"
"containr/internal/scaling"
"github.com/gin-gonic/gin"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
// Initialize Docker client (non-fatal if it fails)
var dockerClient *docker.Client
var buildManager *build.BuildManager
var deploymentEngine *deployment.DeploymentEngine
if client, err := docker.NewClient(); err != nil {
log.Printf("Warning: Failed to initialize Docker client: %v", err)
log.Printf("Docker-related features will be disabled")
} else {
dockerClient = client
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
}
// Initialize build handler
buildHandler := NewBuildHandler(buildManager, dockerClient)
// Initialize scheduler and metrics systems
scheduler := deployment.NewScheduler()
metricsStorage := metrics.NewInMemoryMetricsStorage() // Use in-memory for now
metricsCollector := metrics.NewMetricsCollector(scheduler, metricsStorage)
autoScaler := scaling.NewAutoScaler(scheduler, metricsCollector)
// Initialize scaling handler
scalingHandler := NewScalingHandler(autoScaler)
// Initialize GORM for agent system
gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{})
if err != nil {
panic("Failed to initialize GORM: " + err.Error())
}
// Initialize agent handler
agentHandler := NewNodeAgentHandler(gormDB)
// Initialize database handler
databaseHandler := NewDatabaseHandler(db.DB)
// Initialize security handler
securityHandler := NewSecurityHandler(db, cfg.JWTSecret)
// Note: Proxmox integration can be added later if needed
// For now, focusing on core Containr and APwhy functionality
// Add database and JWT secret to gin context for handlers
router.Use(func(c *gin.Context) {
c.Set("db", db)
c.Set("redis", redis)
c.Set("jwt_secret", cfg.JWTSecret)
c.Set("docker_client", dockerClient)
c.Set("build_manager", buildManager)
if deploymentEngine != nil {
c.Set("deployment_engine", deploymentEngine)
}
c.Set("scheduler", scheduler)
c.Set("metrics_collector", metricsCollector)
c.Set("auto_scaler", autoScaler)
c.Set("scaling_handler", scalingHandler)
c.Set("gorm_db", gormDB)
c.Next()
})
// Health check endpoint
router.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"service": "containr-api",
})
})
// API v1 routes
v1 := router.Group("/api/v1")
{
// Public routes (no authentication required)
public := v1.Group("/")
{
public.POST("/auth/login", handleLogin)
public.POST("/auth/register", handleRegister)
}
// Protected routes (authentication required)
protected := v1.Group("/")
protected.Use(middleware.Auth(cfg.JWTSecret))
{
// User routes
protected.GET("/user/profile", handleGetProfile)
protected.PUT("/user/profile", handleUpdateProfile)
// Project routes
protected.GET("/projects", handleGetProjects)
protected.POST("/projects", handleCreateProject)
// Service routes (nested under projects)
protected.GET("/projects/:id/services", handleGetServices)
protected.POST("/projects/:id/services", handleCreateService)
// Generic project routes
protected.GET("/projects/:id", handleGetProject)
protected.PUT("/projects/:id", handleUpdateProject)
protected.DELETE("/projects/:id", handleDeleteProject)
// Service routes
protected.GET("/services/:id", handleGetService)
protected.PUT("/services/:id", handleUpdateService)
protected.DELETE("/services/:id", handleDeleteService)
// Deployment routes
protected.GET("/services/:id/deployments", handleGetDeployments)
protected.POST("/services/:id/deployments", handleCreateDeployment)
protected.GET("/deployments/:id", handleGetDeployment)
protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
// Environment variables routes
protected.GET("/services/:id/variables", handleGetVariables)
protected.PUT("/services/:id/variables", handleUpdateVariables)
// Logs routes
protected.GET("/services/:id/logs", handleGetLogs)
protected.GET("/deployments/:id/logs", handleGetDeploymentLogs)
// Git integration routes
protected.GET("/git/providers", handleGetGitProviders)
protected.POST("/git/providers", handleCreateGitProvider)
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
protected.POST("/git/repositories/connect", handleConnectGitRepository)
protected.GET("/git/repositories", handleGetConnectedRepositories)
protected.GET("/git/repositories/:repoId/branches", handleGetRepositoryBranches)
protected.POST("/git/webhooks", handleCreateWebhook)
// Build routes
protected.POST("/builds", buildHandler.StartBuild)
protected.GET("/builds", buildHandler.ListBuilds)
protected.GET("/builds/:id", buildHandler.GetBuildStatus)
protected.POST("/builds/:id/cancel", buildHandler.CancelBuild)
protected.GET("/builds/:id/logs", buildHandler.GetBuildLogs)
protected.POST("/builds/plan", buildHandler.GetBuildPlan)
protected.GET("/builds/detect", buildHandler.DetectBuildType)
// Scaling routes
scalingHandler.RegisterRoutes(protected)
// Database routes
protected.GET("/databases", databaseHandler.GetDatabases)
protected.POST("/databases", databaseHandler.CreateDatabase)
protected.GET("/databases/:id", databaseHandler.GetDatabase)
protected.PUT("/databases/:id", databaseHandler.UpdateDatabase)
protected.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
protected.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
protected.POST("/databases/:id/backup", databaseHandler.CreateBackup)
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
// Node Agent routes
api := router.Group("/api")
agentHandler.SetupRoutes(api)
// Preview Environments routes
protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
protected.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
protected.GET("/preview-environments/:id", handleGetPreviewEnvironment)
protected.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
protected.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
protected.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment)
protected.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments)
// Security routes
protected.POST("/security/scans", securityHandler.StartSecurityScan)
protected.GET("/security/scans/:id", securityHandler.GetSecurityScan)
protected.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
protected.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
protected.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
protected.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
protected.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
protected.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
protected.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
protected.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
protected.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
// WebSocket endpoint
protected.GET("/ws", handleWebSocket)
// Templates routes
protected.GET("/templates", handleGetTemplates)
protected.GET("/templates/:id", handleGetTemplate)
protected.POST("/templates/import/github", handleImportGitHubTemplate)
protected.POST("/templates/import/compose", handleImportComposeTemplate)
protected.POST("/templates/:id/deploy", handleCreateFromTemplate)
// Cron Jobs routes
protected.GET("/cron-jobs", handleGetCronJobs)
protected.POST("/cron-jobs", handleCreateCronJob)
protected.GET("/cron-jobs/:id", handleGetCronJob)
protected.PUT("/cron-jobs/:id", handleUpdateCronJob)
protected.DELETE("/cron-jobs/:id", handleDeleteCronJob)
protected.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
protected.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
// Audit Logs routes
protected.GET("/audit-logs", handleGetAuditLogs)
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
}
// APwhy Gateway routes
apwhy := router.Group("/api/v1")
{
// Health check (no auth required)
apwhy.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"ok": true,
"data": gin.H{
"status": "ok",
"name": "Containr + APwhy",
"database": "postgresql",
"generatedAt": time.Now().UTC().Format(time.RFC3339),
},
})
})
}
// Protected APwhy routes (authentication required)
protectedAPwhy := router.Group("/api/v1")
protectedAPwhy.Use(middleware.Auth(cfg.JWTSecret))
{
// Service management
protectedAPwhy.GET("/services", handleAPwhyServicesList)
protectedAPwhy.POST("/services", handleAPwhyServicesCreate)
protectedAPwhy.PATCH("/services/:id", handleAPwhyServicesPatch)
// API Keys
protectedAPwhy.GET("/keys", handleAPwhyKeysList)
protectedAPwhy.POST("/keys", handleAPwhyKeysCreate)
protectedAPwhy.PATCH("/keys/:id", handleAPwhyKeysPatch)
// Analytics
protectedAPwhy.GET("/analytics/ops", handleAPwhyAnalyticsOps)
protectedAPwhy.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
}
}
}
+455
View File
@@ -0,0 +1,455 @@
package api
import (
"net/http"
"strconv"
"time"
"containr/internal/scaling"
"github.com/gin-gonic/gin"
)
// ScalingHandler handles scaling-related API endpoints
type ScalingHandler struct {
autoScaler *scaling.AutoScaler
}
// NewScalingHandler creates a new scaling handler
func NewScalingHandler(autoScaler *scaling.AutoScaler) *ScalingHandler {
return &ScalingHandler{
autoScaler: autoScaler,
}
}
// RegisterRoutes registers scaling routes
func (h *ScalingHandler) RegisterRoutes(router *gin.RouterGroup) {
scaling := router.Group("/scaling")
{
scaling.GET("/policies", h.GetScalingPolicies)
scaling.POST("/policies", h.SetScalingPolicy)
scaling.GET("/policies/:serviceId", h.GetScalingPolicy)
scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy)
scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy)
scaling.GET("/services", h.GetServiceStates)
scaling.GET("/services/:serviceId", h.GetServiceState)
scaling.GET("/services/:serviceId/history", h.GetScalingHistory)
scaling.POST("/services/:serviceId/scale", h.ManualScale)
scaling.GET("/status", h.GetScalingStatus)
scaling.POST("/enable", h.EnableAutoScaler)
scaling.POST("/disable", h.DisableAutoScaler)
scaling.GET("/metrics", h.GetScalingMetrics)
scaling.GET("/events", h.GetScalingEvents)
}
}
// GetScalingPolicies returns all scaling policies
func (h *ScalingHandler) GetScalingPolicies(c *gin.Context) {
states := h.autoScaler.GetAllServiceStates()
policies := make([]*scaling.ScalingPolicy, 0)
for _, state := range states {
if state.Policy != nil {
policies = append(policies, state.Policy)
}
}
c.JSON(http.StatusOK, gin.H{
"policies": policies,
"count": len(policies),
})
}
// SetScalingPolicy creates or updates a scaling policy
func (h *ScalingHandler) SetScalingPolicy(c *gin.Context) {
var policy scaling.ScalingPolicy
if err := c.ShouldBindJSON(&policy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.autoScaler.SetScalingPolicy(&policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Scaling policy set successfully",
"policy": policy,
})
}
// GetScalingPolicy returns a specific scaling policy
func (h *ScalingHandler) GetScalingPolicy(c *gin.Context) {
serviceID := c.Param("serviceId")
policy, err := h.autoScaler.GetScalingPolicy(serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"policy": policy,
})
}
// UpdateScalingPolicy updates an existing scaling policy
func (h *ScalingHandler) UpdateScalingPolicy(c *gin.Context) {
serviceID := c.Param("serviceId")
var policy scaling.ScalingPolicy
if err := c.ShouldBindJSON(&policy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Ensure the service ID matches
policy.ServiceID = serviceID
if err := h.autoScaler.SetScalingPolicy(&policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Scaling policy updated successfully",
"policy": policy,
})
}
// DeleteScalingPolicy removes a scaling policy
func (h *ScalingHandler) DeleteScalingPolicy(c *gin.Context) {
serviceID := c.Param("serviceId")
// Set policy to disabled instead of deleting
policy, err := h.autoScaler.GetScalingPolicy(serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
policy.Enabled = false
if err := h.autoScaler.SetScalingPolicy(policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Scaling policy disabled successfully",
})
}
// GetServiceStates returns all service scaling states
func (h *ScalingHandler) GetServiceStates(c *gin.Context) {
states := h.autoScaler.GetAllServiceStates()
c.JSON(http.StatusOK, gin.H{
"services": states,
"count": len(states),
})
}
// GetServiceState returns a specific service's scaling state
func (h *ScalingHandler) GetServiceState(c *gin.Context) {
serviceID := c.Param("serviceId")
state, err := h.autoScaler.GetServiceState(serviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"state": state,
})
}
// GetScalingHistory returns scaling history for a service
func (h *ScalingHandler) GetScalingHistory(c *gin.Context) {
serviceID := c.Param("serviceId")
// TODO: Implement scaling history retrieval from database
// For now, return mock data
history := []map[string]interface{}{
{
"timestamp": time.Now().Add(-2 * time.Hour),
"action": "scale_up",
"from": 2,
"to": 3,
"reason": "CPU usage above target",
},
{
"timestamp": time.Now().Add(-1 * time.Hour),
"action": "scale_down",
"from": 3,
"to": 2,
"reason": "CPU usage below target",
},
}
c.JSON(http.StatusOK, gin.H{
"service_id": serviceID,
"history": history,
"count": len(history),
})
}
// ManualScale performs manual scaling of a service
func (h *ScalingHandler) ManualScale(c *gin.Context) {
serviceID := c.Param("serviceId")
var request struct {
Replicas int `json:"replicas" binding:"required,min=1,max=20"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// TODO: Implement manual scaling
// This would bypass the auto-scaler and directly scale the service
c.JSON(http.StatusAccepted, gin.H{
"message": "Manual scaling initiated",
"service_id": serviceID,
"replicas": request.Replicas,
"reason": request.Reason,
})
}
// GetScalingStatus returns the overall status of the auto-scaler
func (h *ScalingHandler) GetScalingStatus(c *gin.Context) {
summary := h.autoScaler.GetScalingSummary()
c.JSON(http.StatusOK, gin.H{
"status": "active",
"summary": summary,
"enabled": h.autoScaler.IsEnabled(),
})
}
// EnableAutoScaler enables the auto-scaler
func (h *ScalingHandler) EnableAutoScaler(c *gin.Context) {
h.autoScaler.Enable()
c.JSON(http.StatusOK, gin.H{
"message": "Auto-scaler enabled",
"enabled": true,
})
}
// DisableAutoScaler disables the auto-scaler
func (h *ScalingHandler) DisableAutoScaler(c *gin.Context) {
h.autoScaler.Disable()
c.JSON(http.StatusOK, gin.H{
"message": "Auto-scaler disabled",
"enabled": false,
})
}
// GetScalingMetrics returns scaling-related metrics
func (h *ScalingHandler) GetScalingMetrics(c *gin.Context) {
summary := h.autoScaler.GetScalingSummary()
// Add additional metrics
metrics := map[string]interface{}{
"total_services": summary["total_services"],
"enabled_services": summary["enabled_services"],
"total_replicas": summary["total_replicas"],
"services_scaling_up": summary["scaling_up"],
"services_scaling_down": summary["scaling_down"],
"auto_scaler_enabled": summary["enabled"],
"check_interval_seconds": summary["check_interval"],
"timestamp": time.Now(),
}
c.JSON(http.StatusOK, gin.H{
"metrics": metrics,
})
}
// GetScalingEvents returns recent scaling events
func (h *ScalingHandler) GetScalingEvents(c *gin.Context) {
// Parse query parameters
limitStr := c.DefaultQuery("limit", "50")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 50
}
// TODO: Implement scaling events retrieval from database
// For now, return mock data
events := []map[string]interface{}{
{
"id": "evt_1",
"service_id": "web-service",
"action": "scale_up",
"from": 2,
"to": 3,
"reason": "CPU usage (85%) above target (70%)",
"timestamp": time.Now().Add(-30 * time.Minute),
"cost_impact": 0.01,
},
{
"id": "evt_2",
"service_id": "api-service",
"action": "scale_down",
"from": 5,
"to": 3,
"reason": "Low request rate (10/s)",
"timestamp": time.Now().Add(-1 * time.Hour),
"cost_impact": -0.02,
},
}
// Limit results
if len(events) > limit {
events = events[:limit]
}
c.JSON(http.StatusOK, gin.H{
"events": events,
"count": len(events),
"limit": limit,
})
}
// ScalingPolicyTemplate represents a template for creating scaling policies
type ScalingPolicyTemplate struct {
Name string `json:"name"`
Description string `json:"description"`
Template scaling.ScalingPolicy `json:"template"`
}
// GetScalingPolicyTemplates returns predefined scaling policy templates
func (h *ScalingHandler) GetScalingPolicyTemplates(c *gin.Context) {
templates := []ScalingPolicyTemplate{
{
Name: "Web Application",
Description: "Standard scaling policy for web applications",
Template: scaling.ScalingPolicy{
MinReplicas: 2,
MaxReplicas: 10,
TargetCPU: 70.0,
TargetMemory: 80.0,
ScaleUpCooldown: 3 * time.Minute,
ScaleDownCooldown: 5 * time.Minute,
ScaleUpStep: 1,
ScaleDownStep: 1,
Metrics: []string{"cpu", "memory", "requests_per_second"},
Enabled: true,
CostOptimization: &scaling.CostOptimization{
MaxCostPerHour: 1.0,
PreferEfficiency: true,
IdleTimeout: 10 * time.Minute,
},
},
},
{
Name: "API Service",
Description: "Aggressive scaling for API services",
Template: scaling.ScalingPolicy{
MinReplicas: 1,
MaxReplicas: 20,
TargetCPU: 60.0,
TargetMemory: 75.0,
ScaleUpCooldown: 1 * time.Minute,
ScaleDownCooldown: 3 * time.Minute,
ScaleUpStep: 2,
ScaleDownStep: 1,
Metrics: []string{"cpu", "memory", "requests_per_second", "error_rate"},
Enabled: true,
CostOptimization: &scaling.CostOptimization{
MaxCostPerHour: 2.0,
PreferEfficiency: false,
IdleTimeout: 5 * time.Minute,
},
},
},
{
Name: "Background Worker",
Description: "Conservative scaling for background workers",
Template: scaling.ScalingPolicy{
MinReplicas: 1,
MaxReplicas: 5,
TargetCPU: 80.0,
TargetMemory: 85.0,
ScaleUpCooldown: 5 * time.Minute,
ScaleDownCooldown: 10 * time.Minute,
ScaleUpStep: 1,
ScaleDownStep: 1,
Metrics: []string{"cpu", "memory"},
Enabled: true,
CostOptimization: &scaling.CostOptimization{
MaxCostPerHour: 0.5,
PreferEfficiency: true,
IdleTimeout: 15 * time.Minute,
},
},
},
}
c.JSON(http.StatusOK, gin.H{
"templates": templates,
"count": len(templates),
})
}
// ValidateScalingPolicy validates a scaling policy
func (h *ScalingHandler) ValidateScalingPolicy(c *gin.Context) {
var policy scaling.ScalingPolicy
if err := c.ShouldBindJSON(&policy); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
errors := []string{}
warnings := []string{}
// Validate policy
if policy.MinReplicas < 1 {
errors = append(errors, "min_replicas must be at least 1")
}
if policy.MaxReplicas < policy.MinReplicas {
errors = append(errors, "max_replicas must be greater than or equal to min_replicas")
}
if policy.TargetCPU <= 0 || policy.TargetCPU > 100 {
errors = append(errors, "target_cpu must be between 0 and 100")
}
if policy.TargetMemory <= 0 || policy.TargetMemory > 100 {
errors = append(errors, "target_memory must be between 0 and 100")
}
if policy.ScaleUpStep < 1 {
errors = append(errors, "scale_up_step must be at least 1")
}
if policy.ScaleDownStep < 1 {
errors = append(errors, "scale_down_step must be at least 1")
}
// Warnings
if policy.MaxReplicas > 20 {
warnings = append(warnings, "max_replicas greater than 20 may be costly")
}
if policy.ScaleUpCooldown < 1*time.Minute {
warnings = append(warnings, "scale_up_cooldown less than 1 minute may cause thrashing")
}
if policy.ScaleDownCooldown < 2*time.Minute {
warnings = append(warnings, "scale_down_cooldown less than 2 minutes may cause thrashing")
}
valid := len(errors) == 0
c.JSON(http.StatusOK, gin.H{
"valid": valid,
"errors": errors,
"warnings": warnings,
})
}
+612
View File
@@ -0,0 +1,612 @@
package api
import (
"containr/internal/database"
"containr/internal/security"
"database/sql"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// SecurityHandler handles security-related API endpoints
type SecurityHandler struct {
db *database.DB
scanner *security.Scanner
complianceManager *security.ComplianceManager
encryptionManager *security.EncryptionManager
dataRetentionManager *security.DataRetentionManager
auditLogger *security.AuditLogger
}
// NewSecurityHandler creates a new security handler
func NewSecurityHandler(db *database.DB, encryptionKey string) *SecurityHandler {
encryptionManager, _ := security.NewEncryptionManager(encryptionKey)
return &SecurityHandler{
db: db,
scanner: security.NewScanner(db),
complianceManager: security.NewComplianceManager(db),
encryptionManager: encryptionManager,
dataRetentionManager: security.NewDataRetentionManager(encryptionManager),
auditLogger: security.NewAuditLogger(encryptionManager),
}
}
// StartSecurityScan starts a new security scan
func (sh *SecurityHandler) StartSecurityScan(c *gin.Context) {
var req struct {
ProjectID string `json:"project_id" binding:"required"`
ServiceID string `json:"service_id,omitempty"`
ScanType string `json:"scan_type" binding:"required,oneof=dependency configuration comprehensive"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, ok := sh.requireProjectAccess(c, req.ProjectID)
if !ok {
return
}
if req.ServiceID != "" {
if _, err := uuid.Parse(req.ServiceID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
var serviceExists bool
err := sh.db.QueryRow(
`SELECT EXISTS(
SELECT 1 FROM services WHERE id = $1 AND project_id = $2
)`,
req.ServiceID,
req.ProjectID,
).Scan(&serviceExists)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate service"})
return
}
if !serviceExists {
c.JSON(http.StatusBadRequest, gin.H{"error": "Service not found in project"})
return
}
}
// Log audit event
sh.auditLogger.LogSecurityEvent(userID, "security_scan_started", "project",
map[string]interface{}{
"project_id": req.ProjectID,
"service_id": req.ServiceID,
"scan_type": req.ScanType,
}, c.ClientIP(), c.GetHeader("User-Agent"), true)
scan, err := sh.scanner.StartSecurityScan(req.ProjectID, req.ServiceID, req.ScanType)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start security scan"})
return
}
c.JSON(http.StatusAccepted, scan)
}
// GetSecurityScan retrieves a security scan
func (sh *SecurityHandler) GetSecurityScan(c *gin.Context) {
scanID := firstPathParam(c, "scanId", "id")
if !sh.requireSecurityScanAccess(c, scanID) {
return
}
scan, err := sh.scanner.GetSecurityScan(scanID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Security scan not found"})
return
}
c.JSON(http.StatusOK, scan)
}
// GetProjectSecurityHistory retrieves security scan history for a project
func (sh *SecurityHandler) GetProjectSecurityHistory(c *gin.Context) {
projectID := firstPathParam(c, "projectId", "id", "project_id")
if _, ok := sh.requireProjectAccess(c, projectID); !ok {
return
}
limit := 10
if limitStr := c.Query("limit"); limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 && parsedLimit <= 1000 {
limit = parsedLimit
}
}
scans, err := sh.scanner.GetProjectSecurityHistory(projectID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get security history"})
return
}
c.JSON(http.StatusOK, gin.H{"scans": scans})
}
// GetVulnerabilities retrieves vulnerabilities for a project
func (sh *SecurityHandler) GetVulnerabilities(c *gin.Context) {
projectID := firstPathParam(c, "projectId", "id", "project_id")
if _, ok := sh.requireProjectAccess(c, projectID); !ok {
return
}
// Query vulnerabilities
rows, err := sh.db.Query(`
SELECT id, type, severity, title, description, service_id, status, found_at, resolved_at
FROM vulnerabilities
WHERE project_id = $1
ORDER BY
CASE severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
END,
found_at DESC
`, projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerabilities"})
return
}
defer rows.Close()
var vulnerabilities []security.Vulnerability
for rows.Next() {
var vuln security.Vulnerability
var resolvedAt *time.Time
err := rows.Scan(&vuln.ID, &vuln.Type, &vuln.Severity, &vuln.Title, &vuln.Description,
&vuln.ServiceID, &vuln.Status, &vuln.FoundAt, &resolvedAt)
if err != nil {
continue
}
vuln.ResolvedAt = resolvedAt
vulnerabilities = append(vulnerabilities, vuln)
}
c.JSON(http.StatusOK, gin.H{"vulnerabilities": vulnerabilities})
}
// UpdateVulnerability updates a vulnerability status
func (sh *SecurityHandler) UpdateVulnerability(c *gin.Context) {
vulnID := firstPathParam(c, "vulnId", "id")
userID, ok := sh.requireVulnerabilityAccess(c, vulnID)
if !ok {
return
}
var req struct {
Status string `json:"status" binding:"required,oneof=open resolved ignored"`
Notes string `json:"notes,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var resolvedAt *time.Time
if req.Status == "resolved" {
now := time.Now()
resolvedAt = &now
}
_, err := sh.db.Exec(`
UPDATE vulnerabilities
SET status = $1, resolved_at = $2
WHERE id = $3
`, req.Status, resolvedAt, vulnID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update vulnerability"})
return
}
// Log audit event
sh.auditLogger.LogSecurityEvent(userID, "vulnerability_updated", "vulnerability",
map[string]interface{}{
"vulnerability_id": vulnID,
"new_status": req.Status,
"notes": req.Notes,
}, c.ClientIP(), c.GetHeader("User-Agent"), true)
c.JSON(http.StatusOK, gin.H{"status": "updated"})
}
// StartComplianceAssessment starts a new compliance assessment
func (sh *SecurityHandler) StartComplianceAssessment(c *gin.Context) {
var req struct {
ProjectID string `json:"project_id" binding:"required"`
FrameworkID string `json:"framework_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, ok := sh.requireProjectAccess(c, req.ProjectID)
if !ok {
return
}
if _, err := uuid.Parse(req.FrameworkID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid framework ID"})
return
}
var frameworkExists bool
err := sh.db.QueryRow(
`SELECT EXISTS(
SELECT 1 FROM compliance_frameworks WHERE id = $1
)`,
req.FrameworkID,
).Scan(&frameworkExists)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate framework"})
return
}
if !frameworkExists {
c.JSON(http.StatusNotFound, gin.H{"error": "Compliance framework not found"})
return
}
// Log audit event
sh.auditLogger.LogSecurityEvent(userID, "compliance_assessment_started", "project",
map[string]interface{}{
"project_id": req.ProjectID,
"framework_id": req.FrameworkID,
}, c.ClientIP(), c.GetHeader("User-Agent"), true)
report, err := sh.complianceManager.AssessCompliance(req.ProjectID, req.FrameworkID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start compliance assessment"})
return
}
c.JSON(http.StatusAccepted, report)
}
// GetComplianceReport retrieves a compliance report
func (sh *SecurityHandler) GetComplianceReport(c *gin.Context) {
reportID := firstPathParam(c, "reportId", "id")
if !sh.requireComplianceReportAccess(c, reportID) {
return
}
report, err := sh.complianceManager.GetComplianceReport(reportID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Compliance report not found"})
return
}
c.JSON(http.StatusOK, report)
}
// GetComplianceFrameworks retrieves available compliance frameworks
func (sh *SecurityHandler) GetComplianceFrameworks(c *gin.Context) {
rows, err := sh.db.Query(`
SELECT id, name, description, version, enabled, created_at
FROM compliance_frameworks
WHERE enabled = true
ORDER BY name
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get compliance frameworks"})
return
}
defer rows.Close()
var frameworks []security.ComplianceFramework
for rows.Next() {
var framework security.ComplianceFramework
err := rows.Scan(&framework.ID, &framework.Name, &framework.Description,
&framework.Version, &framework.Enabled, &framework.CreatedAt)
if err != nil {
continue
}
frameworks = append(frameworks, framework)
}
c.JSON(http.StatusOK, gin.H{"frameworks": frameworks})
}
// InitializeGDPRFramework initializes the GDPR compliance framework
func (sh *SecurityHandler) InitializeGDPRFramework(c *gin.Context) {
userID := c.MustGet("user_id").(string)
err := sh.complianceManager.InitializeGDPRFramework()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize GDPR framework"})
return
}
// Log audit event
sh.auditLogger.LogSecurityEvent(userID, "gdpr_framework_initialized", "compliance",
map[string]interface{}{}, c.ClientIP(), c.GetHeader("User-Agent"), true)
c.JSON(http.StatusOK, gin.H{"status": "initialized"})
}
// GetSecurityMetrics retrieves security metrics for a project
func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
projectID := firstPathParam(c, "projectId", "id", "project_id")
if _, ok := sh.requireProjectAccess(c, projectID); !ok {
return
}
// Get vulnerability counts
var vulnMetrics struct {
Total int `json:"total"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
Open int `json:"open"`
Resolved int `json:"resolved"`
}
err := sh.db.QueryRow(`
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE severity = 'critical') as critical,
COUNT(*) FILTER (WHERE severity = 'high') as high,
COUNT(*) FILTER (WHERE severity = 'medium') as medium,
COUNT(*) FILTER (WHERE severity = 'low') as low,
COUNT(*) FILTER (WHERE status = 'open') as open,
COUNT(*) FILTER (WHERE status = 'resolved') as resolved
FROM vulnerabilities
WHERE project_id = $1
`, projectID).Scan(&vulnMetrics.Total, &vulnMetrics.Critical, &vulnMetrics.High,
&vulnMetrics.Medium, &vulnMetrics.Low, &vulnMetrics.Open, &vulnMetrics.Resolved)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerability metrics"})
return
}
// Get latest scan
var latestScan struct {
ID string `json:"id"`
Score int `json:"score"`
ScannedAt time.Time `json:"scanned_at"`
Status string `json:"status"`
}
err = sh.db.QueryRow(`
SELECT id, score, started_at as scanned_at, status
FROM security_scans
WHERE project_id = $1
ORDER BY started_at DESC
LIMIT 1
`, projectID).Scan(&latestScan.ID, &latestScan.Score, &latestScan.ScannedAt, &latestScan.Status)
if err == sql.ErrNoRows {
latestScan = struct {
ID string `json:"id"`
Score int `json:"score"`
ScannedAt time.Time `json:"scanned_at"`
Status string `json:"status"`
}{ID: "", Score: 0, ScannedAt: time.Time{}, Status: "never_scanned"}
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get latest scan"})
return
}
// Get compliance status
var complianceStatus struct {
OverallStatus string `json:"overall_status"`
Score int `json:"score"`
LastAssessed *time.Time `json:"last_assessed"`
}
err = sh.db.QueryRow(`
SELECT overall_status, score, assessment_date
FROM compliance_reports
WHERE project_id = $1
ORDER BY assessment_date DESC
LIMIT 1
`, projectID).Scan(&complianceStatus.OverallStatus, &complianceStatus.Score, &complianceStatus.LastAssessed)
if err == sql.ErrNoRows {
complianceStatus = struct {
OverallStatus string `json:"overall_status"`
Score int `json:"score"`
LastAssessed *time.Time `json:"last_assessed"`
}{OverallStatus: "not_assessed", Score: 0, LastAssessed: nil}
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get compliance status"})
return
}
metrics := gin.H{
"vulnerabilities": vulnMetrics,
"latest_scan": latestScan,
"compliance": complianceStatus,
"security_score": sh.calculateOverallSecurityScore(struct{ Total, Critical, High, Medium, Low, Open, Resolved int }{
Total: vulnMetrics.Total, Critical: vulnMetrics.Critical, High: vulnMetrics.High,
Medium: vulnMetrics.Medium, Low: vulnMetrics.Low, Open: vulnMetrics.Open, Resolved: vulnMetrics.Resolved,
}, latestScan.Score, complianceStatus.Score),
}
c.JSON(http.StatusOK, metrics)
}
// calculateOverallSecurityScore calculates an overall security score
func (sh *SecurityHandler) calculateOverallSecurityScore(vulnMetrics struct {
Total, Critical, High, Medium, Low, Open, Resolved int
}, scanScore, complianceScore int) int {
// Weight the different components
vulnScore := 100
if vulnMetrics.Total > 0 {
deduction := (vulnMetrics.Critical * 25) + (vulnMetrics.High * 15) + (vulnMetrics.Medium * 8) + (vulnMetrics.Low * 3)
vulnScore = max(0, 100-deduction)
}
// Calculate weighted average
overallScore := (vulnScore*40 + scanScore*30 + complianceScore*30) / 100
return overallScore
}
// GetAuditLogs retrieves audit logs for security events
func (sh *SecurityHandler) GetAuditLogs(c *gin.Context) {
projectID := firstPathParam(c, "projectId", "id", "project_id")
if _, ok := sh.requireProjectAccess(c, projectID); !ok {
return
}
limit := 50
if limitStr := c.Query("limit"); limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 && parsedLimit <= 1000 {
limit = parsedLimit
}
}
// In a real implementation, this would query the audit database
// For now, return a placeholder response
c.JSON(http.StatusOK, gin.H{
"audit_logs": []gin.H{
{
"id": uuid.New().String(),
"timestamp": time.Now(),
"user_id": c.MustGet("user_id").(string),
"action": "security_scan_started",
"resource": "project",
"ip_address": c.ClientIP(),
"success": true,
},
},
"total": 1,
"limit": limit,
})
}
func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string) (string, bool) {
userIDValue, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return "", false
}
userID, ok := userIDValue.(string)
if !ok || userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return "", false
}
if _, err := uuid.Parse(projectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return "", false
}
var hasAccess bool
err := sh.db.QueryRow(
`SELECT EXISTS (
SELECT 1
FROM projects p
WHERE p.id = $1
AND (p.owner_id = $2 OR EXISTS (
SELECT 1 FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $2
))
)`,
projectID, userID,
).Scan(&hasAccess)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"})
return "", false
}
if !hasAccess {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return "", false
}
return userID, true
}
func (sh *SecurityHandler) requireSecurityScanAccess(c *gin.Context, scanID string) bool {
if _, err := uuid.Parse(scanID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid scan ID"})
return false
}
var projectID string
err := sh.db.QueryRow("SELECT project_id FROM security_scans WHERE id = $1", scanID).Scan(&projectID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Security scan not found"})
return false
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify scan access"})
return false
}
_, ok := sh.requireProjectAccess(c, projectID)
return ok
}
func (sh *SecurityHandler) requireComplianceReportAccess(c *gin.Context, reportID string) bool {
if _, err := uuid.Parse(reportID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid report ID"})
return false
}
var projectID string
err := sh.db.QueryRow("SELECT project_id FROM compliance_reports WHERE id = $1", reportID).Scan(&projectID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Compliance report not found"})
return false
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify report access"})
return false
}
_, ok := sh.requireProjectAccess(c, projectID)
return ok
}
func (sh *SecurityHandler) requireVulnerabilityAccess(c *gin.Context, vulnID string) (string, bool) {
if _, err := uuid.Parse(vulnID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid vulnerability ID"})
return "", false
}
var projectID string
err := sh.db.QueryRow("SELECT project_id FROM vulnerabilities WHERE id = $1", vulnID).Scan(&projectID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Vulnerability not found"})
return "", false
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify vulnerability access"})
return "", false
}
return sh.requireProjectAccess(c, projectID)
}
// max helper function
func max(a, b int) int {
if a > b {
return a
}
return b
}
File diff suppressed because it is too large Load Diff
+462
View File
@@ -0,0 +1,462 @@
package api
import (
"containr/internal/database"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Service represents a service in the system
type Service struct {
ID uuid.UUID `json:"id" db:"id"`
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
Name string `json:"name" db:"name"`
Type string `json:"type" db:"type"` // web, worker, database, etc.
Status string `json:"status" db:"status"` // building, running, failed, stopped
Image string `json:"image" db:"image"`
Command string `json:"command" db:"command"`
Environment string `json:"environment" db:"environment"` // production, preview, development
GitRepo string `json:"git_repo" db:"git_repo"`
GitBranch string `json:"git_branch" db:"git_branch"`
BuildPath string `json:"build_path" db:"build_path"`
CPU string `json:"cpu" db:"cpu"`
Memory string `json:"memory" db:"memory"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateServiceRequest represents a request to create a service
type CreateServiceRequest struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name" binding:"required,min=1,max=255"`
Type string `json:"type" binding:"required,oneof=web worker database cron"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment" binding:"required,oneof=production preview development"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"`
CPU string `json:"cpu"`
Memory string `json:"memory"`
}
// UpdateServiceRequest represents a request to update a service
type UpdateServiceRequest struct {
Name string `json:"name" binding:"omitempty,min=1,max=255"`
Type string `json:"type" binding:"omitempty,oneof=web worker database cron"`
Status string `json:"status" binding:"omitempty,oneof=building running failed stopped"`
Image string `json:"image"`
Command string `json:"command"`
Environment string `json:"environment" binding:"omitempty,oneof=production preview development"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
BuildPath string `json:"build_path"`
CPU string `json:"cpu"`
Memory string `json:"memory"`
}
// handleGetServices retrieves all services for a project
func handleGetServices(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
projectIDStr := firstPathParam(c, "id", "project_id", "projectId")
projectID, err := uuid.Parse(projectIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
// Check if project exists and user has access
var project Project
err = db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
projectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Get user ID from JWT token (set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Get services for the project
rows, err := db.(*database.DB).Query(
`SELECT id, project_id, name, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at
FROM services
WHERE project_id = $1
ORDER BY created_at DESC`,
projectID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve services"})
return
}
defer rows.Close()
var services []Service
for rows.Next() {
var service Service
err := rows.Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.CreatedAt, &service.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
return
}
services = append(services, service)
}
c.JSON(http.StatusOK, gin.H{"services": services})
}
// handleCreateService creates a new service
func handleCreateService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
projectIDStr := firstPathParam(c, "id", "project_id", "projectId")
projectID, err := uuid.Parse(projectIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
return
}
var req CreateServiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.ProjectID == uuid.Nil {
req.ProjectID = projectID
} else if req.ProjectID != projectID {
c.JSON(http.StatusBadRequest, gin.H{"error": "Project ID in URL and request body must match"})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if project exists and user has access
var project Project
err = db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
req.ProjectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Check if service name already exists in the project
var count int
err = db.(*database.DB).QueryRow(
"SELECT COUNT(*) FROM services WHERE project_id = $1 AND name = $2",
req.ProjectID, req.Name,
).Scan(&count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check service name"})
return
}
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Service name already exists in this project"})
return
}
// Create new service
service := Service{
ID: uuid.New(),
ProjectID: req.ProjectID,
Name: req.Name,
Type: req.Type,
Status: "stopped", // Initial status
Image: req.Image,
Command: req.Command,
Environment: req.Environment,
GitRepo: req.GitRepo,
GitBranch: req.GitBranch,
BuildPath: req.BuildPath,
CPU: req.CPU,
Memory: req.Memory,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Set default values if not provided
if service.CPU == "" {
service.CPU = "0.5"
}
if service.Memory == "" {
service.Memory = "512Mi"
}
// Insert service into database
_, err = db.(*database.DB).Exec(
`INSERT INTO services
(id, project_id, name, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
service.ID, service.ProjectID, service.Name, service.Type, service.Status,
service.Image, service.Command, service.Environment, service.GitRepo,
service.GitBranch, service.BuildPath, service.CPU, service.Memory,
service.CreatedAt, service.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service"})
return
}
c.JSON(http.StatusCreated, gin.H{"service": service})
}
// handleGetService retrieves a specific service
func handleGetService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Get service with project ownership check
var service Service
err = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
s.created_at, s.updated_at
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND p.owner_id = $2`,
serviceID, userID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.CreatedAt, &service.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
c.JSON(http.StatusOK, gin.H{"service": service})
}
// handleUpdateService updates a service
func handleUpdateService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
var req UpdateServiceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if service exists and user has access
var existingService Service
err = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
s.created_at, s.updated_at
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND p.owner_id = $2`,
serviceID, userID,
).Scan(
&existingService.ID, &existingService.ProjectID, &existingService.Name, &existingService.Type,
&existingService.Status, &existingService.Image, &existingService.Command,
&existingService.Environment, &existingService.GitRepo, &existingService.GitBranch,
&existingService.BuildPath, &existingService.CPU, &existingService.Memory,
&existingService.CreatedAt, &existingService.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
// Update fields if provided
if req.Name != "" {
existingService.Name = req.Name
}
if req.Type != "" {
existingService.Type = req.Type
}
if req.Status != "" {
existingService.Status = req.Status
}
if req.Image != "" {
existingService.Image = req.Image
}
if req.Command != "" {
existingService.Command = req.Command
}
if req.Environment != "" {
existingService.Environment = req.Environment
}
if req.GitRepo != "" {
existingService.GitRepo = req.GitRepo
}
if req.GitBranch != "" {
existingService.GitBranch = req.GitBranch
}
if req.BuildPath != "" {
existingService.BuildPath = req.BuildPath
}
if req.CPU != "" {
existingService.CPU = req.CPU
}
if req.Memory != "" {
existingService.Memory = req.Memory
}
existingService.UpdatedAt = time.Now()
// Update service in database
_, err = db.(*database.DB).Exec(
`UPDATE services
SET name = $1, type = $2, status = $3, image = $4, command = $5, environment = $6,
git_repo = $7, git_branch = $8, build_path = $9, cpu = $10, memory = $11, updated_at = $12
WHERE id = $13`,
existingService.Name, existingService.Type, existingService.Status, existingService.Image,
existingService.Command, existingService.Environment, existingService.GitRepo,
existingService.GitBranch, existingService.BuildPath, existingService.CPU, existingService.Memory,
existingService.UpdatedAt, existingService.ID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service"})
return
}
c.JSON(http.StatusOK, gin.H{"service": existingService})
}
// handleDeleteService deletes a service
func handleDeleteService(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if service exists and user has access
var projectOwnerID string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&projectOwnerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
// Check if user owns the project
if projectOwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Delete service (cascade will handle related records)
_, err = db.(*database.DB).Exec(
"DELETE FROM services WHERE id = $1",
serviceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete service"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Service deleted successfully"})
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>APwhy</title>
<script type="module" crossorigin src="/assets/index-DwfYiTMH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DRUelTBf.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+964
View File
@@ -0,0 +1,964 @@
package api
import (
"containr/internal/database"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"regexp"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gopkg.in/yaml.v3"
)
var composeVariablePattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:-|-|:\?|\?)([^}]*))?\}`)
var defaultComposePaths = []string{
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
}
type ServiceTemplate struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Category string `json:"category" db:"category"`
Logo string `json:"logo" db:"logo"`
Config string `json:"config" db:"config"`
Variables string `json:"variables" db:"variables"`
Screenshots string `json:"screenshots" db:"screenshots"`
ComposeYAML string `json:"compose_yaml,omitempty" db:"compose_yaml"`
IsOfficial bool `json:"is_official" db:"is_official"`
SourceType string `json:"source_type" db:"source_type"`
SourceRepo string `json:"source_repo,omitempty" db:"source_repo"`
SourceBranch string `json:"source_branch,omitempty" db:"source_branch"`
SourcePath string `json:"source_path,omitempty" db:"source_path"`
SourceURL string `json:"source_url,omitempty" db:"source_url"`
CreatedBy string `json:"created_by,omitempty" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type TemplateConfig struct {
Type string `json:"type"`
Runtime string `json:"runtime"`
BuildCommand string `json:"build_command"`
StartCommand string `json:"start_command"`
Port int `json:"port"`
HealthCheck string `json:"health_check"`
Environment map[string]string `json:"environment"`
Dockerfile string `json:"dockerfile,omitempty"`
NixpacksConfig map[string]string `json:"nixpacks_config,omitempty"`
}
type ComposeTemplateConfig struct {
Type string `json:"type"`
Format string `json:"format"`
ComposeFile string `json:"compose_file,omitempty"`
ServiceCount int `json:"service_count"`
Services []ComposeServiceSummary `json:"services"`
}
type ComposeServiceSummary struct {
Name string `json:"name"`
Type string `json:"type"`
Image string `json:"image,omitempty"`
BuildContext string `json:"build_context,omitempty"`
Command string `json:"command,omitempty"`
Ports []string `json:"ports,omitempty"`
Environment map[string]string `json:"environment,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
}
type TemplateVariable struct {
Key string `json:"key"`
Label string `json:"label"`
Default string `json:"default"`
Required bool `json:"required"`
Secret bool `json:"secret"`
Description string `json:"description"`
}
type ImportGitHubTemplateRequest struct {
ProviderID string `json:"provider_id"`
RepoFullName string `json:"repo_full_name"`
SourceURL string `json:"source_url"`
Branch string `json:"branch"`
ComposePath string `json:"compose_path"`
ManifestPath string `json:"manifest_path"` // Backward-compatible alias for older clients.
}
type ImportComposeTemplateRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
SourceURL string `json:"source_url"`
ComposeYAML string `json:"compose_yaml" binding:"required"`
}
type parsedComposeTemplate struct {
Name string
Description string
Category string
Logo string
Screenshots []string
Variables []TemplateVariable
Config ComposeTemplateConfig
ComposeYAML string
}
func handleGetTemplates(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
category := c.Query("category")
query := `SELECT id, name, COALESCE(description, ''), category, COALESCE(logo, ''), config, variables,
COALESCE(screenshots, '[]'), COALESCE(compose_yaml, ''), is_official,
COALESCE(source_type, CASE WHEN is_official THEN 'official' ELSE 'community' END),
COALESCE(source_repo, ''), COALESCE(source_branch, ''), COALESCE(source_path, ''),
COALESCE(source_url, ''), COALESCE(created_by::text, ''), created_at, updated_at
FROM service_templates`
args := []interface{}{}
if category != "" {
query += " WHERE category = $1"
args = append(args, category)
}
query += " ORDER BY is_official DESC, name ASC"
rows, err := db.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
return
}
defer rows.Close()
var templates []ServiceTemplate
for rows.Next() {
var t ServiceTemplate
err := rows.Scan(
&t.ID, &t.Name, &t.Description, &t.Category, &t.Logo, &t.Config, &t.Variables,
&t.Screenshots, &t.ComposeYAML, &t.IsOfficial, &t.SourceType, &t.SourceRepo, &t.SourceBranch, &t.SourcePath,
&t.SourceURL, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt,
)
if err != nil {
continue
}
templates = append(templates, t)
}
c.JSON(http.StatusOK, gin.H{"templates": templates})
}
func handleGetTemplate(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
templateID := c.Param("id")
template, err := getTemplateByID(db, templateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
var config map[string]interface{}
_ = json.Unmarshal([]byte(template.Config), &config)
var variables []TemplateVariable
_ = json.Unmarshal([]byte(template.Variables), &variables)
var screenshots []string
_ = json.Unmarshal([]byte(template.Screenshots), &screenshots)
c.JSON(http.StatusOK, gin.H{
"template": template,
"config": config,
"variables": variables,
"screenshots": screenshots,
})
}
func handleImportGitHubTemplate(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req ImportGitHubTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.ComposePath == "" && req.ManifestPath != "" {
req.ComposePath = req.ManifestPath
}
if repo, branch, composePath, ok := parseGitHubTemplateReference(req.SourceURL); ok {
if req.RepoFullName == "" {
req.RepoFullName = repo
}
if req.Branch == "" {
req.Branch = branch
}
if req.ComposePath == "" {
req.ComposePath = composePath
}
}
if req.RepoFullName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub repository is required"})
return
}
provider := GitProvider{Name: "github", APIUrl: "https://api.github.com"}
if req.ProviderID != "" {
err := db.QueryRow(`
SELECT id, name, access_token, api_url
FROM git_providers
WHERE id = $1 AND user_id = $2
`, req.ProviderID, userID).Scan(&provider.ID, &provider.Name, &provider.AccessToken, &provider.APIUrl)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Git provider not found"})
return
}
}
if req.Branch == "" {
details, err := fetchGitRepositoryDetails(provider.Name, req.RepoFullName, provider.AccessToken, provider.APIUrl)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read repository: " + err.Error()})
return
}
if branch, ok := details["default_branch"].(string); ok && branch != "" {
req.Branch = branch
} else {
req.Branch = "main"
}
}
composePath := req.ComposePath
rawCompose, err := fetchGitHubCompose(provider, req.RepoFullName, req.Branch, composePath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to fetch Docker Compose file: " + err.Error()})
return
}
if composePath == "" {
composePath = detectComposePath(rawCompose.path)
}
parsed, err := parseComposeTemplate(rawCompose.content, composePath, req.RepoFullName)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sourceURL := req.SourceURL
if sourceURL == "" {
sourceURL = "https://github.com/" + req.RepoFullName + "/blob/" + req.Branch + "/" + composePath
}
template, err := insertComposeTemplate(db, parsed, insertComposeTemplateOptions{
UserID: userID,
SourceType: "github",
SourceRepo: req.RepoFullName,
SourceBranch: req.Branch,
SourcePath: composePath,
SourceURL: sourceURL,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to import template"})
return
}
LogAuditWithRequest(c, "template", template.ID, "import", map[string]interface{}{
"source": "github",
"repo": req.RepoFullName,
"branch": req.Branch,
"compose_path": composePath,
})
c.JSON(http.StatusCreated, gin.H{"template": template})
}
func handleImportComposeTemplate(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req ImportComposeTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
parsed, err := parseComposeTemplate([]byte(req.ComposeYAML), "docker-compose.yml", req.Name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if strings.TrimSpace(req.Name) != "" {
parsed.Name = strings.TrimSpace(req.Name)
}
if strings.TrimSpace(req.Description) != "" {
parsed.Description = strings.TrimSpace(req.Description)
}
if strings.TrimSpace(req.Category) != "" {
parsed.Category = strings.TrimSpace(req.Category)
}
template, err := insertComposeTemplate(db, parsed, insertComposeTemplateOptions{
UserID: userID,
SourceType: "manual",
SourceURL: req.SourceURL,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to import template"})
return
}
LogAuditWithRequest(c, "template", template.ID, "import", map[string]interface{}{
"source": "manual",
})
c.JSON(http.StatusCreated, gin.H{"template": template})
}
func handleCreateFromTemplate(c *gin.Context) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
templateID := c.Param("id")
var req struct {
ProjectID string `json:"project_id" binding:"required"`
Name string `json:"name" binding:"required"`
Variables map[string]string `json:"variables"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var projectOwner string
if err := db.QueryRow("SELECT owner_id FROM projects WHERE id = $1", req.ProjectID).Scan(&projectOwner); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
if projectOwner != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
template, err := getTemplateByID(db, templateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
if strings.TrimSpace(template.ComposeYAML) != "" {
serviceIDs, err := createServicesFromComposeTemplate(db, req.ProjectID, req.Name, template, req.Variables)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to install template: " + err.Error()})
return
}
LogAuditWithRequest(c, "template", templateID, "install", map[string]interface{}{
"project_id": req.ProjectID,
"name": req.Name,
"service_ids": serviceIDs,
})
firstID := ""
if len(serviceIDs) > 0 {
firstID = serviceIDs[0]
}
c.JSON(http.StatusCreated, gin.H{
"service_id": firstID,
"service_ids": serviceIDs,
"message": "Template installed from Docker Compose",
"serviceCount": len(serviceIDs),
})
return
}
serviceID, err := createLegacyTemplateService(db, req.ProjectID, req.Name, template, req.Variables)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
return
}
LogAuditWithRequest(c, "service", serviceID, "create", map[string]interface{}{
"template_id": templateID,
"name": req.Name,
})
c.JSON(http.StatusCreated, gin.H{
"service_id": serviceID,
"service_ids": []string{serviceID},
"message": "Service created from template",
})
}
type insertComposeTemplateOptions struct {
UserID string
SourceType string
SourceRepo string
SourceBranch string
SourcePath string
SourceURL string
}
func insertComposeTemplate(db *database.DB, parsed parsedComposeTemplate, opts insertComposeTemplateOptions) (ServiceTemplate, error) {
configJSON, _ := json.Marshal(parsed.Config)
variablesJSON, _ := json.Marshal(parsed.Variables)
screenshotsJSON, _ := json.Marshal(parsed.Screenshots)
templateID := "compose-" + uuid.New().String()
now := time.Now()
var template ServiceTemplate
err := db.QueryRow(
`INSERT INTO service_templates
(id, name, description, category, logo, config, variables, screenshots, compose_yaml, is_official,
source_type, source_repo, source_branch, source_path, source_url, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, false, $10, $11, $12, $13, $14, $15, $16, $16)
RETURNING id, name, COALESCE(description, ''), category, COALESCE(logo, ''), config, variables,
COALESCE(screenshots, '[]'), COALESCE(compose_yaml, ''), is_official,
COALESCE(source_type, ''), COALESCE(source_repo, ''), COALESCE(source_branch, ''), COALESCE(source_path, ''),
COALESCE(source_url, ''), COALESCE(created_by::text, ''), created_at, updated_at`,
templateID, parsed.Name, parsed.Description, parsed.Category, parsed.Logo, string(configJSON), string(variablesJSON),
string(screenshotsJSON), parsed.ComposeYAML, opts.SourceType, opts.SourceRepo, opts.SourceBranch,
opts.SourcePath, opts.SourceURL, opts.UserID, now,
).Scan(
&template.ID, &template.Name, &template.Description, &template.Category, &template.Logo,
&template.Config, &template.Variables, &template.Screenshots, &template.ComposeYAML, &template.IsOfficial,
&template.SourceType, &template.SourceRepo, &template.SourceBranch, &template.SourcePath, &template.SourceURL,
&template.CreatedBy, &template.CreatedAt, &template.UpdatedAt,
)
return template, err
}
func getTemplateByID(db *database.DB, templateID string) (ServiceTemplate, error) {
var template ServiceTemplate
err := db.QueryRow(
`SELECT id, name, COALESCE(description, ''), category, COALESCE(logo, ''), config, variables,
COALESCE(screenshots, '[]'), COALESCE(compose_yaml, ''), is_official,
COALESCE(source_type, CASE WHEN is_official THEN 'official' ELSE 'community' END),
COALESCE(source_repo, ''), COALESCE(source_branch, ''), COALESCE(source_path, ''),
COALESCE(source_url, ''), COALESCE(created_by::text, ''), created_at, updated_at
FROM service_templates WHERE id = $1`,
templateID,
).Scan(
&template.ID, &template.Name, &template.Description, &template.Category, &template.Logo,
&template.Config, &template.Variables, &template.Screenshots, &template.ComposeYAML, &template.IsOfficial,
&template.SourceType, &template.SourceRepo, &template.SourceBranch, &template.SourcePath,
&template.SourceURL, &template.CreatedBy, &template.CreatedAt, &template.UpdatedAt,
)
return template, err
}
type fetchedComposeFile struct {
path string
content []byte
}
func fetchGitHubCompose(provider GitProvider, repoFullName, branch, composePath string) (fetchedComposeFile, error) {
if strings.TrimSpace(composePath) != "" {
content, err := fetchGitHubFile(provider.Name, provider.AccessToken, provider.APIUrl, repoFullName, branch, composePath)
return fetchedComposeFile{path: composePath, content: content}, err
}
var lastErr error
for _, candidate := range defaultComposePaths {
content, err := fetchGitHubFile(provider.Name, provider.AccessToken, provider.APIUrl, repoFullName, branch, candidate)
if err == nil {
return fetchedComposeFile{path: candidate, content: content}, nil
}
lastErr = err
}
if lastErr != nil {
return fetchedComposeFile{}, fmt.Errorf("no Compose file found in repository root: %w", lastErr)
}
return fetchedComposeFile{}, fmt.Errorf("no Compose file found in repository root")
}
func detectComposePath(value string) string {
if value != "" {
return value
}
return "docker-compose.yml"
}
func parseComposeTemplate(raw []byte, composePath string, fallbackName string) (parsedComposeTemplate, error) {
var root map[string]interface{}
if err := yaml.Unmarshal(raw, &root); err != nil {
return parsedComposeTemplate{}, fmt.Errorf("Docker Compose YAML is not valid")
}
servicesMap := asMap(root["services"])
if len(servicesMap) == 0 {
return parsedComposeTemplate{}, fmt.Errorf("Docker Compose file must include at least one service")
}
meta := mergeMetadata(asMap(root["x-casaos"]), asMap(root["x-containr"]))
services := make([]ComposeServiceSummary, 0, len(servicesMap))
for name, rawService := range servicesMap {
serviceMap := asMap(rawService)
service := ComposeServiceSummary{
Name: name,
Image: stringValue(serviceMap["image"]),
BuildContext: buildContextValue(serviceMap["build"]),
Command: commandValue(serviceMap["command"]),
Ports: stringSliceValue(serviceMap["ports"]),
Environment: environmentValue(serviceMap["environment"]),
DependsOn: stringSliceValue(serviceMap["depends_on"]),
}
service.Type = inferComposeServiceType(service)
services = append(services, service)
}
sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name })
variables := collectComposeVariables(string(raw))
name := firstTemplateNonEmpty(
stringFromMetadata(meta, "name", "title"),
stringValue(root["name"]),
humanizeTemplateName(fallbackName),
humanizeTemplateName(strings.TrimSuffix(path.Base(composePath), path.Ext(composePath))),
)
description := firstTemplateNonEmpty(
stringFromMetadata(meta, "description", "desc"),
fmt.Sprintf("%s stack with %d Compose services.", name, len(services)),
)
category := firstTemplateNonEmpty(stringFromMetadata(meta, "category", "class"), inferComposeCategory(services))
logo := firstTemplateNonEmpty(stringFromMetadata(meta, "icon", "logo", "thumbnail"), "")
screenshots := stringListFromMetadata(meta, "screenshots", "screenshot", "screenshot_link", "screenshot_links")
return parsedComposeTemplate{
Name: name,
Description: description,
Category: category,
Logo: logo,
Screenshots: screenshots,
Variables: variables,
ComposeYAML: string(raw),
Config: ComposeTemplateConfig{
Type: "compose",
Format: "docker-compose",
ComposeFile: composePath,
ServiceCount: len(services),
Services: services,
},
}, nil
}
func createServicesFromComposeTemplate(db *database.DB, projectID, installName string, template ServiceTemplate, variables map[string]string) ([]string, error) {
var config ComposeTemplateConfig
if err := json.Unmarshal([]byte(template.Config), &config); err != nil {
return nil, err
}
if len(config.Services) == 0 {
parsed, err := parseComposeTemplate([]byte(template.ComposeYAML), template.SourcePath, template.Name)
if err != nil {
return nil, err
}
config = parsed.Config
}
serviceIDs := make([]string, 0, len(config.Services))
now := time.Now()
for _, composeService := range config.Services {
serviceID := uuid.New()
serviceName := installName
if len(config.Services) > 1 {
serviceName = installName + "-" + composeService.Name
}
serviceType := composeService.Type
if serviceType == "" {
serviceType = "web"
}
image := substituteComposeVariables(firstTemplateNonEmpty(composeService.Image, composeService.BuildContext), variables)
command := substituteComposeVariables(composeService.Command, variables)
cpu := "0.5"
memory := "512Mi"
if serviceType == "database" {
cpu = "1"
memory = "1Gi"
}
_, err := db.Exec(
`INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
serviceID, projectID, serviceName, serviceType, "stopped", image, command, "production", cpu, memory, now, now,
)
if err != nil {
return serviceIDs, err
}
for key, value := range composeService.Environment {
resolved := substituteComposeVariables(value, variables)
if explicit, ok := variables[key]; ok {
resolved = explicit
}
_, _ = db.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (service_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at`,
uuid.New(), serviceID, key, resolved, isSecretVariable(key), now, now,
)
}
serviceIDs = append(serviceIDs, serviceID.String())
}
return serviceIDs, nil
}
func createLegacyTemplateService(db *database.DB, projectID, serviceName string, template ServiceTemplate, variables map[string]string) (string, error) {
var config TemplateConfig
_ = json.Unmarshal([]byte(template.Config), &config)
envVars := make(map[string]string)
for key, value := range config.Environment {
envVars[key] = value
}
for key, value := range variables {
envVars[key] = value
}
envVarsJSON, _ := json.Marshal(envVars)
serviceID := uuid.New()
now := time.Now()
_, err := db.Exec(
`INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
serviceID, projectID, serviceName, config.Type, "stopped", config.Runtime, config.StartCommand,
string(envVarsJSON), "0.5", "512Mi", now, now,
)
if err != nil {
return "", err
}
return serviceID.String(), nil
}
func collectComposeVariables(raw string) []TemplateVariable {
matches := composeVariablePattern.FindAllStringSubmatch(raw, -1)
seen := make(map[string]TemplateVariable)
for _, match := range matches {
key := match[1]
defaultValue := ""
required := true
if len(match) > 3 && match[3] != "" {
defaultValue = match[3]
required = false
}
if existing, ok := seen[key]; ok {
if existing.Default == "" && defaultValue != "" {
existing.Default = defaultValue
existing.Required = false
seen[key] = existing
}
continue
}
seen[key] = TemplateVariable{
Key: key,
Label: humanizeTemplateName(key),
Default: defaultValue,
Required: required,
Secret: isSecretVariable(key),
}
}
keys := make([]string, 0, len(seen))
for key := range seen {
keys = append(keys, key)
}
sort.Strings(keys)
variables := make([]TemplateVariable, 0, len(keys))
for _, key := range keys {
variables = append(variables, seen[key])
}
return variables
}
func substituteComposeVariables(value string, variables map[string]string) string {
return composeVariablePattern.ReplaceAllStringFunc(value, func(match string) string {
parts := composeVariablePattern.FindStringSubmatch(match)
if len(parts) < 2 {
return match
}
if variables != nil {
if explicit, ok := variables[parts[1]]; ok {
return explicit
}
}
if len(parts) > 3 {
return parts[3]
}
return ""
})
}
func parseGitHubTemplateReference(rawURL string) (repoFullName, branch, composePath string, ok bool) {
if strings.TrimSpace(rawURL) == "" {
return "", "", "", false
}
parsed, err := url.Parse(rawURL)
if err != nil {
return "", "", "", false
}
host := strings.ToLower(parsed.Host)
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if host == "github.com" && len(parts) >= 2 {
repoFullName = parts[0] + "/" + parts[1]
if len(parts) >= 5 && (parts[2] == "blob" || parts[2] == "tree") {
branch = parts[3]
composePath = strings.Join(parts[4:], "/")
}
return repoFullName, branch, composePath, true
}
if host == "raw.githubusercontent.com" && len(parts) >= 4 {
repoFullName = parts[0] + "/" + parts[1]
branch = parts[2]
composePath = strings.Join(parts[3:], "/")
return repoFullName, branch, composePath, true
}
return "", "", "", false
}
func asMap(value interface{}) map[string]interface{} {
switch typed := value.(type) {
case map[string]interface{}:
return typed
case map[interface{}]interface{}:
result := make(map[string]interface{}, len(typed))
for key, value := range typed {
result[fmt.Sprint(key)] = value
}
return result
default:
return map[string]interface{}{}
}
}
func mergeMetadata(primary, secondary map[string]interface{}) map[string]interface{} {
result := map[string]interface{}{}
for key, value := range primary {
result[strings.ToLower(key)] = value
}
for key, value := range secondary {
result[strings.ToLower(key)] = value
}
return result
}
func stringFromMetadata(metadata map[string]interface{}, keys ...string) string {
for _, key := range keys {
if value := stringValue(metadata[strings.ToLower(key)]); value != "" {
return value
}
}
return ""
}
func stringListFromMetadata(metadata map[string]interface{}, keys ...string) []string {
for _, key := range keys {
values := stringSliceValue(metadata[strings.ToLower(key)])
if len(values) > 0 {
return values
}
}
return []string{}
}
func stringValue(value interface{}) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case int, int64, float64, bool:
return strings.TrimSpace(fmt.Sprint(typed))
default:
return ""
}
}
func stringSliceValue(value interface{}) []string {
switch typed := value.(type) {
case []string:
return typed
case []interface{}:
result := make([]string, 0, len(typed))
for _, item := range typed {
if text := stringValue(item); text != "" {
result = append(result, text)
}
}
return result
case map[string]interface{}:
result := make([]string, 0, len(typed))
for key := range typed {
result = append(result, key)
}
sort.Strings(result)
return result
case map[interface{}]interface{}:
return stringSliceValue(asMap(typed))
case string:
if strings.TrimSpace(typed) == "" {
return []string{}
}
return []string{strings.TrimSpace(typed)}
default:
return []string{}
}
}
func environmentValue(value interface{}) map[string]string {
result := map[string]string{}
switch typed := value.(type) {
case map[string]interface{}:
for key, value := range typed {
result[key] = stringValue(value)
}
case map[interface{}]interface{}:
return environmentValue(asMap(typed))
case []interface{}:
for _, item := range typed {
text := stringValue(item)
if text == "" {
continue
}
key, value, ok := strings.Cut(text, "=")
if ok {
result[key] = value
} else {
result[text] = "${" + text + "}"
}
}
}
return result
}
func buildContextValue(value interface{}) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case map[string]interface{}:
return firstTemplateNonEmpty(stringValue(typed["context"]), stringValue(typed["dockerfile"]))
case map[interface{}]interface{}:
return buildContextValue(asMap(typed))
default:
return ""
}
}
func commandValue(value interface{}) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case []interface{}:
parts := make([]string, 0, len(typed))
for _, item := range typed {
if text := stringValue(item); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, " ")
default:
return ""
}
}
func inferComposeServiceType(service ComposeServiceSummary) string {
lower := strings.ToLower(service.Name + " " + service.Image)
for _, marker := range []string{"postgres", "mysql", "mariadb", "mongo", "redis", "clickhouse", "influxdb"} {
if strings.Contains(lower, marker) {
return "database"
}
}
if len(service.Ports) == 0 {
return "worker"
}
return "web"
}
func inferComposeCategory(services []ComposeServiceSummary) string {
if len(services) == 1 && services[0].Type == "database" {
return "database"
}
for _, service := range services {
if service.Type == "web" {
return "web"
}
}
return "community"
}
func isSecretVariable(key string) bool {
upper := strings.ToUpper(key)
for _, marker := range []string{"PASSWORD", "SECRET", "TOKEN", "API_KEY", "PRIVATE_KEY", "KEY_BASE"} {
if strings.Contains(upper, marker) {
return true
}
}
return false
}
func humanizeTemplateName(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimSuffix(value, ".git")
if strings.Contains(value, "/") {
parts := strings.Split(value, "/")
value = parts[len(parts)-1]
}
value = strings.ReplaceAll(value, "_", " ")
value = strings.ReplaceAll(value, "-", " ")
value = strings.TrimSpace(value)
if value == "" {
return ""
}
words := strings.Fields(value)
for index, word := range words {
if len(word) == 0 {
continue
}
words[index] = strings.ToUpper(word[:1]) + word[1:]
}
return strings.Join(words, " ")
}
func firstTemplateNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func SeedTemplates() []ServiceTemplate {
templates := []ServiceTemplate{
{
ID: "tpl-postgres-compose",
Name: "PostgreSQL",
Description: "Single-service PostgreSQL Compose template.",
Category: "database",
Logo: "https://cdn.simpleicons.org/postgresql",
Config: `{"type":"compose","format":"docker-compose","service_count":1,"services":[{"name":"postgres","type":"database","image":"postgres:16","ports":["${POSTGRES_PORT:-5432}:5432"],"environment":{"POSTGRES_USER":"${POSTGRES_USER:-postgres}","POSTGRES_PASSWORD":"${POSTGRES_PASSWORD}","POSTGRES_DB":"${POSTGRES_DB:-app}"}}]}`,
Variables: `[{"key":"POSTGRES_DB","label":"Postgres Db","default":"app","required":false,"secret":false},{"key":"POSTGRES_PASSWORD","label":"Postgres Password","default":"","required":true,"secret":true},{"key":"POSTGRES_PORT","label":"Postgres Port","default":"5432","required":false,"secret":false},{"key":"POSTGRES_USER","label":"Postgres User","default":"postgres","required":false,"secret":false}]`,
Screenshots: `[]`,
ComposeYAML: "services:\n postgres:\n image: postgres:16\n ports:\n - \"${POSTGRES_PORT:-5432}:5432\"\n environment:\n POSTGRES_USER: \"${POSTGRES_USER:-postgres}\"\n POSTGRES_PASSWORD: \"${POSTGRES_PASSWORD}\"\n POSTGRES_DB: \"${POSTGRES_DB:-app}\"\n",
IsOfficial: true,
SourceType: "official",
},
}
return templates
}
+207
View File
@@ -0,0 +1,207 @@
package api
import (
"containr/internal/database"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type EnvironmentVariable struct {
ID uuid.UUID `json:"id" db:"id"`
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
Key string `json:"key" db:"key"`
Value string `json:"value" db:"value"`
IsSecret bool `json:"is_secret" db:"is_secret"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type UpdateVariablesRequest struct {
Variables []VariableInput `json:"variables" binding:"required"`
}
type VariableInput struct {
Key string `json:"key" binding:"required"`
Value string `json:"value"`
IsSecret bool `json:"is_secret"`
}
func handleGetVariables(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, service_id, key, value, is_secret, created_at, updated_at
FROM environment_variables
WHERE service_id = $1
ORDER BY key ASC`,
serviceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
return
}
defer rows.Close()
var variables []EnvironmentVariable
for rows.Next() {
var v EnvironmentVariable
err := rows.Scan(
&v.ID, &v.ServiceID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan variable"})
return
}
if v.IsSecret {
v.Value = "********"
}
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables})
}
func handleUpdateVariables(c *gin.Context) {
db, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
serviceIDStr := c.Param("id")
serviceID, err := uuid.Parse(serviceIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
var req UpdateVariablesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
tx, err := db.(*database.DB).Begin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
return
}
defer tx.Rollback()
_, err = tx.Exec("DELETE FROM environment_variables WHERE service_id = $1", serviceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear existing variables"})
return
}
now := time.Now()
for _, v := range req.Variables {
varID := uuid.New()
_, err = tx.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
varID, serviceID, v.Key, v.Value, v.IsSecret, now, now,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
return
}
}
if err = tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, service_id, key, value, is_secret, created_at, updated_at
FROM environment_variables
WHERE service_id = $1
ORDER BY key ASC`,
serviceID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve variables"})
return
}
defer rows.Close()
var variables []EnvironmentVariable
for rows.Next() {
var v EnvironmentVariable
err := rows.Scan(
&v.ID, &v.ServiceID, &v.Key, &v.Value, &v.IsSecret, &v.CreatedAt, &v.UpdatedAt,
)
if err != nil {
continue
}
if v.IsSecret {
v.Value = "********"
}
variables = append(variables, v)
}
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Environment variables updated successfully"})
}
+270
View File
@@ -0,0 +1,270 @@
package api
import (
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
type WebSocketClient struct {
ID string
UserID string
Conn *websocket.Conn
Channels map[string]bool
Send chan []byte
}
type WebSocketMessage struct {
Type string `json:"type"`
Channel string `json:"channel"`
Data interface{} `json:"data"`
Timestamp time.Time `json:"timestamp"`
}
type WebSocketHub struct {
clients map[string]*WebSocketClient
broadcast chan *WebSocketMessage
register chan *WebSocketClient
unregister chan *WebSocketClient
mu sync.RWMutex
}
var wsHub = &WebSocketHub{
clients: make(map[string]*WebSocketClient),
broadcast: make(chan *WebSocketMessage, 100),
register: make(chan *WebSocketClient),
unregister: make(chan *WebSocketClient),
}
func init() {
go wsHub.run()
}
func (h *WebSocketHub) run() {
for {
select {
case client := <-h.register:
h.mu.Lock()
h.clients[client.ID] = client
h.mu.Unlock()
log.Printf("WebSocket client connected: %s", client.ID)
case client := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[client.ID]; ok {
delete(h.clients, client.ID)
close(client.Send)
}
h.mu.Unlock()
log.Printf("WebSocket client disconnected: %s", client.ID)
case message := <-h.broadcast:
h.mu.RLock()
data, err := json.Marshal(message)
if err != nil {
log.Printf("Error marshaling WebSocket message: %v", err)
h.mu.RUnlock()
continue
}
for _, client := range h.clients {
if client.Channels[message.Channel] || message.Channel == "all" {
select {
case client.Send <- data:
default:
close(client.Send)
delete(h.clients, client.ID)
}
}
}
h.mu.RUnlock()
}
}
}
func (h *WebSocketHub) Broadcast(channel string, msgType string, data interface{}) {
message := &WebSocketMessage{
Type: msgType,
Channel: channel,
Data: data,
Timestamp: time.Now(),
}
h.broadcast <- message
}
func (h *WebSocketHub) BroadcastToUser(userID string, msgType string, data interface{}) {
h.mu.RLock()
defer h.mu.RUnlock()
message := &WebSocketMessage{
Type: msgType,
Channel: "user:" + userID,
Data: data,
Timestamp: time.Now(),
}
messageBytes, err := json.Marshal(message)
if err != nil {
return
}
for _, client := range h.clients {
if client.UserID == userID {
select {
case client.Send <- messageBytes:
default:
}
}
}
}
func handleWebSocket(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
return
}
userID, exists := c.Get("user_id")
if !exists {
conn.Close()
return
}
client := &WebSocketClient{
ID: generateClientID(),
UserID: userID.(string),
Conn: conn,
Channels: make(map[string]bool),
Send: make(chan []byte, 256),
}
wsHub.register <- client
go client.writePump()
go client.readPump()
}
func (c *WebSocketClient) readPump() {
defer func() {
wsHub.unregister <- c
c.Conn.Close()
}()
c.Conn.SetReadLimit(512)
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
for {
_, message, err := c.Conn.ReadMessage()
if err != nil {
break
}
var msg struct {
Action string `json:"action"`
Channel string `json:"channel"`
}
if err := json.Unmarshal(message, &msg); err != nil {
continue
}
switch msg.Action {
case "subscribe":
c.Channels[msg.Channel] = true
case "unsubscribe":
delete(c.Channels, msg.Channel)
}
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
}
}
func (c *WebSocketClient) writePump() {
ticker := time.NewTicker(30 * time.Second)
defer func() {
ticker.Stop()
c.Conn.Close()
}()
for {
select {
case message, ok := <-c.Send:
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.Conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
n := len(c.Send)
for i := 0; i < n; i++ {
w.Write([]byte{'\n'})
w.Write(<-c.Send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
func generateClientID() string {
return time.Now().Format("20060102150405") + "-" + randomString(8)
}
func randomString(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[time.Now().Nanosecond()%len(letters)]
}
return string(b)
}
func BroadcastServiceUpdate(serviceID string, data interface{}) {
wsHub.Broadcast("service:"+serviceID, "service_update", data)
}
func BroadcastDeploymentUpdate(deploymentID string, data interface{}) {
wsHub.Broadcast("deployment:"+deploymentID, "deployment_update", data)
}
func BroadcastBuildUpdate(buildID string, data interface{}) {
wsHub.Broadcast("build:"+buildID, "build_update", data)
}
func BroadcastMetricsUpdate(serviceID string, data interface{}) {
wsHub.Broadcast("metrics:"+serviceID, "metrics_update", data)
}
func BroadcastScalingEvent(serviceID string, data interface{}) {
wsHub.Broadcast("scaling:"+serviceID, "scaling_event", data)
}
func NotifyUser(userID string, notificationType string, data interface{}) {
wsHub.BroadcastToUser(userID, notificationType, data)
}
+74
View File
@@ -0,0 +1,74 @@
package analytics
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
type Client struct {
BaseURL string
APIKey string
WebsiteID string
HTTP *http.Client
}
func NewClient(baseURL, apiKey, websiteID string) *Client {
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
WebsiteID: websiteID,
HTTP: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (c *Client) Enabled() bool {
return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != ""
}
func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) {
if !c.Enabled() {
return map[string]any{
"enabled": false,
}, nil
}
u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID))
if err != nil {
return nil, err
}
query := u.Query()
query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli()))
query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli()))
u.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Accept", "application/json")
res, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return nil, fmt.Errorf("umami returned %d", res.StatusCode)
}
var payload map[string]any
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return nil, err
}
payload["enabled"] = true
return payload, nil
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More