mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Overhaul: real metrics, Git integration UI, open-source hygiene, root cleanup
Backend: - GET /services/:id/metrics with real Docker container stats - DELETE /git/providers/:id and GET repo branches endpoint Frontend: - Service detail shows real telemetry or honest empty states - Live status distinguishes idle/polling/reconnecting - Git providers section in Settings; repo+branch pickers in Add Service - Real logo, compact Projects header, canvas fitView fix - index.css deduplicated (1725 -> 1384 lines) - Fixed smoke tests, lint errors, ToastProvider/context unification Repo hygiene: - Added LICENSE (MIT), CONTRIBUTING.md, SECURITY.md; rewrote README - Untracked .env.prod and data/letsencrypt secrets; fixed .gitignore - Removed legacy root src/internal/cmd and stale configs/docs/artifacts - Moved guides and design references under docs/ Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
355a97bab4
commit
9f3b196032
499 files changed
+4471
-94235
No files matched your search
@@ -171,6 +171,7 @@ type AgentCommand struct {
|
||||
// AgentHeartbeat represents a heartbeat message from an agent
|
||||
type AgentHeartbeat struct {
|
||||
NodeAgentID string `json:"node_agent_id"`
|
||||
AuthToken string `json:"auth_token,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Status string `json:"status"`
|
||||
Resources NodeResources `json:"resources"`
|
||||
@@ -193,6 +194,10 @@ type AgentHeartbeatRecord struct {
|
||||
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"`
|
||||
@@ -216,7 +221,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
IPAddress string `json:"ip_address" binding:"required"`
|
||||
Port int `json:"port" binding:"required"`
|
||||
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
||||
AuthToken string `json:"auth_token" binding:"required"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -224,7 +229,8 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !isValidAgentAuthToken(req.AuthToken) {
|
||||
authToken := firstNonEmpty(req.AuthToken, agentAuthTokenFromRequest(c))
|
||||
if !isValidAgentAuthToken(authToken) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
@@ -246,7 +252,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"agent_id": existingAgent.ID,
|
||||
"auth_token": req.AuthToken,
|
||||
"auth_token": authToken,
|
||||
"status": "updated",
|
||||
})
|
||||
return
|
||||
@@ -308,7 +314,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"agent_id": agent.ID,
|
||||
"auth_token": req.AuthToken,
|
||||
"auth_token": authToken,
|
||||
"status": "registered",
|
||||
})
|
||||
}
|
||||
@@ -388,6 +394,11 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
authToken := firstNonEmpty(heartbeat.AuthToken, agentAuthTokenFromRequest(c))
|
||||
if !isValidAgentAuthToken(authToken) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
if heartbeat.Timestamp.IsZero() {
|
||||
heartbeat.Timestamp = time.Now()
|
||||
}
|
||||
@@ -436,6 +447,118 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// GetPendingCommandsForAgent exposes queued commands to token-authenticated node agents.
|
||||
func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) {
|
||||
if !isValidAgentAuthToken(agentAuthTokenFromRequest(c)) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"commands": commands})
|
||||
}
|
||||
|
||||
// CompleteCommand lets a node agent report command completion or failure.
|
||||
func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
|
||||
if !isValidAgentAuthToken(agentAuthTokenFromRequest(c)) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
|
||||
agentID := c.Param("id")
|
||||
commandID := c.Param("commandId")
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
Result string `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Status != "completed" && req.Status != "failed" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or failed"})
|
||||
return
|
||||
}
|
||||
|
||||
var command AgentCommand
|
||||
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch command"})
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"command": command})
|
||||
}
|
||||
|
||||
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentCommand) {
|
||||
var container ContainerInstance
|
||||
if err := h.db.First(&container, "id = ?", *command.ContainerID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if command.Status == "failed" {
|
||||
errorMessage := ""
|
||||
if command.Error != nil {
|
||||
errorMessage = *command.Error
|
||||
}
|
||||
container.Status.State = "error"
|
||||
container.Status.Error = &errorMessage
|
||||
_ = h.db.Save(&container).Error
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
switch command.Type {
|
||||
case "create_container", "start_container", "restart_container":
|
||||
container.Status.State = "running"
|
||||
container.Status.Health = "unknown"
|
||||
container.Status.StartedAt = &now
|
||||
container.Status.Error = nil
|
||||
case "stop_container":
|
||||
container.Status.State = "stopped"
|
||||
container.Status.FinishedAt = &now
|
||||
case "remove_container":
|
||||
container.Status.State = "removed"
|
||||
container.Status.FinishedAt = &now
|
||||
}
|
||||
container.UpdatedAt = now
|
||||
_ = h.db.Save(&container).Error
|
||||
}
|
||||
|
||||
// GetAgentContainers returns containers running on a specific agent
|
||||
func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) {
|
||||
agentID := c.Param("id")
|
||||
@@ -634,7 +757,9 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
|
||||
NodeAgentID: agentID,
|
||||
ContainerID: &container.ID,
|
||||
Payload: map[string]interface{}{
|
||||
"container_id": containerID,
|
||||
"container_id": containerID,
|
||||
"container_name": container.Name,
|
||||
"docker_name": container.Name,
|
||||
},
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
@@ -757,6 +882,19 @@ func isValidAgentAuthToken(token string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func agentAuthTokenFromRequest(c *gin.Context) string {
|
||||
if token := strings.TrimSpace(c.GetHeader("X-Containr-Agent-Token")); token != "" {
|
||||
return token
|
||||
}
|
||||
if authHeader := strings.TrimSpace(c.GetHeader("Authorization")); authHeader != "" {
|
||||
const bearerPrefix = "Bearer "
|
||||
if strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(authHeader, bearerPrefix))
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(c.Query("auth_token"))
|
||||
}
|
||||
|
||||
func configuredAgentAuthTokens() []string {
|
||||
candidateCSV := strings.TrimSpace(os.Getenv("CONTAINR_AGENT_AUTH_TOKENS"))
|
||||
if candidateCSV != "" {
|
||||
@@ -806,16 +944,25 @@ func maxInt(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
// SetupRoutes registers the agent routes
|
||||
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
|
||||
// SetupPublicRoutes registers token-authenticated agent ingestion routes.
|
||||
func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
|
||||
agents := router.Group("/agents")
|
||||
{
|
||||
agents.POST("/register", h.RegisterAgent)
|
||||
agents.POST("/heartbeat", h.SendHeartbeat)
|
||||
agents.GET("/:id/commands", h.GetPendingCommandsForAgent)
|
||||
agents.POST("/:id/commands/:commandId/result", h.CompleteCommand)
|
||||
}
|
||||
}
|
||||
|
||||
// SetupRoutes registers authenticated dashboard/control agent routes.
|
||||
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
|
||||
agents := router.Group("/agents")
|
||||
{
|
||||
agents.GET("", h.GetAgents)
|
||||
agents.GET("/:id", h.GetAgent)
|
||||
agents.PUT("/:id", h.UpdateAgent)
|
||||
agents.DELETE("/:id", h.DeleteAgent)
|
||||
agents.POST("/heartbeat", h.SendHeartbeat)
|
||||
|
||||
agents.GET("/:id/containers", h.GetAgentContainers)
|
||||
agents.POST("/:id/containers", h.CreateContainer)
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"containr/internal/database"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -18,10 +26,16 @@ type LoginRequest struct {
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
Name string `json:"name" binding:"required,min=2"`
|
||||
}
|
||||
|
||||
type ManualUserCreateRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
Name string `json:"name" binding:"required,min=2,max=255"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
User interface{} `json:"user"`
|
||||
@@ -55,17 +69,28 @@ func handleLogin(c *gin.Context) {
|
||||
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err = createLocalUserFromBetterAuth(db, authUser)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create local user"})
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
} else {
|
||||
// Check password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil {
|
||||
if _, ok := verifyBetterAuthCredentials(c, req.Email, req.Password); !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
@@ -81,6 +106,154 @@ func handleLogin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func handleAuthBootstrap(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
count, err := countLocalUsers(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to inspect auth bootstrap state"})
|
||||
return
|
||||
}
|
||||
|
||||
mode := "login"
|
||||
if count == 0 {
|
||||
mode = "register"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"has_users": count > 0,
|
||||
"user_count": count,
|
||||
"mode": mode,
|
||||
})
|
||||
}
|
||||
|
||||
type betterAuthLoginUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
type betterAuthLoginResponse struct {
|
||||
User betterAuthLoginUser `json:"user"`
|
||||
}
|
||||
|
||||
func verifyBetterAuthCredentials(c *gin.Context, email string, password string) (betterAuthLoginUser, bool) {
|
||||
endpoint, err := betterAuthSignInURL()
|
||||
if err != nil {
|
||||
log.Printf("Better Auth fallback disabled: %v", err)
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
body, err := json.Marshal(gin.H{
|
||||
"email": strings.TrimSpace(email),
|
||||
"password": password,
|
||||
})
|
||||
if err != nil {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
log.Printf("Better Auth fallback failed: %v", err)
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
var payload betterAuthLoginResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
log.Printf("Better Auth fallback response decode failed: %v", err)
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
payload.User.Email = strings.ToLower(strings.TrimSpace(firstNonEmpty(payload.User.Email, email)))
|
||||
payload.User.Name = strings.TrimSpace(payload.User.Name)
|
||||
if payload.User.Name == "" {
|
||||
payload.User.Name = "Containr User"
|
||||
}
|
||||
|
||||
if payload.User.Email == "" {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
return payload.User, true
|
||||
}
|
||||
|
||||
func betterAuthSignInURL() (string, error) {
|
||||
rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL"))
|
||||
if rawTarget == "" {
|
||||
rawTarget = "http://127.0.0.1:3001"
|
||||
}
|
||||
|
||||
target, err := parseAuthProxyTarget(rawTarget)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return target.ResolveReference(&url.URL{Path: "/api/auth/sign-in/email"}).String(), nil
|
||||
}
|
||||
|
||||
func betterAuthSignUpURL() (string, error) {
|
||||
rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL"))
|
||||
if rawTarget == "" {
|
||||
rawTarget = "http://127.0.0.1:3001"
|
||||
}
|
||||
|
||||
target, err := parseAuthProxyTarget(rawTarget)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return target.ResolveReference(&url.URL{Path: "/api/auth/sign-up/email"}).String(), nil
|
||||
}
|
||||
|
||||
func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser) (User, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(authUser.Email))
|
||||
if email == "" {
|
||||
return User{}, errors.New("email is required")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(authUser.Name)
|
||||
if name == "" {
|
||||
name = "Containr User"
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(uuid.NewString()), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
var user User
|
||||
err = db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name, avatar_url)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''))
|
||||
ON CONFLICT (email) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
||||
updated_at = NOW()
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)).
|
||||
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func handleRegister(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -91,9 +264,19 @@ func handleRegister(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jwtSecret := c.MustGet("jwt_secret").(string)
|
||||
|
||||
// Check if user already exists
|
||||
var count int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&count)
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||
return
|
||||
}
|
||||
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
@@ -137,6 +320,121 @@ func handleRegister(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func handleCreateUser(c *gin.Context) {
|
||||
var req ManualUserCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Email == "" || req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Email and name are required"})
|
||||
return
|
||||
}
|
||||
|
||||
var existing int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&existing); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
if existing > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := createBetterAuthUser(c, req.Name, req.Email, req.Password); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, errBetterAuthUserExists) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||
return
|
||||
}
|
||||
|
||||
var user User
|
||||
err = db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"user": user})
|
||||
}
|
||||
|
||||
var errBetterAuthUserExists = errors.New("User already exists")
|
||||
|
||||
func createBetterAuthUser(c *gin.Context, name string, email string, password string) error {
|
||||
endpoint, err := betterAuthSignUpURL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := json.Marshal(gin.H{
|
||||
"name": name,
|
||||
"email": email,
|
||||
"password": password,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if token := strings.TrimSpace(os.Getenv("BETTER_AUTH_INTERNAL_TOKEN")); token != "" {
|
||||
request.Header.Set("X-Containr-Auth-Internal", token)
|
||||
}
|
||||
|
||||
response, err := (&http.Client{Timeout: 5 * time.Second}).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
|
||||
return nil
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
_ = json.NewDecoder(response.Body).Decode(&payload)
|
||||
message := "Failed to create auth user"
|
||||
if rawMessage, ok := payload["message"].(string); ok && strings.TrimSpace(rawMessage) != "" {
|
||||
message = rawMessage
|
||||
} else if rawError, ok := payload["error"].(string); ok && strings.TrimSpace(rawError) != "" {
|
||||
message = rawError
|
||||
}
|
||||
|
||||
if response.StatusCode == http.StatusConflict || strings.Contains(strings.ToLower(message), "already") {
|
||||
return errBetterAuthUserExists
|
||||
}
|
||||
|
||||
return errors.New(message)
|
||||
}
|
||||
|
||||
func countLocalUsers(db *database.DB) (int, error) {
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func handleGetProfile(c *gin.Context) {
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config, db *database.DB) {
|
||||
targetURL, err := parseAuthProxyTarget(cfg.BetterAuthProxyURL)
|
||||
if err != nil {
|
||||
log.Printf("Warning: auth proxy disabled: %v", err)
|
||||
@@ -28,6 +29,12 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
}
|
||||
|
||||
handler := func(c *gin.Context) {
|
||||
if shouldBlockPublicBetterAuthSignup(c, cfg, db) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
proxy.ServeHTTP(c.Writer, c.Request)
|
||||
c.Abort()
|
||||
}
|
||||
@@ -36,6 +43,33 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
router.Any("/api/auth/*proxyPath", handler)
|
||||
}
|
||||
|
||||
func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *database.DB) bool {
|
||||
if db == nil || db.DB == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !strings.EqualFold(c.Request.Method, http.MethodPost) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(c.Request.URL.Path, "/sign-up/email") {
|
||||
return false
|
||||
}
|
||||
|
||||
internalToken := strings.TrimSpace(cfg.BetterAuthInternalToken)
|
||||
if internalToken != "" && c.GetHeader("X-Containr-Auth-Internal") == internalToken {
|
||||
return false
|
||||
}
|
||||
|
||||
count, err := countLocalUsers(db)
|
||||
if err != nil {
|
||||
log.Printf("Failed to inspect signup bootstrap state: %v", err)
|
||||
return true
|
||||
}
|
||||
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func parseAuthProxyTarget(rawTarget string) (*url.URL, error) {
|
||||
trimmed := strings.TrimSpace(rawTarget)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -1306,3 +1306,208 @@ func generateWebhookSecret() (string, error) {
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// handleDeleteGitProvider removes a provider connection. Repositories and
|
||||
// webhooks are removed by ON DELETE CASCADE.
|
||||
func handleDeleteGitProvider(c *gin.Context) {
|
||||
ctx, ok := requireGitRequestContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
providerID := strings.TrimSpace(c.Param("providerId"))
|
||||
if _, err := uuid.Parse(providerID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := ctx.db.Exec(
|
||||
"DELETE FROM git_providers WHERE id = $1 AND user_id = $2",
|
||||
providerID, ctx.userID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
|
||||
return
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Provider disconnected"})
|
||||
}
|
||||
|
||||
// handleGetGitRepositoryBranches lists branches for a repository through the
|
||||
// provider's stored credentials.
|
||||
func handleGetGitRepositoryBranches(c *gin.Context) {
|
||||
ctx, ok := requireGitRequestContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
providerID := strings.TrimSpace(c.Param("providerId"))
|
||||
if _, err := uuid.Parse(providerID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
|
||||
return
|
||||
}
|
||||
|
||||
owner := strings.TrimSpace(c.Param("owner"))
|
||||
repo := strings.TrimSpace(c.Param("repo"))
|
||||
if owner == "" || repo == "" || strings.ContainsAny(owner+repo, " \t\r\n") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository reference"})
|
||||
return
|
||||
}
|
||||
|
||||
var providerName, providerAPIURL, providerAccessToken string
|
||||
err := ctx.db.QueryRow(`
|
||||
SELECT name, api_url, access_token
|
||||
FROM git_providers
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`, providerID, ctx.userID).Scan(&providerName, &providerAPIURL, &providerAccessToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
branches, err := fetchRepositoryBranches(providerName, providerAPIURL, providerAccessToken, owner, repo)
|
||||
if err != nil {
|
||||
log.Printf("branch fetch failed for %s/%s via %s: %v", owner, repo, providerName, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch branches from provider"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"branches": branches})
|
||||
}
|
||||
|
||||
type remoteBranch struct {
|
||||
Name string `json:"name"`
|
||||
Protected bool `json:"protected"`
|
||||
}
|
||||
|
||||
func fetchRepositoryBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(providerName)) {
|
||||
case "github", "github_app":
|
||||
return fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo)
|
||||
case "gitea":
|
||||
return fetchGiteaBranches(providerAPIURL, accessToken, owner, repo)
|
||||
default:
|
||||
return nil, fmt.Errorf("branch listing is not supported for provider %s", providerName)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||
baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.github.com"
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(accessToken)
|
||||
if strings.EqualFold(providerName, "github_app") {
|
||||
installationID, err := strconv.ParseInt(token, 10, 64)
|
||||
if err != nil || installationID <= 0 {
|
||||
return nil, fmt.Errorf("github_app installation id is invalid")
|
||||
}
|
||||
token, err = getGitHubAppInstallationToken(baseURL, installationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("github access token is missing")
|
||||
}
|
||||
|
||||
branches := make([]remoteBranch, 0)
|
||||
for page := 1; page <= 5; page++ {
|
||||
request, err := http.NewRequest(http.MethodGet,
|
||||
fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100&page=%d", baseURL, owner, repo, page), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/vnd.github+json")
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
||||
response.Body.Close()
|
||||
return nil, fmt.Errorf("github branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
||||
}
|
||||
|
||||
var rows []map[string]interface{}
|
||||
decodeErr := json.NewDecoder(response.Body).Decode(&rows)
|
||||
response.Body.Close()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
name := asGitString(row["name"])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])})
|
||||
}
|
||||
|
||||
if len(rows) < 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
func fetchGiteaBranches(providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||
baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimSuffix(strings.TrimSpace(getenvOrDefault("GITEA_BASE_URL", "")), "/")
|
||||
}
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("gitea api_url is required")
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet,
|
||||
fmt.Sprintf("%s/api/v1/repos/%s/%s/branches?limit=100", baseURL, owner, repo), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if token := strings.TrimSpace(accessToken); token != "" {
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
||||
return nil, fmt.Errorf("gitea branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
||||
}
|
||||
|
||||
var rows []map[string]interface{}
|
||||
if err := json.NewDecoder(response.Body).Decode(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
branches := make([]remoteBranch, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if name := asGitString(row["name"]); name != "" {
|
||||
branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])})
|
||||
}
|
||||
}
|
||||
return branches, nil
|
||||
}
|
||||
@@ -64,11 +64,15 @@ func handleGetLogs(c *gin.Context) {
|
||||
|
||||
dockerClient, exists := c.Get("docker_client")
|
||||
if !exists || dockerClient == nil {
|
||||
respondDependencyUnavailable(c, "docker", "Container log streaming is unavailable because Docker is not configured.")
|
||||
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
||||
return
|
||||
}
|
||||
|
||||
client := dockerClient.(*docker.Client)
|
||||
client, ok := dockerClient.(*docker.Client)
|
||||
if !ok || client == nil {
|
||||
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
||||
return
|
||||
}
|
||||
containerName := fmt.Sprintf("containr-%s", serviceID)
|
||||
|
||||
logOpts := docker.LogOptions{
|
||||
@@ -82,6 +86,10 @@ func handleGetLogs(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
|
||||
if err != nil {
|
||||
if isDockerNotFoundError(err) {
|
||||
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
|
||||
return
|
||||
}
|
||||
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
|
||||
return
|
||||
}
|
||||
@@ -156,6 +164,13 @@ func handleGetLogs(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logEntries})
|
||||
}
|
||||
|
||||
func respondEmptyServiceLogs(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": []LogEntry{},
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetDeploymentLogs(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/docker"
|
||||
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ServiceMetricsResponse is the aggregated live telemetry for a service,
|
||||
// sourced from Docker container stats for containers named
|
||||
// containr-<serviceID>-<deploymentID>-<index>.
|
||||
type ServiceMetricsResponse struct {
|
||||
ServiceID string `json:"service_id"`
|
||||
Status string `json:"status"` // ok, no_containers, docker_unavailable
|
||||
Instances []ServiceInstanceMetrics `json:"instances"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
||||
MemoryLimitBytes uint64 `json:"memory_limit_bytes"`
|
||||
NetworkRxBytes uint64 `json:"network_rx_bytes"`
|
||||
NetworkTxBytes uint64 `json:"network_tx_bytes"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
// ServiceInstanceMetrics holds live stats for one running container.
|
||||
type ServiceInstanceMetrics struct {
|
||||
ContainerID string `json:"container_id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
||||
MemoryLimitBytes uint64 `json:"memory_limit_bytes"`
|
||||
NetworkRxBytes uint64 `json:"network_rx_bytes"`
|
||||
NetworkTxBytes uint64 `json:"network_tx_bytes"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
// handleGetServiceMetrics returns real resource usage for the service's
|
||||
// Docker containers. Empty result when the service has never deployed.
|
||||
func handleGetServiceMetrics(c *gin.Context) {
|
||||
dbValue, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
db := dbValue.(*database.DB)
|
||||
|
||||
serviceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerID string
|
||||
var status string
|
||||
err = db.QueryRow(
|
||||
`SELECT p.owner_id, COALESCE(s.status, '')
|
||||
FROM services s JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&ownerID, &status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
if ownerID != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
response := ServiceMetricsResponse{
|
||||
ServiceID: serviceID.String(),
|
||||
Status: "ok",
|
||||
Instances: []ServiceInstanceMetrics{},
|
||||
CollectedAt: time.Now(),
|
||||
}
|
||||
|
||||
dockerValue, exists := c.Get("docker_client")
|
||||
if !exists || dockerValue == nil {
|
||||
response.Status = "docker_unavailable"
|
||||
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||
return
|
||||
}
|
||||
dockerClient := dockerValue.(*docker.Client)
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
containers, err := dockerClient.ListContainers(ctx, false)
|
||||
if err != nil {
|
||||
response.Status = "docker_unavailable"
|
||||
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||
return
|
||||
}
|
||||
|
||||
namePrefix := fmt.Sprintf("containr-%s-", serviceID.String())
|
||||
matched := 0
|
||||
for _, ctr := range containers {
|
||||
name := ""
|
||||
for _, n := range ctr.Names {
|
||||
name = strings.TrimPrefix(n, "/")
|
||||
if strings.HasPrefix(name, namePrefix) {
|
||||
break
|
||||
}
|
||||
name = ""
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
|
||||
instance := ServiceInstanceMetrics{
|
||||
ContainerID: ctr.ID,
|
||||
Name: name,
|
||||
State: ctr.State,
|
||||
}
|
||||
|
||||
if info, err := dockerClient.GetContainer(ctx, ctr.ID); err == nil {
|
||||
if info.State != nil && info.State.StartedAt != "" {
|
||||
if parsed, perr := time.Parse(time.RFC3339Nano, info.State.StartedAt); perr == nil {
|
||||
instance.StartedAt = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ctr.State == "running" {
|
||||
if stats, err := readContainerStats(ctx, dockerClient, ctr.ID); err == nil {
|
||||
instance.CPUPercent = stats.cpuPercent
|
||||
instance.MemoryUsageBytes = stats.memoryUsage
|
||||
instance.MemoryLimitBytes = stats.memoryLimit
|
||||
instance.NetworkRxBytes = stats.networkRx
|
||||
instance.NetworkTxBytes = stats.networkTx
|
||||
}
|
||||
}
|
||||
|
||||
response.Instances = append(response.Instances, instance)
|
||||
response.CPUPercent += instance.CPUPercent
|
||||
response.MemoryUsageBytes += instance.MemoryUsageBytes
|
||||
response.MemoryLimitBytes += instance.MemoryLimitBytes
|
||||
response.NetworkRxBytes += instance.NetworkRxBytes
|
||||
response.NetworkTxBytes += instance.NetworkTxBytes
|
||||
}
|
||||
|
||||
if matched == 0 {
|
||||
response.Status = "no_containers"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||
}
|
||||
|
||||
type containerStatsSnapshot struct {
|
||||
cpuPercent float64
|
||||
memoryUsage uint64
|
||||
memoryLimit uint64
|
||||
networkRx uint64
|
||||
networkTx uint64
|
||||
}
|
||||
|
||||
func readContainerStats(ctx context.Context, client *docker.Client, containerID string) (*containerStatsSnapshot, error) {
|
||||
reader, err := client.GetContainerStats(ctx, containerID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Body.Close()
|
||||
|
||||
var stats containertypes.StatsResponse
|
||||
if err := json.NewDecoder(reader.Body).Decode(&stats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snapshot := &containerStatsSnapshot{
|
||||
memoryUsage: stats.MemoryStats.Usage,
|
||||
memoryLimit: stats.MemoryStats.Limit,
|
||||
}
|
||||
|
||||
cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage) - float64(stats.PreCPUStats.CPUUsage.TotalUsage)
|
||||
systemDelta := float64(stats.CPUStats.SystemUsage) - float64(stats.PreCPUStats.SystemUsage)
|
||||
onlineCPUs := float64(stats.CPUStats.OnlineCPUs)
|
||||
if onlineCPUs == 0 {
|
||||
onlineCPUs = float64(len(stats.CPUStats.CPUUsage.PercpuUsage))
|
||||
}
|
||||
if cpuDelta > 0 && systemDelta > 0 && onlineCPUs > 0 {
|
||||
snapshot.cpuPercent = (cpuDelta / systemDelta) * onlineCPUs * 100
|
||||
}
|
||||
|
||||
for _, net := range stats.Networks {
|
||||
snapshot.networkRx += net.RxBytes
|
||||
snapshot.networkTx += net.TxBytes
|
||||
}
|
||||
|
||||
return snapshot, nil
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
|
||||
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
||||
// Expose Better Auth through backend so frontend can use a single backend origin.
|
||||
setupAuthProxyRoutes(router, cfg)
|
||||
setupAuthProxyRoutes(router, cfg, db)
|
||||
|
||||
// Initialize Docker client (non-fatal if it fails)
|
||||
var dockerClient *docker.Client
|
||||
@@ -158,11 +158,15 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
router.HEAD("/ready", healthHandler)
|
||||
|
||||
// API v1 routes
|
||||
publicAgents := router.Group("/api")
|
||||
agentHandler.SetupPublicRoutes(publicAgents)
|
||||
|
||||
v1 := router.Group("/api/v1")
|
||||
{
|
||||
// Public routes (no authentication required)
|
||||
public := v1.Group("/")
|
||||
{
|
||||
public.GET("/auth/bootstrap", handleAuthBootstrap)
|
||||
public.POST("/auth/login", handleLogin)
|
||||
public.POST("/auth/register", handleRegister)
|
||||
}
|
||||
@@ -174,6 +178,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// User routes
|
||||
protected.GET("/user/profile", handleGetProfile)
|
||||
protected.PUT("/user/profile", handleUpdateProfile)
|
||||
protected.POST("/users", handleCreateUser)
|
||||
|
||||
// Project routes
|
||||
protected.GET("/projects", handleGetProjects)
|
||||
@@ -192,6 +197,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.GET("/services/:id", handleGetService)
|
||||
protected.PUT("/services/:id", handleUpdateService)
|
||||
protected.DELETE("/services/:id", handleDeleteService)
|
||||
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
|
||||
|
||||
// Deployment routes
|
||||
protected.GET("/services/:id/deployments", handleGetDeployments)
|
||||
@@ -212,7 +218,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.POST("/git/github-app/connect", handleConnectGitHubApp)
|
||||
protected.GET("/git/providers", handleGetGitProviders)
|
||||
protected.POST("/git/providers", handleCreateGitProvider)
|
||||
protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
|
||||
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
|
||||
protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
|
||||
protected.POST("/git/repositories/connect", handleConnectGitRepository)
|
||||
protected.GET("/git/repositories", handleGetConnectedRepositories)
|
||||
protected.POST("/git/webhooks", handleCreateWebhook)
|
||||
@@ -226,6 +234,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.POST("/builds/plan", buildHandler.GetBuildPlan)
|
||||
protected.GET("/builds/detect", buildHandler.DetectBuildType)
|
||||
|
||||
// System routes
|
||||
protected.GET("/system/upgrade/status", handleGetUpgradeStatus)
|
||||
protected.POST("/system/upgrade/pull", handlePullUpgradeImage)
|
||||
protected.GET("/system/host", handleGetHostMonitoring)
|
||||
|
||||
// Scaling routes
|
||||
scalingHandler.RegisterRoutes(protected)
|
||||
haAPIManager.RegisterRoutes(protected)
|
||||
@@ -241,9 +254,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
||||
|
||||
// Node Agent routes
|
||||
api := router.Group("/api")
|
||||
api.Use(middleware.Auth(cfg.JWTSecret))
|
||||
agentHandler.SetupRoutes(api)
|
||||
agentHandler.SetupRoutes(protected)
|
||||
|
||||
// Preview Environments routes
|
||||
protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
|
||||
|
||||
@@ -99,8 +99,18 @@ func handleGetServices(c *gin.Context) {
|
||||
|
||||
// Get services for the project
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT id, project_id, name, type, status, image, command, environment,
|
||||
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at
|
||||
`SELECT id, project_id, name,
|
||||
COALESCE(type, service_type, ''),
|
||||
COALESCE(status, ''),
|
||||
COALESCE(image, image_name, ''),
|
||||
COALESCE(command, start_command, ''),
|
||||
COALESCE(environment, ''),
|
||||
COALESCE(git_repo, source_url, ''),
|
||||
COALESCE(git_branch, ''),
|
||||
COALESCE(build_path, ''),
|
||||
COALESCE(cpu, ''),
|
||||
COALESCE(memory, ''),
|
||||
created_at, updated_at
|
||||
FROM services
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
@@ -228,15 +238,25 @@ func handleCreateService(c *gin.Context) {
|
||||
service.Memory = "512Mi"
|
||||
}
|
||||
|
||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"})
|
||||
return
|
||||
}
|
||||
|
||||
sourceType := inferServiceSourceType(service)
|
||||
|
||||
// Insert service into database
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO services
|
||||
(id, project_id, name, type, status, image, command, environment,
|
||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||
build_command, start_command, type, status, image, command, environment,
|
||||
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
|
||||
service.ID, service.ProjectID, service.Name, service.Type, service.Status,
|
||||
service.Image, service.Command, service.Environment, service.GitRepo,
|
||||
service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`,
|
||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
service.CreatedAt, service.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -248,6 +268,26 @@ func handleCreateService(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{"service": service})
|
||||
}
|
||||
|
||||
func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) {
|
||||
var environmentID uuid.UUID
|
||||
err := db.QueryRow(
|
||||
"SELECT id FROM environments WHERE project_id = $1 AND name = $2",
|
||||
projectID,
|
||||
environment,
|
||||
).Scan(&environmentID)
|
||||
return environmentID, err
|
||||
}
|
||||
|
||||
func inferServiceSourceType(service Service) string {
|
||||
if service.GitRepo != "" {
|
||||
return "github"
|
||||
}
|
||||
if service.Image != "" {
|
||||
return "image"
|
||||
}
|
||||
return "dockerfile"
|
||||
}
|
||||
|
||||
// handleGetService retrieves a specific service
|
||||
func handleGetService(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
@@ -273,8 +313,17 @@ func handleGetService(c *gin.Context) {
|
||||
// Get service with project ownership check
|
||||
var service Service
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''),
|
||||
COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''),
|
||||
COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''),
|
||||
COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''),
|
||||
COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''),
|
||||
COALESCE(s.memory, ''),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
@@ -326,8 +375,17 @@ func handleUpdateService(c *gin.Context) {
|
||||
// Check if service exists and user has access
|
||||
var existingService Service
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''),
|
||||
COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''),
|
||||
COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''),
|
||||
COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''),
|
||||
COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''),
|
||||
COALESCE(s.memory, ''),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"containr/internal/docker"
|
||||
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type upgradeStatusResponse struct {
|
||||
ImageRef string `json:"image_ref"`
|
||||
Registry string `json:"registry"`
|
||||
DockerAvailable bool `json:"docker_available"`
|
||||
Installed bool `json:"installed"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
AuthConfigured bool `json:"auth_configured"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type hostMonitoringResponse struct {
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Architecture string `json:"architecture"`
|
||||
CPU hostCPU `json:"cpu"`
|
||||
Memory hostMemory `json:"memory"`
|
||||
Storage hostStorage `json:"storage"`
|
||||
Load SystemLoad `json:"load"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
DockerAvailable bool `json:"docker_available"`
|
||||
Docker map[string]any `json:"docker,omitempty"`
|
||||
CollectedAt string `json:"collected_at"`
|
||||
}
|
||||
|
||||
type hostCPU struct {
|
||||
Cores int `json:"cores"`
|
||||
}
|
||||
|
||||
type hostMemory struct {
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
Available uint64 `json:"available"`
|
||||
UsagePct float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
type hostStorage struct {
|
||||
Path string `json:"path"`
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
Available uint64 `json:"available"`
|
||||
UsagePct float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
func handleGetUpgradeStatus(c *gin.Context) {
|
||||
imageRef := configuredUpgradeImageRef()
|
||||
dockerClient, _ := c.Get("docker_client")
|
||||
client, _ := dockerClient.(*docker.Client)
|
||||
|
||||
response := upgradeStatusResponse{
|
||||
ImageRef: imageRef,
|
||||
Registry: registryHost(imageRef),
|
||||
DockerAvailable: client != nil,
|
||||
AuthConfigured: upgradeRegistryAuthConfigured(),
|
||||
Message: "Upgrade image auto-detected",
|
||||
}
|
||||
|
||||
if imageRef == "" {
|
||||
response.Message = "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF to enable image pulls"
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
response.Message = "Docker client unavailable"
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
info, err := client.GetImageInfo(ctx, imageRef)
|
||||
if err != nil {
|
||||
response.Message = "Image not installed locally"
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
response.Installed = true
|
||||
response.Digest = info.Digest
|
||||
response.Size = info.Size
|
||||
response.Message = "Image installed locally"
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func handlePullUpgradeImage(c *gin.Context) {
|
||||
imageRef := configuredUpgradeImageRef()
|
||||
if imageRef == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF before pulling"})
|
||||
return
|
||||
}
|
||||
|
||||
dockerClient, _ := c.Get("docker_client")
|
||||
client, ok := dockerClient.(*docker.Client)
|
||||
if !ok || client == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker client unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
reader, err := client.PullImage(ctx, imageRef, upgradeRegistryAuth())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
_, _ = io.Copy(io.Discard, reader)
|
||||
|
||||
info, infoErr := client.GetImageInfo(ctx, imageRef)
|
||||
response := gin.H{
|
||||
"image_ref": imageRef,
|
||||
"registry": registryHost(imageRef),
|
||||
"message": "Image pull completed",
|
||||
}
|
||||
if infoErr == nil {
|
||||
response["digest"] = info.Digest
|
||||
response["size"] = info.Size
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func handleGetHostMonitoring(c *gin.Context) {
|
||||
hostname, _ := os.Hostname()
|
||||
memory := readHostMemory()
|
||||
storage := readHostStorage("/")
|
||||
load := readSystemLoad()
|
||||
uptime := readSystemUptime()
|
||||
|
||||
response := hostMonitoringResponse{
|
||||
Hostname: hostname,
|
||||
OS: runtime.GOOS,
|
||||
Architecture: runtime.GOARCH,
|
||||
CPU: hostCPU{Cores: runtime.NumCPU()},
|
||||
Memory: memory,
|
||||
Storage: storage,
|
||||
Load: load,
|
||||
UptimeSeconds: uptime,
|
||||
CollectedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if dockerClient, ok := c.Get("docker_client"); ok {
|
||||
if client, ok := dockerClient.(*docker.Client); ok && client != nil {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
if info, err := client.GetSystemInfo(ctx); err == nil {
|
||||
response.DockerAvailable = true
|
||||
response.Docker = map[string]any{
|
||||
"containers": info.Containers,
|
||||
"images": info.Images,
|
||||
"driver": info.Driver,
|
||||
"server": info.ServerVersion,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func configuredUpgradeImageRef() string {
|
||||
for _, key := range []string{"CONTAINR_UPGRADE_IMAGE_REF", "CONTAINR_IMAGE_REF", "DOCKER_IMAGE_REF"} {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func registryHost(imageRef string) string {
|
||||
parts := strings.Split(imageRef, "/")
|
||||
if len(parts) > 1 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
|
||||
return parts[0]
|
||||
}
|
||||
if strings.HasPrefix(imageRef, "ghcr.io/") {
|
||||
return "ghcr.io"
|
||||
}
|
||||
return "docker.io"
|
||||
}
|
||||
|
||||
func upgradeRegistryAuthConfigured() bool {
|
||||
auth := upgradeRegistryAuth()
|
||||
return strings.TrimSpace(auth.Username) != "" || strings.TrimSpace(auth.Password) != "" || strings.TrimSpace(auth.Auth) != ""
|
||||
}
|
||||
|
||||
func upgradeRegistryAuth() registry.AuthConfig {
|
||||
username := firstConfigured("GITHUB_CONTAINER_REGISTRY_USER", "GHCR_USERNAME", "DOCKER_REGISTRY_USERNAME")
|
||||
password := firstConfigured("GITHUB_CONTAINER_REGISTRY_TOKEN", "GHCR_TOKEN", "DOCKER_REGISTRY_PASSWORD")
|
||||
return registry.AuthConfig{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
|
||||
func firstConfigured(keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readHostMemory() hostMemory {
|
||||
values := map[string]uint64{}
|
||||
file, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return hostMemory{}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSuffix(fields[0], ":")
|
||||
value, parseErr := strconv.ParseUint(fields[1], 10, 64)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
values[key] = value * 1024
|
||||
}
|
||||
|
||||
total := values["MemTotal"]
|
||||
available := values["MemAvailable"]
|
||||
used := uint64(0)
|
||||
if total > available {
|
||||
used = total - available
|
||||
}
|
||||
|
||||
usage := 0.0
|
||||
if total > 0 {
|
||||
usage = float64(used) / float64(total) * 100
|
||||
}
|
||||
|
||||
return hostMemory{
|
||||
Total: total,
|
||||
Used: used,
|
||||
Available: available,
|
||||
UsagePct: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func readHostStorage(path string) hostStorage {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return hostStorage{Path: path}
|
||||
}
|
||||
|
||||
total := stat.Blocks * uint64(stat.Bsize)
|
||||
available := stat.Bavail * uint64(stat.Bsize)
|
||||
used := total - available
|
||||
usage := 0.0
|
||||
if total > 0 {
|
||||
usage = float64(used) / float64(total) * 100
|
||||
}
|
||||
|
||||
return hostStorage{
|
||||
Path: path,
|
||||
Total: total,
|
||||
Used: used,
|
||||
Available: available,
|
||||
UsagePct: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func readSystemLoad() SystemLoad {
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return SystemLoad{}
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return SystemLoad{}
|
||||
}
|
||||
load1, _ := strconv.ParseFloat(fields[0], 64)
|
||||
load5, _ := strconv.ParseFloat(fields[1], 64)
|
||||
load15, _ := strconv.ParseFloat(fields[2], 64)
|
||||
return SystemLoad{Load1M: load1, Load5M: load5, Load15M: load15}
|
||||
}
|
||||
|
||||
func readSystemUptime() int64 {
|
||||
data, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
uptime, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(uptime)
|
||||
}
|
||||
@@ -279,9 +279,16 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
|
||||
serviceImage := resolveTemplateRuntimeImage(config.Runtime)
|
||||
serviceCommand := strings.TrimSpace(config.StartCommand)
|
||||
buildCommand := strings.TrimSpace(config.BuildCommand)
|
||||
cpu, memory := defaultTemplateResources(serviceType)
|
||||
serviceEnvironment := "production"
|
||||
|
||||
environmentID, err := getProjectEnvironmentID(db, projectID, serviceEnvironment)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"})
|
||||
return
|
||||
}
|
||||
|
||||
serviceID := uuid.New()
|
||||
now := time.Now()
|
||||
|
||||
@@ -294,18 +301,24 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
|
||||
txQueries := queries.WithTx(tx)
|
||||
err = txQueries.CreateServiceFromTemplate(ctx, sqlcdb.CreateServiceFromTemplateParams{
|
||||
ID: serviceID,
|
||||
ProjectID: projectID,
|
||||
Name: req.Name,
|
||||
Type: serviceType,
|
||||
Status: "stopped",
|
||||
Image: sql.NullString{String: serviceImage, Valid: true},
|
||||
Command: sql.NullString{String: serviceCommand, Valid: true},
|
||||
Environment: sql.NullString{String: serviceEnvironment, Valid: true},
|
||||
Cpu: sql.NullString{String: cpu, Valid: true},
|
||||
Memory: sql.NullString{String: memory, Valid: true},
|
||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
ID: serviceID,
|
||||
ProjectID: projectID,
|
||||
Name: req.Name,
|
||||
EnvironmentID: environmentID,
|
||||
ServiceType: serviceType,
|
||||
SourceType: "template",
|
||||
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",
|
||||
Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
||||
Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
||||
Environment: sql.NullString{String: serviceEnvironment, Valid: true},
|
||||
Cpu: sql.NullString{String: cpu, Valid: true},
|
||||
Memory: sql.NullString{String: memory, Valid: true},
|
||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
|
||||
|
||||
Reference in new issue
Block a user