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:
Tomas DvorakandDevin committed 2026-09-15 18:59:40 +02:00
1 parent 355a97bab4
commit 9f3b196032
499 files changed
+4471 -94235

No files matched your search

+155 -8
View File
@@ -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)
+309 -11
View File
@@ -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)
+35 -1
View File
@@ -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 == "" {
+205
View File
@@ -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
}
+17 -2
View File
@@ -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 {
+207
View File
@@ -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
}
+15 -4
View File
@@ -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)
+69 -11
View File
@@ -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
+321
View File
@@ -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)
}
+25 -12
View File
@@ -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"})