Consolidate backend: drop vendored APwhy, converge on goose + sqlc

Phase 0 cleanup verified end-to-end:

- Delete vendored APwhy runtime (cmd/apwhy, internal/apwhy) and the
  unwired copy (internal/api/server.go + static bundle + cloned
  internal/{storage,analytics,auth,rbac}) — ~7.5k lines of dead code.
- Keep Containr's native internal/gateway; move APwhy management
  routes under /api/v1/gateway/* so they stop colliding with the
  native /services paths. Fix handlers against the real schema:
  UUID ids via RETURNING, integer enabled flags, plan validation,
  service validate endpoint, unique-violation mapping.
- Port agents.go off GORM onto sqlc (sqlc/queries/agents.sql);
  drop gorm dependencies; fix sqlc schema drift (services.environment_id).
- Replace the SELECT 1 goose baseline with the real consolidated
  schema (52 tables) minus APwhy's parallel auth/RBAC objects;
  idempotent via IF NOT EXISTS / OR REPLACE / guarded DO blocks so it
  replays cleanly over legacy installs. Add a drop migration for the
  dead objects and delete the legacy filename-tracked migrations/
  loader, directory, and Dockerfile copy. Verified via cmd/migrate:
  fresh install 53 tables, legacy upgrade clean to 20260917000000.
- Canvas: add invisible React Flow handles to ServiceNode so inferred
  auto-connections stop emitting warning #008.
- Remove reactflow dep (superseded by @xyflow/react), test harness
  cmds, and legacy 0001_init.sql backups.

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 09:40:08 +02:00
1 parent 37a56dc6b6
commit 9e3aa3aa17
153 files changed
+6996 -11135

No files matched your search

+400 -251
View File
@@ -1,7 +1,10 @@
package api
import (
"context"
"crypto/subtle"
"database/sql"
"encoding/json"
"fmt"
"math"
"net/http"
@@ -9,26 +12,29 @@ import (
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/sqlc-dev/pqtype"
)
// 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'"`
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"`
Port int `json:"port"`
Status string `json:"status"`
Version string `json:"version"`
Capabilities AgentCapabilities `json:"capabilities" gorm:"serializer:json"`
Resources NodeResources `json:"resources" gorm:"serializer:json"`
Capabilities AgentCapabilities `json:"capabilities"`
Resources NodeResources `json:"resources"`
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"`
Metadata map[string]interface{} `json:"metadata"`
}
// AgentCapabilities defines what the agent can do
@@ -89,20 +95,20 @@ type BandwidthInfo struct {
// 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"`
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
ProjectID string `json:"project_id"`
ServiceID string `json:"service_id"`
NodeAgentID string `json:"node_agent_id"`
Status ContainerStatus `json:"status"`
Resources ContainerResources `json:"resources"`
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"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -155,12 +161,12 @@ type HealthCheck struct {
// 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"`
ID string `json:"id"`
Type string `json:"type"`
NodeAgentID string `json:"node_agent_id"`
ContainerID *string `json:"container_id"`
Payload map[string]interface{} `json:"payload" gorm:"serializer:json"`
Status string `json:"status" gorm:"default:'pending'"`
Payload map[string]interface{} `json:"payload"`
Status string `json:"status"`
Result *string `json:"result"`
Error *string `json:"error"`
CreatedAt time.Time `json:"created_at"`
@@ -181,23 +187,6 @@ type AgentHeartbeat struct {
Version string `json:"version"`
}
type AgentHeartbeatRecord struct {
ID string `json:"id" gorm:"primaryKey"`
NodeAgentID string `json:"node_agent_id" gorm:"index;not null"`
Timestamp time.Time `json:"timestamp" gorm:"index;not null"`
Status string `json:"status"`
Resources NodeResources `json:"resources" gorm:"serializer:json"`
ContainerCount int `json:"container_count"`
SystemLoad SystemLoad `json:"system_load" gorm:"serializer:json"`
Uptime int64 `json:"uptime"`
Version string `json:"version"`
CreatedAt time.Time `json:"created_at"`
}
func (AgentHeartbeatRecord) TableName() string {
return "agent_heartbeats"
}
type SystemLoad struct {
Load1M float64 `json:"load_1m"`
Load5M float64 `json:"load_5m"`
@@ -206,13 +195,125 @@ type SystemLoad struct {
// NodeAgentHandler handles agent-related endpoints
type NodeAgentHandler struct {
db *gorm.DB
q *sqlcdb.Queries
}
func NewNodeAgentHandler(db *gorm.DB) *NodeAgentHandler {
return &NodeAgentHandler{db: db}
func NewNodeAgentHandler(db *database.DB) *NodeAgentHandler {
return &NodeAgentHandler{q: sqlcdb.New(db.DB)}
}
// --- row <-> API type conversion helpers ---
func rawJSON(v interface{}) pqtype.NullRawMessage {
if v == nil {
return pqtype.NullRawMessage{}
}
b, err := json.Marshal(v)
if err != nil {
return pqtype.NullRawMessage{}
}
return pqtype.NullRawMessage{RawMessage: b, Valid: true}
}
func unmarshalRaw(raw pqtype.NullRawMessage, dst interface{}) {
if raw.Valid && len(raw.RawMessage) > 0 {
_ = json.Unmarshal(raw.RawMessage, dst)
}
}
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
agent := NodeAgent{
ID: row.ID,
Name: row.Name,
Hostname: row.Hostname,
IPAddress: row.IpAddress,
Port: int(row.Port),
Status: row.Status.String,
Version: row.Version.String,
Metadata: map[string]interface{}{},
}
if row.LastHeartbeat.Valid {
agent.LastHeartbeat = row.LastHeartbeat.Time
}
if row.CreatedAt.Valid {
agent.CreatedAt = row.CreatedAt.Time
}
if row.UpdatedAt.Valid {
agent.UpdatedAt = row.UpdatedAt.Time
}
unmarshalRaw(row.Capabilities, &agent.Capabilities)
unmarshalRaw(row.Resources, &agent.Resources)
unmarshalRaw(row.Metadata, &agent.Metadata)
return agent
}
func containerFromRow(row sqlcdb.ContainerInstance) ContainerInstance {
container := ContainerInstance{
ID: row.ID,
Name: row.Name,
Image: row.Image,
ProjectID: row.ProjectID,
ServiceID: row.ServiceID,
NodeAgentID: row.NodeAgentID,
}
if row.CreatedAt.Valid {
container.CreatedAt = row.CreatedAt.Time
}
if row.UpdatedAt.Valid {
container.UpdatedAt = row.UpdatedAt.Time
}
if row.StartedAt.Valid {
t := row.StartedAt.Time
container.StartedAt = &t
}
unmarshalRaw(row.Status, &container.Status)
unmarshalRaw(row.Resources, &container.Resources)
unmarshalRaw(row.Ports, &container.Ports)
unmarshalRaw(row.Environment, &container.Environment)
unmarshalRaw(row.Volumes, &container.Volumes)
unmarshalRaw(row.Networks, &container.Networks)
unmarshalRaw(row.RestartPolicy, &container.RestartPolicy)
if row.HealthCheck.Valid {
var hc HealthCheck
if err := json.Unmarshal(row.HealthCheck.RawMessage, &hc); err == nil {
container.HealthCheck = &hc
}
}
return container
}
func commandFromRow(row sqlcdb.AgentCommand) AgentCommand {
cmd := AgentCommand{
ID: row.ID,
Type: row.Type,
NodeAgentID: row.NodeAgentID,
Status: row.Status.String,
}
if row.ContainerID.Valid {
cmd.ContainerID = &row.ContainerID.String
}
if row.Result.Valid {
cmd.Result = &row.Result.String
}
if row.Error.Valid {
cmd.Error = &row.Error.String
}
if row.CreatedAt.Valid {
cmd.CreatedAt = row.CreatedAt.Time
}
if row.UpdatedAt.Valid {
cmd.UpdatedAt = row.UpdatedAt.Time
}
if row.CompletedAt.Valid {
t := row.CompletedAt.Time
cmd.CompletedAt = &t
}
unmarshalRaw(row.Payload, &cmd.Payload)
return cmd
}
// --- handlers ---
// RegisterAgent handles agent registration
func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
var req struct {
@@ -235,85 +336,83 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
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()
ctx := c.Request.Context()
if err := h.db.Save(&existingAgent).Error; err != nil {
// Re-registering an existing host updates it in place.
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
Hostname: req.Hostname,
IpAddress: req.IPAddress,
}); err == nil {
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
ID: existing.ID,
Name: req.Name,
Hostname: existing.Hostname,
IpAddress: existing.IpAddress,
Port: int32(req.Port),
Status: sql.NullString{String: "connecting", Valid: true},
Version: existing.Version,
Capabilities: rawJSON(req.Capabilities),
Resources: existing.Resources,
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
Metadata: existing.Metadata,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return
}
c.JSON(http.StatusOK, gin.H{
"agent_id": existingAgent.ID,
"agent_id": updated.ID,
"auth_token": 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,
agentID := uuid.New().String()
resources := NodeResources{
CPU: CPUResources{Cores: 4},
Memory: MemoryResources{
Total: 8 * 1024 * 1024 * 1024, // 8GB
Available: 8 * 1024 * 1024 * 1024,
},
Storage: StorageResources{
Total: 100 * 1024 * 1024 * 1024, // 100GB
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",
},
},
},
LastHeartbeat: time.Now(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Metadata: make(map[string]interface{}),
}
if err := h.db.Create(&agent).Error; err != nil {
_, err := h.q.CreateAgent(ctx, sqlcdb.CreateAgentParams{
ID: agentID,
Name: req.Name,
Hostname: req.Hostname,
IpAddress: req.IPAddress,
Port: int32(req.Port),
Status: sql.NullString{String: "connecting", Valid: true},
Version: sql.NullString{},
Capabilities: rawJSON(req.Capabilities),
Resources: rawJSON(resources),
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
Metadata: rawJSON(map[string]interface{}{}),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
return
}
c.JSON(http.StatusCreated, gin.H{
"agent_id": agent.ID,
"agent_id": agentID,
"auth_token": authToken,
"status": "registered",
})
@@ -321,22 +420,25 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
// GetAgents returns all registered agents
func (h *NodeAgentHandler) GetAgents(c *gin.Context) {
var agents []NodeAgent
if err := h.db.Find(&agents).Error; err != nil {
rows, err := h.q.ListAgents(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agents"})
return
}
agents := make([]NodeAgent, 0, len(rows))
for _, row := range rows {
agents = append(agents, agentFromRow(row))
}
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 {
row, err := h.q.GetAgent(c.Request.Context(), c.Param("id"))
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
@@ -344,16 +446,17 @@ func (h *NodeAgentHandler) GetAgent(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"agent": agent})
c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(row)})
}
// UpdateAgent updates an agent's information
func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
row, err := h.q.GetAgent(ctx, id)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
@@ -367,19 +470,53 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
return
}
if err := h.db.Model(&agent).Updates(updates).Error; err != nil {
agent := agentFromRow(row)
applyAgentUpdates(&agent, updates)
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
ID: agent.ID,
Name: agent.Name,
Hostname: agent.Hostname,
IpAddress: agent.IPAddress,
Port: int32(agent.Port),
Status: sql.NullString{String: agent.Status, Valid: agent.Status != ""},
Version: sql.NullString{String: agent.Version, Valid: agent.Version != ""},
Capabilities: rawJSON(agent.Capabilities),
Resources: rawJSON(agent.Resources),
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
Metadata: rawJSON(agent.Metadata),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return
}
c.JSON(http.StatusOK, gin.H{"agent": agent})
c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(updated)})
}
// applyAgentUpdates merges whitelisted keys from an arbitrary update map.
func applyAgentUpdates(agent *NodeAgent, updates map[string]interface{}) {
remarshal := func(key string, dst interface{}) {
if v, ok := updates[key]; ok {
if b, err := json.Marshal(v); err == nil {
_ = json.Unmarshal(b, dst)
}
}
}
remarshal("name", &agent.Name)
remarshal("hostname", &agent.Hostname)
remarshal("ip_address", &agent.IPAddress)
remarshal("port", &agent.Port)
remarshal("status", &agent.Status)
remarshal("version", &agent.Version)
remarshal("capabilities", &agent.Capabilities)
remarshal("resources", &agent.Resources)
remarshal("metadata", &agent.Metadata)
}
// 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 {
if err := h.q.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agent"})
return
}
@@ -403,9 +540,9 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
heartbeat.Timestamp = time.Now()
}
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", heartbeat.NodeAgentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
ctx := c.Request.Context()
if _, err := h.q.GetAgent(ctx, heartbeat.NodeAgentID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
@@ -413,35 +550,31 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
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 {
if err := h.q.UpdateAgentHeartbeat(ctx, sqlcdb.UpdateAgentHeartbeatParams{
ID: heartbeat.NodeAgentID,
Status: sql.NullString{String: heartbeat.Status, Valid: heartbeat.Status != ""},
Resources: rawJSON(heartbeat.Resources),
LastHeartbeat: sql.NullTime{Time: heartbeat.Timestamp, Valid: true},
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return
}
record := AgentHeartbeatRecord{
err := h.q.InsertAgentHeartbeat(ctx, sqlcdb.InsertAgentHeartbeatParams{
ID: uuid.New().String(),
NodeAgentID: heartbeat.NodeAgentID,
Timestamp: heartbeat.Timestamp,
Status: heartbeat.Status,
Resources: heartbeat.Resources,
ContainerCount: heartbeat.ContainerCount,
SystemLoad: heartbeat.SystemLoad,
Resources: rawJSON(heartbeat.Resources).RawMessage,
ContainerCount: int32(heartbeat.ContainerCount),
SystemLoad: rawJSON(heartbeat.SystemLoad).RawMessage,
Uptime: heartbeat.Uptime,
Version: heartbeat.Version,
CreatedAt: time.Now(),
}
if err := h.db.Create(&record).Error; err != nil {
})
if err != nil && !isMissingTableError(err) {
// Keep heartbeat endpoint available even if history table is not yet migrated.
if !isMissingTableError(err) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"})
return
}
c.Status(http.StatusOK)
@@ -454,17 +587,17 @@ func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) {
return
}
agentID := c.Param("id")
var commands []AgentCommand
if err := h.db.
Where("node_agent_id = ? AND status = ?", agentID, "pending").
Order("created_at ASC").
Limit(25).
Find(&commands).Error; err != nil {
rows, err := h.q.ListPendingCommands(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
return
}
commands := make([]AgentCommand, 0, len(rows))
for _, row := range rows {
commands = append(commands, commandFromRow(row))
}
c.JSON(http.StatusOK, gin.H{"commands": commands})
}
@@ -491,45 +624,39 @@ func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
return
}
var command AgentCommand
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
ctx := c.Request.Context()
command, err := h.q.CompleteCommand(ctx, sqlcdb.CompleteCommandParams{
ID: commandID,
NodeAgentID: agentID,
Status: sql.NullString{String: req.Status, Valid: true},
Result: sql.NullString{String: req.Result, Valid: req.Result != ""},
Error: sql.NullString{String: req.Error, Valid: req.Error != ""},
})
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch command"})
return
}
now := time.Now()
command.Status = req.Status
command.UpdatedAt = now
command.CompletedAt = &now
if req.Result != "" {
command.Result = &req.Result
}
if req.Error != "" {
command.Error = &req.Error
}
if err := h.db.Save(&command).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update command"})
return
}
if command.ContainerID != nil {
h.updateContainerStatusAfterCommand(command)
out := commandFromRow(command)
if out.ContainerID != nil {
h.updateContainerStatusAfterCommand(ctx, out)
}
c.JSON(http.StatusOK, gin.H{"command": command})
c.JSON(http.StatusOK, gin.H{"command": out})
}
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentCommand) {
var container ContainerInstance
if err := h.db.First(&container, "id = ?", *command.ContainerID).Error; err != nil {
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(ctx context.Context, command AgentCommand) {
row, err := h.q.GetContainer(ctx, *command.ContainerID)
if err != nil {
return
}
container := containerFromRow(row)
if command.Status == "failed" {
errorMessage := ""
if command.Error != nil {
@@ -537,7 +664,10 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman
}
container.Status.State = "error"
container.Status.Error = &errorMessage
_ = h.db.Save(&container).Error
_ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
ID: container.ID,
Status: rawJSON(container.Status),
})
return
}
@@ -555,20 +685,25 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman
container.Status.State = "removed"
container.Status.FinishedAt = &now
}
container.UpdatedAt = now
_ = h.db.Save(&container).Error
_ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
ID: container.ID,
Status: rawJSON(container.Status),
})
}
// 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 {
rows, err := h.q.ListContainersForAgent(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch containers"})
return
}
containers := make([]ContainerInstance, 0, len(rows))
for _, row := range rows {
containers = append(containers, containerFromRow(row))
}
c.JSON(http.StatusOK, gin.H{"containers": containers})
}
@@ -595,10 +730,11 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
return
}
ctx := c.Request.Context()
// Verify agent exists
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
if _, err := h.q.GetAgent(ctx, agentID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
@@ -606,17 +742,15 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
return
}
containerID := uuid.New().String()
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",
},
ID: containerID,
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,
@@ -628,26 +762,36 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
UpdatedAt: time.Now(),
}
if err := h.db.Create(&container).Error; err != nil {
_, err := h.q.CreateContainer(ctx, sqlcdb.CreateContainerParams{
ID: container.ID,
Name: container.Name,
Image: container.Image,
ProjectID: container.ProjectID,
ServiceID: container.ServiceID,
NodeAgentID: container.NodeAgentID,
Status: rawJSON(container.Status),
Resources: rawJSON(container.Resources),
Ports: rawJSON(container.Ports),
Environment: rawJSON(container.Environment),
Volumes: rawJSON(container.Volumes),
Networks: rawJSON(container.Networks),
RestartPolicy: rawJSON(container.RestartPolicy),
HealthCheck: rawJSON(container.HealthCheck),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container"})
return
}
// Create command to start container on agent
command := AgentCommand{
_, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
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 {
ContainerID: sql.NullString{String: container.ID, Valid: true},
Payload: rawJSON(map[string]interface{}{"container": container}),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container command"})
return
}
@@ -669,45 +813,44 @@ func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
return
}
command := AgentCommand{
command, err := h.q.CreateCommand(c.Request.Context(), sqlcdb.CreateCommandParams{
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 {
Payload: rawJSON(req.Payload),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
return
}
c.JSON(http.StatusCreated, gin.H{"command": command})
c.JSON(http.StatusCreated, gin.H{"command": commandFromRow(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 {
rows, err := h.q.ListCommandsForAgent(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
return
}
commands := make([]AgentCommand, 0, len(rows))
for _, row := range rows {
commands = append(commands, commandFromRow(row))
}
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 {
command, err := h.q.GetCommandForAgent(c.Request.Context(), sqlcdb.GetCommandForAgentParams{
ID: c.Param("commandId"),
NodeAgentID: c.Param("id"),
})
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
return
}
@@ -715,7 +858,7 @@ func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"command": command})
c.JSON(http.StatusOK, gin.H{"command": commandFromRow(command)})
}
// ContainerAction handles container lifecycle actions
@@ -739,10 +882,15 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
return
}
ctx := c.Request.Context()
// 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 {
containerRow, err := h.q.GetContainerForAgent(ctx, sqlcdb.GetContainerForAgentParams{
ID: containerID,
NodeAgentID: agentID,
})
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
return
}
@@ -751,22 +899,18 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
}
// Create command for the action
command := AgentCommand{
_, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
ID: uuid.New().String(),
Type: fmt.Sprintf("%s_container", action),
NodeAgentID: agentID,
ContainerID: &container.ID,
Payload: map[string]interface{}{
ContainerID: sql.NullString{String: containerRow.ID, Valid: true},
Payload: rawJSON(map[string]interface{}{
"container_id": containerID,
"container_name": container.Name,
"docker_name": container.Name,
},
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
"container_name": containerRow.Name,
"docker_name": containerRow.Name,
}),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
return
}
@@ -794,22 +938,23 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) {
return
}
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
ctx := c.Request.Context()
agentRow, err := h.q.GetAgent(ctx, agentID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
return
}
agent := agentFromRow(agentRow)
from := time.Now().Add(-duration)
var records []AgentHeartbeatRecord
queryErr := h.db.
Where("node_agent_id = ? AND timestamp >= ?", agentID, from).
Order("timestamp ASC").
Find(&records).Error
records, queryErr := h.q.ListAgentHeartbeatsSince(ctx, sqlcdb.ListAgentHeartbeatsSinceParams{
NodeAgentID: agentID,
Timestamp: from,
})
if queryErr != nil && !isMissingTableError(queryErr) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent metrics"})
return
@@ -817,7 +962,11 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) {
metrics := make([]map[string]interface{}, 0, len(records))
for _, record := range records {
metrics = append(metrics, buildMetricPoint(record.Timestamp, record.Resources, record.SystemLoad, record.ContainerCount))
var resources NodeResources
var load SystemLoad
_ = json.Unmarshal(record.Resources, &resources)
_ = json.Unmarshal(record.SystemLoad, &load)
metrics = append(metrics, buildMetricPoint(record.Timestamp, resources, load, int(record.ContainerCount)))
}
if len(metrics) == 0 {
+128 -72
View File
@@ -6,6 +6,7 @@ import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strings"
@@ -13,21 +14,14 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
// Constants for validation and limits
// Default quota limits applied when the request omits them.
const (
MaxServiceNameLength = 100
MaxRoutePrefixLength = 200
MaxUpstreamURLLength = 500
MaxAPIKeyNameLength = 100
MinAPIKeyLength = 20
MaxAPIKeyLength = 100
DefaultRPMLimit = 60
DefaultMonthlyQuota = 1000
MaxRPMLimit = 10000
MaxMonthlyQuota = 10000000
DefaultRPMLimit = 60
DefaultMonthlyQuota = 1000
)
// Validator instance for request validation
@@ -75,15 +69,6 @@ type APIKeyRequest struct {
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)
@@ -162,6 +147,84 @@ func validateRequest(c *gin.Context, req interface{}) error {
return nil
}
// isUniqueViolation reports whether err is a Postgres unique-constraint violation.
func isUniqueViolation(err error) bool {
var pqErr *pq.Error
return errors.As(err, &pqErr) && pqErr.Code == "23505"
}
// handleAPwhyServiceValidate probes the upstream health path and records the
// result on the service row. Ported from the standalone APwhy server.
func handleAPwhyServiceValidate(c *gin.Context) {
serviceID := c.Param("id")
db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context()
var upstreamURL, healthPath string
var authHeader, authValue sql.NullString
var timeoutMs sql.NullInt64
err := db.QueryRowContext(ctx, `
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
FROM api_services WHERE id = $1
`, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs)
if err != nil {
sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil)
return
}
timeout := 8000
if timeoutMs.Valid && timeoutMs.Int64 > 0 {
timeout = int(timeoutMs.Int64)
}
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if authHeader.Valid && authHeader.String != "" {
req.Header.Set(authHeader.String, authValue.String)
}
client := &http.Client{Timeout: time.Duration(timeout) * time.Millisecond}
res, probeErr := client.Do(req)
status := http.StatusBadGateway
message := ""
ok := false
if probeErr != nil {
message = probeErr.Error()
} else {
defer res.Body.Close()
status = res.StatusCode
message = fmt.Sprintf("HTTP %d", res.StatusCode)
ok = res.StatusCode >= 200 && res.StatusCode < 300
}
validationStatus := "failed"
if ok {
validationStatus = "healthy"
}
_, _ = db.ExecContext(ctx, `
UPDATE api_services
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
WHERE id = $3
`, validationStatus, message, serviceID)
if !ok {
_, _ = db.ExecContext(ctx, `
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
`, serviceID, message, status)
}
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
"validation": map[string]interface{}{
"ok": ok,
"status": status,
"message": message,
},
})
}
// handleAPwhyServicesList returns a list of API services
func handleAPwhyServicesList(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
@@ -170,7 +233,7 @@ func handleAPwhyServicesList(c *gin.Context) {
// 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
FROM api_services
ORDER BY created_at DESC
`)
if err != nil {
@@ -180,10 +243,10 @@ func handleAPwhyServicesList(c *gin.Context) {
}
defer rows.Close()
var services []map[string]interface{}
services := make([]map[string]interface{}, 0)
for rows.Next() {
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
var enabled bool
var enabled int
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
if err != nil {
continue // Skip malformed rows
@@ -195,7 +258,7 @@ func handleAPwhyServicesList(c *gin.Context) {
"slug": slug,
"upstreamUrl": upstreamURL,
"routePrefix": routePrefix,
"enabled": enabled,
"enabled": enabled != 0,
"createdAt": createdAt,
"updatedAt": updatedAt,
})
@@ -220,24 +283,8 @@ func handleAPwhyServicesCreate(c *gin.Context) {
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
@@ -251,10 +298,22 @@ func handleAPwhyServicesCreate(c *gin.Context) {
monthlyQuota = DefaultMonthlyQuota
}
_, err = db.ExecContext(ctx, query, id, req.Name, slug, req.UpstreamURL,
req.RoutePrefix, rpmLimit, monthlyQuota)
// id defaults to gen_random_uuid(); enabled defaults to 1.
var id string
err := db.QueryRowContext(ctx, `
INSERT INTO api_services (
name, slug, upstream_url, route_prefix, health_path,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
RETURNING id
`, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id)
if err != nil {
if isUniqueViolation(err) {
sendErrorResponse(c, http.StatusConflict, "CONFLICT",
"A service with this slug or route prefix already exists", err.Error())
return
}
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
"Failed to create service", err.Error())
return
@@ -297,9 +356,13 @@ func handleAPwhyServicesPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
enabled := 0
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(),
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
*input.Enabled, serviceID)
enabled, serviceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -334,10 +397,10 @@ func handleAPwhyKeysList(c *gin.Context) {
}
defer rows.Close()
var keys []map[string]interface{}
keys := make([]map[string]interface{}, 0)
for rows.Next() {
var id, name, keyPrefix, plan, createdAt, updatedAt string
var enabled bool
var enabled int
var rpmLimit, monthlyQuota sql.NullInt64
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
@@ -350,7 +413,7 @@ func handleAPwhyKeysList(c *gin.Context) {
"name": name,
"keyPrefix": keyPrefix,
"plan": plan,
"enabled": enabled,
"enabled": enabled != 0,
"createdAt": createdAt,
"updatedAt": updatedAt,
}
@@ -407,36 +470,25 @@ func handleAPwhyKeysCreate(c *gin.Context) {
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
plan, rpmLimit, monthlyQuota, err := validateAPIKeyPlan(input.Plan)
if err != nil {
sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR",
"Invalid plan", err.Error())
return
}
// Insert key into database
_, err = db.ExecContext(context.Background(), `
// id defaults to gen_random_uuid(); enabled defaults to 1.
var id string
err = db.QueryRowContext(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)
name, key_hash, key_prefix, plan, allowed_service_ids,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
RETURNING id
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -451,7 +503,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
"data": gin.H{
"id": id,
"name": input.Name,
"plan": input.Plan,
"plan": plan,
"key": apiKey, // Only return the actual key once
"keyPrefix": keyPrefix,
"enabled": true,
@@ -480,9 +532,13 @@ func handleAPwhyKeysPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
enabled := 0
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(),
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
*input.Enabled, keyID)
enabled, keyID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
+15 -1
View File
@@ -1,6 +1,10 @@
package api
import "github.com/gin-gonic/gin"
import (
"strings"
"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 {
@@ -11,3 +15,13 @@ func firstPathParam(c *gin.Context, names ...string) string {
}
return ""
}
// firstNonEmpty returns the first non-blank string, trimmed.
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
+16 -44
View File
@@ -17,8 +17,6 @@ import (
"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) {
@@ -56,14 +54,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// 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 agent handler (sqlc-backed)
agentHandler := NewNodeAgentHandler(db)
// Initialize database handler
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
@@ -90,7 +82,6 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
c.Set("auto_scaler", autoScaler)
c.Set("ha_manager", haManager)
c.Set("scaling_handler", scalingHandler)
c.Set("gorm_db", gormDB)
c.Next()
})
@@ -298,42 +289,23 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// 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),
},
})
})
}
// API Gateway routes (merged APwhy) - namespaced to avoid
// colliding with Containr's own /services paths.
gateway := protected.Group("/gateway")
{
gateway.GET("/services", handleAPwhyServicesList)
gateway.POST("/services", handleAPwhyServicesCreate)
gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
gateway.GET("/services/:id/validate", handleAPwhyServiceValidate)
// 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)
gateway.GET("/keys", handleAPwhyKeysList)
gateway.POST("/keys", handleAPwhyKeysCreate)
gateway.PATCH("/keys/:id", handleAPwhyKeysPatch)
// 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)
gateway.GET("/analytics/ops", handleAPwhyAnalyticsOps)
gateway.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1 +0,0 @@
@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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 truncated
@@ -1 +0,0 @@
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();const Ge=!1,We=(e,t)=>e===t,Se=Symbol("solid-proxy"),Qe=typeof Proxy=="function",Xe=Symbol("solid-track"),de={equals:We};let je=Fe;const J=1,fe=2,Ve={owned:null,cleanups:null,context:null,owner:null},ve={};var S=null;let xe=null,Ye=null,_=null,I=null,q=null,he=0;function ce(e,t){const r=_,n=S,s=e.length===0,i=t===void 0?n:t,o=s?Ve:{owned:null,cleanups:null,context:i?i.context:null,owner:i},a=s?e:()=>e(()=>j(()=>re(o)));S=o,_=null;try{return H(a,!0)}finally{_=r,S=n}}function y(e,t){t=t?Object.assign({},de,t):de;const r={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},n=s=>(typeof s=="function"&&(s=s(r.value)),qe(r,s));return[Be.bind(r),n]}function ze(e,t,r){const n=me(e,t,!0,J);z(n)}function L(e,t,r){const n=me(e,t,!1,J);z(n)}function Ee(e,t,r){je=it;const n=me(e,t,!1,J);n.user=!0,q?q.push(n):z(n)}function R(e,t,r){r=r?Object.assign({},de,r):de;const n=me(e,t,!0,0);return n.observers=null,n.observerSlots=null,n.comparator=r.equals||void 0,z(n),Be.bind(n)}function Ze(e){return e&&typeof e=="object"&&"then"in e}function F(e,t,r){let n,s,i;n=!0,s=e,i={};let o=null,a=ve,h=!1,m="initialValue"in i,d=typeof n=="function"&&R(n);const l=new Set,[f,g]=(i.storage||y)(i.initialValue),[b,x]=y(void 0),[V,U]=y(void 0,{equals:!1}),[T,O]=y(m?"ready":"unresolved");function k(w,$,B,N){return o===w&&(o=null,N!==void 0&&(m=!0),(w===a||$===a)&&i.onHydrated&&queueMicrotask(()=>i.onHydrated(N,{value:$})),a=ve,M($,B)),$}function M(w,$){H(()=>{$===void 0&&g(()=>w),O($!==void 0?"errored":m?"ready":"unresolved"),x($);for(const B of l.keys())B.decrement();l.clear()},!1)}function G(){const w=nt,$=f(),B=b();if(B!==void 0&&!o)throw B;return _&&_.user,$}function Z(w=!0){if(w!==!1&&h)return;h=!1;const $=d?d():n;if($==null||$===!1){k(o,j(f));return}let B;const N=a!==ve?a:j(()=>{try{return s($,{value:f(),refetching:w})}catch(te){B=te}});if(B!==void 0){k(o,void 0,ue(B),$);return}else if(!Ze(N))return k(o,N,void 0,$),N;return o=N,"v"in N?(N.s===1?k(o,N.v,void 0,$):k(o,void 0,ue(N.v),$),N):(h=!0,queueMicrotask(()=>h=!1),H(()=>{O(m?"refreshing":"pending"),U()},!1),N.then(te=>k(N,te,void 0,$),te=>k(N,void 0,ue(te),$)))}Object.defineProperties(G,{state:{get:()=>T()},error:{get:()=>b()},loading:{get(){const w=T();return w==="pending"||w==="refreshing"}},latest:{get(){if(!m)return G();const w=b();if(w&&!o)throw w;return f()}}});let ee=S;return d?ze(()=>(ee=S,Z(!1))):Z(!1),[G,{refetch:w=>tt(ee,()=>Z(w)),mutate:g}]}function j(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function et(e){return S===null||(S.cleanups===null?S.cleanups=[e]:S.cleanups.push(e)),e}function tt(e,t){const r=S,n=_;S=e,_=null;try{return H(t,!0)}catch(s){ke(s)}finally{S=r,_=n}}const[rl,nl]=y(!1);function rt(e){const t=R(e),r=R(()=>Ce(t()));return r.toArray=()=>{const n=r();return Array.isArray(n)?n:n!=null?[n]:[]},r}let nt;function Be(){if(this.sources&&this.state)if(this.state===J)z(this);else{const e=I;I=null,H(()=>ge(this),!1),I=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,r){let n=e.value;return(!e.comparator||!e.comparator(n,t))&&(e.value=t,e.observers&&e.observers.length&&H(()=>{for(let s=0;s<e.observers.length;s+=1){const i=e.observers[s],o=xe&&xe.running;o&&xe.disposed.has(i),(o?!i.tState:!i.state)&&(i.pure?I.push(i):q.push(i),i.observers&&Ke(i)),o||(i.state=J)}if(I.length>1e6)throw I=[],new Error},!1)),t}function z(e){if(!e.fn)return;re(e);const t=he;lt(e,e.value,t)}function lt(e,t,r){let n;const s=S,i=_;_=S=e;try{n=e.fn(t)}catch(o){return e.pure&&(e.state=J,e.owned&&e.owned.forEach(re),e.owned=null),e.updatedAt=r+1,ke(o)}finally{_=i,S=s}(!e.updatedAt||e.updatedAt<=r)&&(e.updatedAt!=null&&"observers"in e?qe(e,n):e.value=n,e.updatedAt=r)}function me(e,t,r,n=J,s){const i={fn:e,state:n,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:S,context:S?S.context:null,pure:r};return S===null||S!==Ve&&(S.owned?S.owned.push(i):S.owned=[i]),i}function pe(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&j(e.suspenLine truncated
@@ -1,13 +0,0 @@
<!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>
+2 -2
View File
@@ -310,8 +310,8 @@ func handleCreateFromTemplate(c *gin.Context) {
ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""},
StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
Type: serviceType,
Status: "stopped",
Type: sql.NullString{String: serviceType, Valid: true},
Status: sql.NullString{String: "stopped", Valid: true},
Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
Environment: sql.NullString{String: serviceEnvironment, Valid: true},