mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
37a56dc6b6
commit
9e3aa3aa17
153 files changed
+6996
-11135
No files matched your search
@@ -1,74 +0,0 @@
|
||||
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
|
||||
}
|
||||
+400
-251
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
@@ -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},
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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
@@ -1,321 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"containr/internal/apwhy/config"
|
||||
"containr/internal/apwhy/storage"
|
||||
)
|
||||
|
||||
type apiErrorPayload struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type apiResponsePayload struct {
|
||||
OK bool `json:"ok"`
|
||||
Data any `json:"data"`
|
||||
Error apiErrorPayload `json:"error"`
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (*httptest.Server, *http.Client) {
|
||||
t.Helper()
|
||||
|
||||
cfg := config.Load()
|
||||
cfg.SQLitePath = ":memory:"
|
||||
cfg.CookieSecure = false
|
||||
|
||||
store, err := storage.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test storage: %v", err)
|
||||
}
|
||||
|
||||
server := NewServer(store, cfg)
|
||||
ts := httptest.NewServer(server.Handler())
|
||||
|
||||
t.Cleanup(func() {
|
||||
ts.Close()
|
||||
_ = store.Close()
|
||||
})
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cookie jar: %v", err)
|
||||
}
|
||||
|
||||
return ts, &http.Client{Jar: jar}
|
||||
}
|
||||
|
||||
func requestJSON(t *testing.T, client *http.Client, method string, url string, body string) (int, apiResponsePayload) {
|
||||
t.Helper()
|
||||
|
||||
var bodyReader *bytes.Reader
|
||||
if body == "" {
|
||||
bodyReader = bytes.NewReader(nil)
|
||||
} else {
|
||||
bodyReader = bytes.NewReader([]byte(body))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, bodyReader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var payload apiResponsePayload
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("failed to decode response body: %v", err)
|
||||
}
|
||||
|
||||
return resp.StatusCode, payload
|
||||
}
|
||||
|
||||
func bootstrapAndLoginOwner(t *testing.T, client *http.Client, baseURL string) {
|
||||
t.Helper()
|
||||
|
||||
status, payload := requestJSON(
|
||||
t,
|
||||
client,
|
||||
http.MethodPost,
|
||||
baseURL+"/api/v1/bootstrap/register-owner",
|
||||
`{"email":"[email protected]","password":"owner-pass-123"}`,
|
||||
)
|
||||
if status != http.StatusCreated || !payload.OK {
|
||||
t.Fatalf("owner bootstrap failed: status=%d payload=%+v", status, payload)
|
||||
}
|
||||
|
||||
status, payload = requestJSON(
|
||||
t,
|
||||
client,
|
||||
http.MethodPost,
|
||||
baseURL+"/api/v1/auth/login",
|
||||
`{"email":"[email protected]","password":"owner-pass-123"}`,
|
||||
)
|
||||
if status != http.StatusOK || !payload.OK {
|
||||
t.Fatalf("owner login failed: status=%d payload=%+v", status, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerBootstrapLoginAndProtectedRoutes(t *testing.T) {
|
||||
ts, authClient := newTestServer(t)
|
||||
baseURL := ts.URL
|
||||
|
||||
// Bootstrap should be open before any user exists.
|
||||
status, payload := requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/bootstrap/status", "")
|
||||
if status != http.StatusOK || !payload.OK {
|
||||
t.Fatalf("unexpected bootstrap status response: status=%d payload=%+v", status, payload)
|
||||
}
|
||||
bootstrapData, ok := payload.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload for bootstrap status, got %#v", payload.Data)
|
||||
}
|
||||
if hasUsers, _ := bootstrapData["hasUsers"].(bool); hasUsers {
|
||||
t.Fatalf("expected hasUsers=false before owner bootstrap")
|
||||
}
|
||||
if registrationOpen, _ := bootstrapData["registrationOpen"].(bool); !registrationOpen {
|
||||
t.Fatalf("expected registrationOpen=true before owner bootstrap")
|
||||
}
|
||||
|
||||
// Protected route without auth should fail.
|
||||
anonClient := &http.Client{}
|
||||
status, payload = requestJSON(t, anonClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
|
||||
if status != http.StatusUnauthorized || payload.OK {
|
||||
t.Fatalf("expected unauthorized /auth/me response, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
|
||||
// Register owner and login.
|
||||
bootstrapAndLoginOwner(t, authClient, baseURL)
|
||||
|
||||
// Bootstrap should close once owner exists.
|
||||
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/bootstrap/status", "")
|
||||
if status != http.StatusOK || !payload.OK {
|
||||
t.Fatalf("unexpected bootstrap status after owner creation: status=%d payload=%+v", status, payload)
|
||||
}
|
||||
bootstrapData, ok = payload.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload for bootstrap status, got %#v", payload.Data)
|
||||
}
|
||||
if hasUsers, _ := bootstrapData["hasUsers"].(bool); !hasUsers {
|
||||
t.Fatalf("expected hasUsers=true after owner bootstrap")
|
||||
}
|
||||
if registrationOpen, _ := bootstrapData["registrationOpen"].(bool); registrationOpen {
|
||||
t.Fatalf("expected registrationOpen=false after owner bootstrap")
|
||||
}
|
||||
|
||||
// Owner session cookie should authorize /auth/me.
|
||||
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
|
||||
if status != http.StatusOK || !payload.OK {
|
||||
t.Fatalf("expected successful /auth/me response, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
meData, ok := payload.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload for auth/me, got %#v", payload.Data)
|
||||
}
|
||||
if email, _ := meData["email"].(string); email != "[email protected]" {
|
||||
t.Fatalf("unexpected /auth/me email: %q", email)
|
||||
}
|
||||
|
||||
// Logout should revoke auth session.
|
||||
status, payload = requestJSON(t, authClient, http.MethodPost, baseURL+"/api/v1/auth/logout", "")
|
||||
if status != http.StatusOK || !payload.OK {
|
||||
t.Fatalf("expected successful logout response, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
|
||||
if status != http.StatusUnauthorized || payload.OK {
|
||||
t.Fatalf("expected unauthorized /auth/me after logout, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerServicesCreateValidationRules(t *testing.T) {
|
||||
ts, client := newTestServer(t)
|
||||
baseURL := ts.URL
|
||||
|
||||
bootstrapAndLoginOwner(t, client, baseURL)
|
||||
|
||||
// Route prefix under /api must be rejected.
|
||||
status, payload := requestJSON(
|
||||
t,
|
||||
client,
|
||||
http.MethodPost,
|
||||
baseURL+"/api/v1/services",
|
||||
`{"name":"Bad Service","upstreamUrl":"http://example.com","routePrefix":"/api/conflict"}`,
|
||||
)
|
||||
if status != http.StatusBadRequest || payload.OK {
|
||||
t.Fatalf("expected route conflict validation failure, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
if payload.Error.Code != "ROUTE_CONFLICT" {
|
||||
t.Fatalf("expected ROUTE_CONFLICT error code, got %q", payload.Error.Code)
|
||||
}
|
||||
|
||||
// Valid service should be created successfully.
|
||||
status, payload = requestJSON(
|
||||
t,
|
||||
client,
|
||||
http.MethodPost,
|
||||
baseURL+"/api/v1/services",
|
||||
`{"name":"Web Frontend","upstreamUrl":"http://example.com","routePrefix":"/web"}`,
|
||||
)
|
||||
if status != http.StatusCreated || !payload.OK {
|
||||
t.Fatalf("expected successful service creation, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
createData, ok := payload.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload for service creation, got %#v", payload.Data)
|
||||
}
|
||||
if _, ok := createData["id"].(string); !ok {
|
||||
t.Fatalf("expected created service id in response")
|
||||
}
|
||||
if slug, _ := createData["slug"].(string); !strings.Contains(slug, "web-frontend") {
|
||||
t.Fatalf("expected normalized service slug, got %q", slug)
|
||||
}
|
||||
|
||||
// List should contain the newly created service.
|
||||
status, payload = requestJSON(t, client, http.MethodGet, baseURL+"/api/v1/services", "")
|
||||
if status != http.StatusOK || !payload.OK {
|
||||
t.Fatalf("expected successful services list response, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
rows, ok := payload.Data.([]any)
|
||||
if !ok || len(rows) == 0 {
|
||||
t.Fatalf("expected non-empty services list, got %#v", payload.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerProxyWithAPIKey(t *testing.T) {
|
||||
ts, authClient := newTestServer(t)
|
||||
baseURL := ts.URL
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-api-key") != "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, "unexpected-api-key-header")
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, "ok:"+r.URL.Path+"?"+r.URL.RawQuery)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
bootstrapAndLoginOwner(t, authClient, baseURL)
|
||||
|
||||
// Create service exposed at /web/* and proxied to the upstream test server.
|
||||
status, payload := requestJSON(
|
||||
t,
|
||||
authClient,
|
||||
http.MethodPost,
|
||||
baseURL+"/api/v1/services",
|
||||
`{"name":"Web Proxy","upstreamUrl":"`+upstream.URL+`","routePrefix":"/web"}`,
|
||||
)
|
||||
if status != http.StatusCreated || !payload.OK {
|
||||
t.Fatalf("expected successful service creation, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
|
||||
// Mint API key for gateway access.
|
||||
status, payload = requestJSON(
|
||||
t,
|
||||
authClient,
|
||||
http.MethodPost,
|
||||
baseURL+"/api/v1/keys",
|
||||
`{"name":"integration-key","plan":"free"}`,
|
||||
)
|
||||
if status != http.StatusCreated || !payload.OK {
|
||||
t.Fatalf("expected successful key creation, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
keyData, ok := payload.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected object payload for key creation, got %#v", payload.Data)
|
||||
}
|
||||
rawKey, _ := keyData["key"].(string)
|
||||
if strings.TrimSpace(rawKey) == "" {
|
||||
t.Fatalf("expected non-empty raw API key")
|
||||
}
|
||||
|
||||
// No API key should be rejected.
|
||||
anonClient := &http.Client{}
|
||||
status, payload = requestJSON(t, anonClient, http.MethodGet, baseURL+"/web/ping?x=1", "")
|
||||
if status != http.StatusUnauthorized || payload.OK {
|
||||
t.Fatalf("expected unauthorized proxy request without key, got status=%d payload=%+v", status, payload)
|
||||
}
|
||||
if payload.Error.Code != "API_KEY_MISSING" {
|
||||
t.Fatalf("expected API_KEY_MISSING error code, got %q", payload.Error.Code)
|
||||
}
|
||||
|
||||
// Valid API key should proxy request and strip the key before forwarding.
|
||||
req, err := http.NewRequest(http.MethodGet, baseURL+"/web/ping?x=1", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create proxy request: %v", err)
|
||||
}
|
||||
req.Header.Set("x-api-key", rawKey)
|
||||
|
||||
resp, err := anonClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("proxy request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read proxy response body: %v", err)
|
||||
}
|
||||
body := string(bodyBytes)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected proxy status 200, got %d body=%q", resp.StatusCode, body)
|
||||
}
|
||||
if body != "ok:/ping?x=1" {
|
||||
t.Fatalf("unexpected proxy body: %q", body)
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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>
|
||||
@@ -1,119 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
func RandomToken(bytesLen int) (string, error) {
|
||||
buf := make([]byte, bytesLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func RandomPassword(length int) (string, error) {
|
||||
token, err := RandomToken(length)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(token) > length {
|
||||
return token[:length], nil
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func RandomID(prefix string) (string, error) {
|
||||
buf := make([]byte, 10)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s_%s", prefix, hex.EncodeToString(buf)), nil
|
||||
}
|
||||
|
||||
func HashToken(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(password) < 8 {
|
||||
return "", errors.New("password must be at least 8 characters")
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
iterations := uint32(3)
|
||||
memory := uint32(64 * 1024)
|
||||
parallelism := uint8(2)
|
||||
keyLen := uint32(32)
|
||||
|
||||
hash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, keyLen)
|
||||
encoded := fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
memory,
|
||||
iterations,
|
||||
parallelism,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
)
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func VerifyPassword(encodedHash, password string) (bool, error) {
|
||||
parts := strings.Split(encodedHash, "$")
|
||||
if len(parts) != 6 {
|
||||
return false, errors.New("invalid hash format")
|
||||
}
|
||||
if parts[1] != "argon2id" {
|
||||
return false, errors.New("unsupported hash type")
|
||||
}
|
||||
|
||||
var memory uint32
|
||||
var iterations uint32
|
||||
var parallelism uint8
|
||||
|
||||
_, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
decodedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
calculated := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, uint32(len(decodedHash)))
|
||||
if len(calculated) != len(decodedHash) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var diff byte
|
||||
for i := range calculated {
|
||||
diff |= calculated[i] ^ decodedHash[i]
|
||||
}
|
||||
return diff == 0, nil
|
||||
}
|
||||
|
||||
func ParseIntOrDefault(value string, fallback int) int {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
SQLitePath string
|
||||
DashboardUIBasePath string
|
||||
APIKeyHeader string
|
||||
ServiceTokenHeader string
|
||||
AllowRootRoutePrefix bool
|
||||
DefaultServiceTimeout time.Duration
|
||||
CookieSecure bool
|
||||
CookieDomain string
|
||||
AccessTokenTTL time.Duration
|
||||
RefreshTokenTTL time.Duration
|
||||
SessionAccessCookie string
|
||||
SessionRefreshCookie string
|
||||
FreeRPM int
|
||||
ProRPM int
|
||||
BusinessRPM int
|
||||
FreeMonthlyQuota int
|
||||
ProMonthlyQuota int
|
||||
BusinessMonthlyQuota int
|
||||
UmamiBaseURL string
|
||||
UmamiAPIKey string
|
||||
UmamiWebsiteID string
|
||||
TrustedProxyCIDR string
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getenvInt(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func getenvBool(key string, fallback bool) bool {
|
||||
value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value == "1" || value == "true" || value == "yes"
|
||||
}
|
||||
|
||||
func normalizePathPrefix(value, fallback string) string {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
if !strings.HasPrefix(v, "/") {
|
||||
v = "/" + v
|
||||
}
|
||||
if len(v) > 1 && strings.HasSuffix(v, "/") {
|
||||
v = strings.TrimSuffix(v, "/")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func normalizeSQLitePath(value string) string {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
v = "./data/apwhy.sqlite"
|
||||
}
|
||||
if v == ":memory:" {
|
||||
return v
|
||||
}
|
||||
if strings.HasPrefix(v, "file:") {
|
||||
return v
|
||||
}
|
||||
if filepath.IsAbs(v) {
|
||||
return "file:" + v
|
||||
}
|
||||
abs, err := filepath.Abs(v)
|
||||
if err != nil {
|
||||
return "file:" + v
|
||||
}
|
||||
return "file:" + abs
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
return Config{
|
||||
Port: getenvInt("APWHY_PORT", getenvInt("PORT", 3001)),
|
||||
SQLitePath: normalizeSQLitePath(getenv("SQLITE_DB_PATH", getenv("DATABASE_URL", "./data/apwhy.sqlite"))),
|
||||
DashboardUIBasePath: normalizePathPrefix(getenv("DASHBOARD_UI_BASE_PATH", "/"), "/"),
|
||||
APIKeyHeader: strings.ToLower(getenv("API_KEY_HEADER", "x-api-key")),
|
||||
ServiceTokenHeader: strings.ToLower(getenv("SERVICE_TOKEN_HEADER", "x-apwhy-service-token")),
|
||||
AllowRootRoutePrefix: getenvBool("ALLOW_ROOT_ROUTE_PREFIX", false),
|
||||
DefaultServiceTimeout: time.Duration(
|
||||
getenvInt("DEFAULT_SERVICE_TIMEOUT_MS", 8000),
|
||||
) * time.Millisecond,
|
||||
CookieSecure: getenvBool("COOKIE_SECURE", false),
|
||||
CookieDomain: getenv("COOKIE_DOMAIN", ""),
|
||||
AccessTokenTTL: time.Duration(getenvInt("ACCESS_TOKEN_TTL_MINUTES", 15)) * time.Minute,
|
||||
RefreshTokenTTL: time.Duration(getenvInt("REFRESH_TOKEN_TTL_HOURS", 168)) * time.Hour,
|
||||
SessionAccessCookie: getenv("SESSION_ACCESS_COOKIE", "apwhy_access"),
|
||||
SessionRefreshCookie: getenv("SESSION_REFRESH_COOKIE", "apwhy_refresh"),
|
||||
FreeRPM: getenvInt("FREE_RPM", 60),
|
||||
ProRPM: getenvInt("PRO_RPM", 600),
|
||||
BusinessRPM: getenvInt("BUSINESS_RPM", 3000),
|
||||
FreeMonthlyQuota: getenvInt("FREE_MONTHLY_QUOTA", 1000),
|
||||
ProMonthlyQuota: getenvInt("PRO_MONTHLY_QUOTA", 50000),
|
||||
BusinessMonthlyQuota: getenvInt("BUSINESS_MONTHLY_QUOTA", 300000),
|
||||
UmamiBaseURL: strings.TrimRight(getenv("UMAMI_BASE_URL", ""), "/"),
|
||||
UmamiAPIKey: getenv("UMAMI_API_KEY", ""),
|
||||
UmamiWebsiteID: getenv("UMAMI_WEBSITE_ID", ""),
|
||||
TrustedProxyCIDR: getenv("TRUSTED_PROXY_CIDR", ""),
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func BuildTargetURL(upstreamURL, routePrefix, requestPath, rawQuery string) (string, error) {
|
||||
base, err := url.Parse(upstreamURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
trimmedPrefix := routePrefix
|
||||
if trimmedPrefix == "/" {
|
||||
trimmedPrefix = ""
|
||||
}
|
||||
withoutPrefix := requestPath
|
||||
if trimmedPrefix != "" && strings.HasPrefix(requestPath, trimmedPrefix) {
|
||||
withoutPrefix = strings.TrimPrefix(requestPath, trimmedPrefix)
|
||||
}
|
||||
if !strings.HasPrefix(withoutPrefix, "/") {
|
||||
withoutPrefix = "/" + withoutPrefix
|
||||
}
|
||||
base.Path = strings.TrimRight(base.Path, "/") + withoutPrefix
|
||||
base.RawQuery = rawQuery
|
||||
return base.String(), nil
|
||||
}
|
||||
|
||||
func ProxyRequest(client *http.Client, w http.ResponseWriter, r *http.Request, targetURL string, headers map[string]string) (int, []byte, error) {
|
||||
var body []byte
|
||||
if r.Body != nil {
|
||||
defer r.Body.Close()
|
||||
payload, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
body = payload
|
||||
}
|
||||
|
||||
upstreamReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
for key, values := range r.Header {
|
||||
lower := strings.ToLower(key)
|
||||
if lower == "host" || lower == "content-length" || lower == "connection" {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
upstreamReq.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range headers {
|
||||
upstreamReq.Header.Set(key, value)
|
||||
}
|
||||
|
||||
res, err := client.Do(upstreamReq)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
for key, values := range res.Header {
|
||||
if strings.EqualFold(key, "transfer-encoding") || strings.EqualFold(key, "connection") {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(res.StatusCode)
|
||||
|
||||
respBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return res.StatusCode, nil, err
|
||||
}
|
||||
_, _ = w.Write(respBody)
|
||||
|
||||
return res.StatusCode, respBody, nil
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package rbac
|
||||
|
||||
type PermissionSeed struct {
|
||||
Code string
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
var PermissionSeeds = []PermissionSeed{
|
||||
{Code: "services.read", Name: "Read Services", Description: "View service routing and status."},
|
||||
{Code: "services.write", Name: "Manage Services", Description: "Create and modify protected services."},
|
||||
{Code: "databases.read", Name: "Read Databases", Description: "View database connectors."},
|
||||
{Code: "databases.write", Name: "Manage Databases", Description: "Create and modify database connectors."},
|
||||
{Code: "keys.read", Name: "Read API Keys", Description: "View API keys and plans."},
|
||||
{Code: "keys.write", Name: "Manage API Keys", Description: "Create and modify API keys."},
|
||||
{Code: "users.read", Name: "Read Users", Description: "View users and login status."},
|
||||
{Code: "users.write", Name: "Manage Users", Description: "Create and modify user accounts."},
|
||||
{Code: "roles.read", Name: "Read Roles", Description: "View roles and permissions."},
|
||||
{Code: "roles.write", Name: "Manage Roles", Description: "Create and modify roles and permissions."},
|
||||
{Code: "analytics.read", Name: "Read Analytics", Description: "View ops and traffic analytics."},
|
||||
{Code: "settings.write", Name: "Manage Settings", Description: "Modify system settings and integrations."},
|
||||
}
|
||||
|
||||
var OwnerPermissionCodes = []string{
|
||||
"services.read", "services.write",
|
||||
"databases.read", "databases.write",
|
||||
"keys.read", "keys.write",
|
||||
"users.read", "users.write",
|
||||
"roles.read", "roles.write",
|
||||
"analytics.read", "settings.write",
|
||||
}
|
||||
|
||||
var AdminPermissionCodes = []string{
|
||||
"services.read", "services.write",
|
||||
"databases.read", "databases.write",
|
||||
"keys.read", "keys.write",
|
||||
"users.read", "users.write",
|
||||
"roles.read",
|
||||
"analytics.read",
|
||||
}
|
||||
|
||||
var ViewerPermissionCodes = []string{
|
||||
"services.read",
|
||||
"databases.read",
|
||||
"keys.read",
|
||||
"users.read",
|
||||
"roles.read",
|
||||
"analytics.read",
|
||||
}
|
||||
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user