Files
Containr/app/backend/internal/api/system.go
Tomas DvorakandDevin 7839dc48a3 feat(deploy): per-service deploy queue, cancellable deployments, no-cache builds
- internal/deployqueue: serialized FIFO per service — concurrent deploys
  of one service no longer race (the container-name-conflict failure
  mode); different services still run in parallel.
- Deploys and rollbacks enqueue through the queue; a busy service
  returns status 'queued' and promotes when the active job finishes.
- POST /deployments/:id/cancel — queued jobs drop immediately
  (200 cancelled); active jobs get ctx cancelled so build/reconcile
  abort (202 cancelling, settles 'cancelled'); finished deployments
  return 409 NOT_CANCELLABLE.
- Redeploy and variables save now route through the same queue so they
  can't overlap a build; PUT /services/:id/variables accepts
  redeploy:true to apply env changes to running containers.
- no_cache on deployment create plumbs BuildRequest -> BuildOptions for
  nixpacks, dockerfile and railpack builders.
- CLI: deployments cancel, deploy --no-cache, variables set/unset
  --redeploy. MCP: containr_deployments_cancel; deploy/variables_set
  document no_cache/redeploy. UI: Cancel button + queued/cancelled
  badges on the deployments table. OpenAPI + types regenerated.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-08 16:23:28 +02:00

330 lines
8.4 KiB
Go

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"`
// Scope names what the telemetry actually measures: the physical host for
// native dockerd, or the Docker Desktop VM when the daemon runs in one.
Scope string `json:"scope"`
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,
Scope: "host",
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,
"os": info.OperatingSystem,
}
if strings.Contains(strings.ToLower(info.OperatingSystem), "docker desktop") {
response.Scope = "docker-desktop-vm"
}
}
}
}
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)
}