mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Merge pull request #19 from Dvorinka/feat/deploy-queue
feat(deploy): pipeline maturity — queue, cancel, no-cache, secrets at rest
This commit is contained in:
29 files changed
+963
-40
No files matched your search
@@ -28,6 +28,7 @@ Dev stack: Postgres on `:15432` (`containr-postgres-1`), Dragonfly on `:16379` (
|
|||||||
## Conventions that matter
|
## Conventions that matter
|
||||||
|
|
||||||
- New endpoints go in the right route group; mutations on `authed`/`admin` get idempotency for free.
|
- New endpoints go in the right route group; mutations on `authed`/`admin` get idempotency for free.
|
||||||
|
- Deploy work goes through `deploy_queue` (`internal/deployqueue`) — per-service FIFO; enqueue jobs instead of `go`-ing deployment/reconcile goroutines so they never race a build.
|
||||||
- New list endpoints use `pageWindow`/`paginationMeta` (`internal/api/pagination.go`) — cursor + page, `next_cursor` in the envelope.
|
- New list endpoints use `pageWindow`/`paginationMeta` (`internal/api/pagination.go`) — cursor + page, `next_cursor` in the envelope.
|
||||||
- New errors use stable codes via `respondError(c, status, code, msg)` in `internal/api/errors.go`.
|
- New errors use stable codes via `respondError(c, status, code, msg)` in `internal/api/errors.go`.
|
||||||
- New CLI command → also register an MCP tool in `internal/mcpserver/server.go` and document in OpenAPI. All three surfaces ship together.
|
- New CLI command → also register an MCP tool in `internal/mcpserver/server.go` and document in OpenAPI. All three surfaces ship together.
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package api
|
|||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -34,6 +36,7 @@ type CreateDeploymentRequest struct {
|
|||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Trigger string `json:"trigger"`
|
Trigger string `json:"trigger"`
|
||||||
EnvVars map[string]string `json:"env_vars"`
|
EnvVars map[string]string `json:"env_vars"`
|
||||||
|
NoCache bool `json:"no_cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeploymentResponse struct {
|
type DeploymentResponse struct {
|
||||||
@@ -309,7 +312,23 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
engineInstance := engine.(*deployment.DeploymentEngine)
|
engineInstance := engine.(*deployment.DeploymentEngine)
|
||||||
go runDeploymentAndSync(context.Background(), db.(*database.DB), engineInstance, &d, service, req, userID.(string))
|
pos := getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
|
||||||
|
DeploymentID: d.ID,
|
||||||
|
Run: func(jctx context.Context) {
|
||||||
|
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, service, req, userID.(string))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if pos > 0 {
|
||||||
|
d.Status = "queued"
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), d.ID,
|
||||||
|
)
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), serviceID,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, DeploymentResponse{
|
c.JSON(http.StatusCreated, DeploymentResponse{
|
||||||
@@ -427,6 +446,7 @@ func runDeploymentAndSyncWithImage(
|
|||||||
SourcePath: sourcePath,
|
SourcePath: sourcePath,
|
||||||
Branch: req.Branch,
|
Branch: req.Branch,
|
||||||
Commit: req.CommitHash,
|
Commit: req.CommitHash,
|
||||||
|
NoCache: req.NoCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,16 +474,19 @@ func runDeploymentAndSyncWithImage(
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
failedAt := time.Now()
|
failedAt := time.Now()
|
||||||
timeoutErr := "Deployment timed out before completion"
|
finalStatus, finalErr := "failed", "Deployment timed out before completion"
|
||||||
|
if errors.Is(ctx.Err(), context.Canceled) {
|
||||||
|
finalStatus, finalErr = "cancelled", "Deployment cancelled"
|
||||||
|
}
|
||||||
_, _ = db.Exec(
|
_, _ = db.Exec(
|
||||||
`UPDATE deployments
|
`UPDATE deployments
|
||||||
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
|
SET status = $1, error = $2, completed_at = $3, updated_at = $3
|
||||||
WHERE id = $3`,
|
WHERE id = $4`,
|
||||||
timeoutErr, failedAt, dbDeployment.ID,
|
finalStatus, finalErr, failedAt, dbDeployment.ID,
|
||||||
)
|
)
|
||||||
_, _ = db.Exec(
|
_, _ = db.Exec(
|
||||||
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`,
|
||||||
failedAt, service.ID,
|
finalStatus, failedAt, service.ID,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
case <-syncTicker.C:
|
case <-syncTicker.C:
|
||||||
@@ -530,13 +553,86 @@ func runDeploymentAndSyncWithImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mapEngineStatusToDBStatus(status string) string {
|
func mapEngineStatusToDBStatus(status string) string {
|
||||||
switch status {
|
if status == "running" {
|
||||||
case "running":
|
|
||||||
return "deployed"
|
return "deployed"
|
||||||
case "cancelled":
|
}
|
||||||
return "failed"
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDeployQueue returns the per-service deploy queue. SetupRoutes always
|
||||||
|
// sets it; the fallback keeps handler tests that skip SetupRoutes working.
|
||||||
|
func getDeployQueue(c *gin.Context) *deployqueue.Queue {
|
||||||
|
if v, exists := c.Get("deploy_queue"); exists {
|
||||||
|
if q, ok := v.(*deployqueue.Queue); ok && q != nil {
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deployqueue.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCancelDeployment cancels a queued or in-flight deployment. Queued
|
||||||
|
// jobs are dropped and marked cancelled immediately; active jobs get their
|
||||||
|
// context cancelled so the build/reconcile aborts and the sync loop writes
|
||||||
|
// the terminal 'cancelled' status.
|
||||||
|
func handleCancelDeployment(c *gin.Context) {
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available", "code": "DEPENDENCY_UNAVAILABLE"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deploymentID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid deployment ID", "code": "VALIDATION"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated", "code": "UNAUTHENTICATED"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var status, owner string
|
||||||
|
var serviceID uuid.UUID
|
||||||
|
err = db.(*database.DB).QueryRow(
|
||||||
|
`SELECT d.status, d.service_id, p.owner_id
|
||||||
|
FROM deployments d
|
||||||
|
JOIN services s ON d.service_id = s.id
|
||||||
|
JOIN projects p ON s.project_id = p.id
|
||||||
|
WHERE d.id = $1`,
|
||||||
|
deploymentID,
|
||||||
|
).Scan(&status, &serviceID, &owner)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found", "code": "NOT_FOUND"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if owner != userID.(string) && !contextIsAdmin(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied", "code": "FORBIDDEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case "deployed", "failed", "cancelled":
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Deployment already finished", "code": "NOT_CANCELLABLE"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch getDeployQueue(c).Cancel(serviceID, deploymentID) {
|
||||||
|
case deployqueue.WasQueued:
|
||||||
|
now := time.Now()
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE deployments SET status = 'cancelled', error = 'Deployment cancelled', completed_at = $1, updated_at = $1 WHERE id = $2`,
|
||||||
|
now, deploymentID,
|
||||||
|
)
|
||||||
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET status = 'cancelled', updated_at = $1 WHERE id = $2`,
|
||||||
|
now, serviceID,
|
||||||
|
)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
|
||||||
|
case deployqueue.WasActive:
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"status": "cancelling"})
|
||||||
default:
|
default:
|
||||||
return status
|
c.JSON(http.StatusConflict, gin.H{"error": "Deployment is not running on this node", "code": "NOT_CANCELLABLE"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,7 +831,12 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
Trigger: "rollback",
|
Trigger: "rollback",
|
||||||
}
|
}
|
||||||
// Reuse the stored image directly — no rebuild.
|
// Reuse the stored image directly — no rebuild.
|
||||||
go runDeploymentAndSyncWithImage(context.Background(), db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
|
getDeployQueue(c).Enqueue(serviceID, deployqueue.Job{
|
||||||
|
DeploymentID: rollback.ID,
|
||||||
|
Run: func(jctx context.Context) {
|
||||||
|
runDeploymentAndSyncWithImage(jctx, db.(*database.DB), engine, &rollback, service, rollbackReq, userID.(string), targetImage)
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -178,11 +179,24 @@ func handleGitWebhookPush(c *gin.Context) {
|
|||||||
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||||
time.Now(), service.ID,
|
time.Now(), service.ID,
|
||||||
)
|
)
|
||||||
go runDeploymentAndSync(
|
// Webhook pushes default to a clean build — stale layers are the
|
||||||
context.Background(), db, engine, &d, service,
|
// classic silent-rollback failure (dflow convention).
|
||||||
CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook"},
|
webhookReq := CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook", NoCache: true}
|
||||||
repoUserID,
|
if pos := getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{
|
||||||
)
|
DeploymentID: d.ID,
|
||||||
|
Run: func(jctx context.Context) {
|
||||||
|
runDeploymentAndSync(jctx, db, engine, &d, service, webhookReq, repoUserID)
|
||||||
|
},
|
||||||
|
}); pos > 0 {
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), d.ID,
|
||||||
|
)
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), service.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
enqueued++
|
enqueued++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,20 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"containr/internal/build"
|
"containr/internal/build"
|
||||||
"containr/internal/config"
|
"containr/internal/config"
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
"containr/internal/ha"
|
"containr/internal/ha"
|
||||||
"containr/internal/metrics"
|
"containr/internal/metrics"
|
||||||
"containr/internal/middleware"
|
"containr/internal/middleware"
|
||||||
"containr/internal/scaling"
|
"containr/internal/scaling"
|
||||||
|
"containr/internal/secrets"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -98,6 +101,45 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// Initialize database handler
|
// Initialize database handler
|
||||||
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
||||||
|
|
||||||
|
// Per-service serialized deploy queue — one active deployment per
|
||||||
|
// service, the rest wait as 'queued'.
|
||||||
|
deployQueue := deployqueue.New()
|
||||||
|
|
||||||
|
// Encrypt-at-rest for secret env vars. SECRETS_KEY wins, JWT_SECRET is
|
||||||
|
// the fallback so existing installs need no new config.
|
||||||
|
secrets.Configure(os.Getenv("SECRETS_KEY"), cfg.JWTSecret)
|
||||||
|
|
||||||
|
// Boot sweep: in-memory engine state dies on restart, so anything that
|
||||||
|
// looked in-flight is actually orphaned — mark it failed rather than
|
||||||
|
// leaving phantom 'building'/'queued' rows.
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE deployments SET status = 'failed', error = 'Interrupted by server restart', completed_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE status IN ('queued', 'pending', 'building', 'deploying', 'rolling_back')`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Boot sweep: encrypt any plaintext secret values left over from before
|
||||||
|
// encryption-at-rest landed. Transparent and idempotent.
|
||||||
|
if encRows, err := db.Query(`SELECT id, value FROM environment_variables WHERE is_secret = TRUE`); err == nil {
|
||||||
|
type encRow struct {
|
||||||
|
id string
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
var toEncrypt []encRow
|
||||||
|
for encRows.Next() {
|
||||||
|
var r encRow
|
||||||
|
if err := encRows.Scan(&r.id, &r.value); err == nil && !secrets.IsEncrypted(r.value) {
|
||||||
|
toEncrypt = append(toEncrypt, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encRows.Close()
|
||||||
|
for _, r := range toEncrypt {
|
||||||
|
_, _ = db.Exec(`UPDATE environment_variables SET value = $1, updated_at = NOW() WHERE id = $2`, secrets.Encrypt(r.value), r.id)
|
||||||
|
}
|
||||||
|
if len(toEncrypt) > 0 {
|
||||||
|
log.Printf("secrets: encrypted %d plaintext secret variable(s)", len(toEncrypt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize security handler
|
// Initialize security handler
|
||||||
securityHandler := NewSecurityHandler(db, cfg.JWTSecret)
|
securityHandler := NewSecurityHandler(db, cfg.JWTSecret)
|
||||||
|
|
||||||
@@ -115,6 +157,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
if deploymentEngine != nil {
|
if deploymentEngine != nil {
|
||||||
c.Set("deployment_engine", deploymentEngine)
|
c.Set("deployment_engine", deploymentEngine)
|
||||||
}
|
}
|
||||||
|
c.Set("deploy_queue", deployQueue)
|
||||||
c.Set("scheduler", scheduler)
|
c.Set("scheduler", scheduler)
|
||||||
c.Set("metrics_collector", metricsCollector)
|
c.Set("metrics_collector", metricsCollector)
|
||||||
c.Set("auto_scaler", autoScaler)
|
c.Set("auto_scaler", autoScaler)
|
||||||
@@ -289,6 +332,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
|
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
|
||||||
authed.POST("/services/:id/deployments", handleCreateDeployment)
|
authed.POST("/services/:id/deployments", handleCreateDeployment)
|
||||||
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
|
||||||
|
authed.POST("/deployments/:id/cancel", handleCancelDeployment)
|
||||||
|
|
||||||
// Environment variables — secrets, owner/admin only.
|
// Environment variables — secrets, owner/admin only.
|
||||||
authed.GET("/services/:id/variables", handleGetVariables)
|
authed.GET("/services/:id/variables", handleGetVariables)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
|
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
|
"containr/internal/deployqueue"
|
||||||
|
"containr/internal/secrets"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -78,7 +80,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
|
|||||||
if err := rows.Scan(&k, &v); err != nil {
|
if err := rows.Scan(&k, &v); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
env[k] = v
|
env[k] = secrets.Decrypt(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
var refServices []struct{ ID, Name string }
|
var refServices []struct{ ID, Name string }
|
||||||
@@ -126,7 +128,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return value, true
|
return secrets.Decrypt(value), true
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range env {
|
for k, v := range env {
|
||||||
@@ -381,6 +383,7 @@ func handleServiceRestart(c *gin.Context) {
|
|||||||
|
|
||||||
// handleServiceRedeploy re-applies the current spec: pulls the image for
|
// handleServiceRedeploy re-applies the current spec: pulls the image for
|
||||||
// image-sourced services and reconciles containers (env, ports, replicas).
|
// image-sourced services and reconciles containers (env, ports, replicas).
|
||||||
|
// Runs through the per-service deploy queue so it never overlaps a build.
|
||||||
func handleServiceRedeploy(c *gin.Context) {
|
func handleServiceRedeploy(c *gin.Context) {
|
||||||
engine, db, ok := getRuntimeEngine(c)
|
engine, db, ok := getRuntimeEngine(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -406,7 +409,78 @@ func handleServiceRedeploy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
service.Image = image
|
service.Image = image
|
||||||
reconcileNow(c, engine, db, service)
|
|
||||||
|
finished := make(chan struct{})
|
||||||
|
var recErr error
|
||||||
|
var state *deployment.RuntimeState
|
||||||
|
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{Run: func(jctx context.Context) {
|
||||||
|
defer close(finished)
|
||||||
|
state, recErr = reconcileServiceJob(jctx, db, engine, service)
|
||||||
|
}})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-finished:
|
||||||
|
if recErr != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": recErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"runtime": state})
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"status": "queued"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileServiceJob is the shared queue-job body for redeploy paths:
|
||||||
|
// build the spec from current DB state, reconcile containers, persist the
|
||||||
|
// resulting status and published port.
|
||||||
|
func reconcileServiceJob(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine, service Service) (*deployment.RuntimeState, error) {
|
||||||
|
spec, err := serviceRuntimeSpec(db, service)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
state, err := engine.ReconcileService(ctx, spec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if state != nil {
|
||||||
|
persistPublishedPort(db, service.ID, state.Ports)
|
||||||
|
status := "stopped"
|
||||||
|
if state.Status != "" {
|
||||||
|
status = state.Status
|
||||||
|
}
|
||||||
|
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID)
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueServiceRedeploy resolves a deployable image (service.Image, else
|
||||||
|
// the last successful deployment's tag) and queues a reconcile. Returns
|
||||||
|
// false when no image is available or the engine is missing.
|
||||||
|
func enqueueServiceRedeploy(c *gin.Context, db *database.DB, service Service) bool {
|
||||||
|
engineValue, exists := c.Get("deployment_engine")
|
||||||
|
if !exists || engineValue == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
engine, ok := engineValue.(*deployment.DeploymentEngine)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
image := service.Image
|
||||||
|
if image == "" {
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT image_name || ':' || image_tag FROM deployments
|
||||||
|
WHERE service_id = $1 AND status = 'deployed' AND image_name <> ''
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
service.ID,
|
||||||
|
).Scan(&image); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service.Image = image
|
||||||
|
getDeployQueue(c).Enqueue(service.ID, deployqueue.Job{Run: func(jctx context.Context) {
|
||||||
|
_, _ = reconcileServiceJob(jctx, db, engine, service)
|
||||||
|
}})
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// persistPublishedPort records the host port Docker actually bound so later
|
// persistPublishedPort records the host port Docker actually bound so later
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ type hostMonitoringResponse struct {
|
|||||||
Docker map[string]any `json:"docker,omitempty"`
|
Docker map[string]any `json:"docker,omitempty"`
|
||||||
// Scope names what the telemetry actually measures: the physical host for
|
// Scope names what the telemetry actually measures: the physical host for
|
||||||
// native dockerd, or the Docker Desktop VM when the daemon runs in one.
|
// native dockerd, or the Docker Desktop VM when the daemon runs in one.
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
CollectedAt string `json:"collected_at"`
|
CollectedAt string `json:"collected_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type hostCPU struct {
|
type hostCPU struct {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
"containr/internal/secrets"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ type EnvironmentVariable struct {
|
|||||||
|
|
||||||
type UpdateVariablesRequest struct {
|
type UpdateVariablesRequest struct {
|
||||||
Variables []VariableInput `json:"variables" binding:"required"`
|
Variables []VariableInput `json:"variables" binding:"required"`
|
||||||
|
Redeploy bool `json:"redeploy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VariableInput struct {
|
type VariableInput struct {
|
||||||
@@ -159,6 +161,9 @@ func handleUpdateVariables(c *gin.Context) {
|
|||||||
value = stored
|
value = stored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if v.IsSecret {
|
||||||
|
value = secrets.Encrypt(value)
|
||||||
|
}
|
||||||
varID := uuid.New()
|
varID := uuid.New()
|
||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||||
@@ -204,5 +209,22 @@ func handleUpdateVariables(c *gin.Context) {
|
|||||||
variables = append(variables, v)
|
variables = append(variables, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"variables": variables, "message": "Environment variables updated successfully"})
|
resp := gin.H{"variables": variables, "message": "Environment variables updated successfully"}
|
||||||
|
if req.Redeploy {
|
||||||
|
// Load the service spec inputs and queue a reconcile behind any
|
||||||
|
// in-flight deployment — new env lands in the recreated containers.
|
||||||
|
var svc Service
|
||||||
|
svcErr := db.(*database.DB).QueryRow(
|
||||||
|
`SELECT id, project_id, name, COALESCE(image, ''), COALESCE(command, ''),
|
||||||
|
COALESCE(replicas, 1), COALESCE(port, 0), COALESCE(domain, ''),
|
||||||
|
COALESCE(healthcheck_path, ''), COALESCE(restart_policy, 'unless-stopped'),
|
||||||
|
COALESCE(cpu, ''), COALESCE(memory, '')
|
||||||
|
FROM services WHERE id = $1`,
|
||||||
|
serviceID,
|
||||||
|
).Scan(&svc.ID, &svc.ProjectID, &svc.Name, &svc.Image, &svc.Command,
|
||||||
|
&svc.Replicas, &svc.Port, &svc.Domain, &svc.HealthCheckPath,
|
||||||
|
&svc.RestartPolicy, &svc.CPU, &svc.Memory)
|
||||||
|
resp["redeploy_queued"] = svcErr == nil && enqueueServiceRedeploy(c, db.(*database.DB), svc)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
@@ -222,6 +222,7 @@ func (d *DockerfileBuilder) Build(ctx context.Context, req *types.BuildRequest)
|
|||||||
Dockerfile: "Dockerfile",
|
Dockerfile: "Dockerfile",
|
||||||
Tags: []string{imageName},
|
Tags: []string{imageName},
|
||||||
BuildArgs: buildArgs,
|
BuildArgs: buildArgs,
|
||||||
|
NoCache: req.NoCache,
|
||||||
Remove: true,
|
Remove: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -498,7 +498,8 @@ func (n *NixpacksBuilder) Build(ctx context.Context, req *types.BuildRequest) (*
|
|||||||
BuildArgs: map[string]*string{
|
BuildArgs: map[string]*string{
|
||||||
"BUILDKIT_INLINE_CACHE": strPtr("1"),
|
"BUILDKIT_INLINE_CACHE": strPtr("1"),
|
||||||
},
|
},
|
||||||
Remove: true,
|
NoCache: req.NoCache,
|
||||||
|
Remove: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = n.dockerClient.BuildImage(ctx, buildCtx, buildOptions)
|
_, err = n.dockerClient.BuildImage(ctx, buildCtx, buildOptions)
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ func (rb *RailpackBuilder) Build(ctx context.Context, req *types.BuildRequest) (
|
|||||||
Tags: []string{imageName},
|
Tags: []string{imageName},
|
||||||
BuildArgs: buildArgs,
|
BuildArgs: buildArgs,
|
||||||
Labels: req.Labels,
|
Labels: req.Labels,
|
||||||
|
NoCache: req.NoCache,
|
||||||
Remove: true,
|
Remove: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ var DeployCmd = &cobra.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
body := map[string]interface{}{"trigger": "cli"}
|
body := map[string]interface{}{"trigger": "cli"}
|
||||||
|
if nc, _ := cmd.Flags().GetBool("no-cache"); nc {
|
||||||
|
body["no_cache"] = true
|
||||||
|
}
|
||||||
for _, f := range []string{"commit-hash", "branch"} {
|
for _, f := range []string{"commit-hash", "branch"} {
|
||||||
if v, _ := cmd.Flags().GetString(f); v != "" {
|
if v, _ := cmd.Flags().GetString(f); v != "" {
|
||||||
key := "commit_hash"
|
key := "commit_hash"
|
||||||
@@ -106,6 +109,29 @@ var deploymentsLogsCmd = &cobra.Command{
|
|||||||
RunE: runDeploymentLogs,
|
RunE: runDeploymentLogs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var deploymentsCancelCmd = &cobra.Command{
|
||||||
|
Use: "cancel <id>",
|
||||||
|
Short: "Cancel a queued or running deployment",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := client()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := c.Do("POST", "/deployments/"+args[0]+"/cancel", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if JSONMode() {
|
||||||
|
PrintRaw(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
obj, _ := unwrapObject(data)
|
||||||
|
fmt.Printf("Deployment %s: %s\n", args[0], str(obj, "status"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
var deploymentsRollbackCmd = &cobra.Command{
|
var deploymentsRollbackCmd = &cobra.Command{
|
||||||
Use: "rollback <id>",
|
Use: "rollback <id>",
|
||||||
Short: "Roll back to a deployment",
|
Short: "Roll back to a deployment",
|
||||||
@@ -131,5 +157,6 @@ var deploymentsRollbackCmd = &cobra.Command{
|
|||||||
func init() {
|
func init() {
|
||||||
DeployCmd.Flags().String("commit-hash", "", "commit to deploy")
|
DeployCmd.Flags().String("commit-hash", "", "commit to deploy")
|
||||||
DeployCmd.Flags().String("branch", "", "branch to deploy")
|
DeployCmd.Flags().String("branch", "", "branch to deploy")
|
||||||
DeploymentsCmd.AddCommand(deploymentsListCmd, deploymentsGetCmd, deploymentsLogsCmd, deploymentsRollbackCmd)
|
DeployCmd.Flags().Bool("no-cache", false, "build without Docker layer cache")
|
||||||
|
DeploymentsCmd.AddCommand(deploymentsListCmd, deploymentsGetCmd, deploymentsLogsCmd, deploymentsRollbackCmd, deploymentsCancelCmd)
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,8 @@ var varsSetCmd = &cobra.Command{
|
|||||||
Args: cobra.MinimumNArgs(2),
|
Args: cobra.MinimumNArgs(2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
secret, _ := cmd.Flags().GetBool("secret")
|
secret, _ := cmd.Flags().GetBool("secret")
|
||||||
return mutateVars(args[0], func(vars []map[string]interface{}) []map[string]interface{} {
|
redeploy, _ := cmd.Flags().GetBool("redeploy")
|
||||||
|
return mutateVars(args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
|
||||||
for _, pair := range args[1:] {
|
for _, pair := range args[1:] {
|
||||||
k, v, found := strings.Cut(pair, "=")
|
k, v, found := strings.Cut(pair, "=")
|
||||||
if !found || k == "" {
|
if !found || k == "" {
|
||||||
@@ -71,7 +72,8 @@ var varsUnsetCmd = &cobra.Command{
|
|||||||
for _, k := range args[1:] {
|
for _, k := range args[1:] {
|
||||||
drop[k] = true
|
drop[k] = true
|
||||||
}
|
}
|
||||||
return mutateVars(args[0], func(vars []map[string]interface{}) []map[string]interface{} {
|
redeploy, _ := cmd.Flags().GetBool("redeploy")
|
||||||
|
return mutateVars(args[0], redeploy, func(vars []map[string]interface{}) []map[string]interface{} {
|
||||||
out := vars[:0]
|
out := vars[:0]
|
||||||
for _, v := range vars {
|
for _, v := range vars {
|
||||||
if !drop[str(v, "key")] {
|
if !drop[str(v, "key")] {
|
||||||
@@ -91,7 +93,7 @@ func fetchVars(c *Client, serviceID string) ([]map[string]interface{}, error) {
|
|||||||
return unwrapList(data, "variables")
|
return unwrapList(data, "variables")
|
||||||
}
|
}
|
||||||
|
|
||||||
func mutateVars(serviceID string, mutate func([]map[string]interface{}) []map[string]interface{}) error {
|
func mutateVars(serviceID string, redeploy bool, mutate func([]map[string]interface{}) []map[string]interface{}) error {
|
||||||
c, err := client()
|
c, err := client()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -109,7 +111,11 @@ func mutateVars(serviceID string, mutate func([]map[string]interface{}) []map[st
|
|||||||
"is_secret": v["is_secret"] == true,
|
"is_secret": v["is_secret"] == true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
data, err := c.Do("PUT", "/services/"+serviceID+"/variables", map[string]interface{}{"variables": payload})
|
body := map[string]interface{}{"variables": payload}
|
||||||
|
if redeploy {
|
||||||
|
body["redeploy"] = true
|
||||||
|
}
|
||||||
|
data, err := c.Do("PUT", "/services/"+serviceID+"/variables", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -117,7 +123,12 @@ func mutateVars(serviceID string, mutate func([]map[string]interface{}) []map[st
|
|||||||
PrintRaw(data)
|
PrintRaw(data)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", serviceID)
|
obj, _ := unwrapObject(data)
|
||||||
|
if obj["redeploy_queued"] == true {
|
||||||
|
fmt.Printf("Variables updated on %s — redeploy queued\n", serviceID)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Variables updated on %s — redeploy to apply to running containers\n", serviceID)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,5 +145,7 @@ func upsertVar(vars []map[string]interface{}, key, value string, secret bool) []
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
|
varsSetCmd.Flags().Bool("secret", false, "mark the variable(s) as secret")
|
||||||
|
varsSetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
||||||
|
varsUnsetCmd.Flags().Bool("redeploy", false, "redeploy the service after saving")
|
||||||
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd)
|
VariablesCmd.AddCommand(varsListCmd, varsSetCmd, varsUnsetCmd)
|
||||||
}
|
}
|
||||||
@@ -150,6 +150,7 @@ type BuildConfig struct {
|
|||||||
Environment map[string]string `json:"environment"`
|
Environment map[string]string `json:"environment"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Commit string `json:"commit"`
|
Commit string `json:"commit"`
|
||||||
|
NoCache bool `json:"no_cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TriggerConfig struct {
|
type TriggerConfig struct {
|
||||||
@@ -291,6 +292,7 @@ func (de *DeploymentEngine) buildImage(ctx context.Context, deployment *Deployme
|
|||||||
TriggeredBy: "deployment_engine",
|
TriggeredBy: "deployment_engine",
|
||||||
Branch: buildConfig.Branch,
|
Branch: buildConfig.Branch,
|
||||||
Commit: buildConfig.Commit,
|
Commit: buildConfig.Commit,
|
||||||
|
NoCache: buildConfig.NoCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := de.buildManager.Build(ctx, buildReq)
|
response, err := de.buildManager.Build(ctx, buildReq)
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package deployqueue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Job is a unit of serialized deployment work. DeploymentID is the
|
||||||
|
// deployments row it mirrors (uuid.Nil for runtime ops with no row).
|
||||||
|
// Run is invoked with a cancellable context — cancelled by Cancel for
|
||||||
|
// active jobs, or the job is dropped before starting when queued.
|
||||||
|
type Job struct {
|
||||||
|
DeploymentID uuid.UUID
|
||||||
|
Run func(ctx context.Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue serializes deploy work per service: one active job, the rest FIFO.
|
||||||
|
// A build for service A never races a redeploy or second build of A, while
|
||||||
|
// services B, C proceed in parallel. Not a global worker pool — per-service
|
||||||
|
// serialization is what prevents container thrash.
|
||||||
|
type Queue struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
states map[uuid.UUID]*serviceState
|
||||||
|
cancels map[uuid.UUID]context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceState struct {
|
||||||
|
running bool
|
||||||
|
pending []Job
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Queue {
|
||||||
|
return &Queue{
|
||||||
|
states: make(map[uuid.UUID]*serviceState),
|
||||||
|
cancels: make(map[uuid.UUID]context.CancelFunc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue schedules a job. Returns the position: 0 means it starts
|
||||||
|
// immediately, >0 means that many jobs run first.
|
||||||
|
func (q *Queue) Enqueue(serviceID uuid.UUID, job Job) int {
|
||||||
|
q.mu.Lock()
|
||||||
|
st := q.states[serviceID]
|
||||||
|
if st == nil {
|
||||||
|
st = &serviceState{}
|
||||||
|
q.states[serviceID] = st
|
||||||
|
}
|
||||||
|
if st.running {
|
||||||
|
st.pending = append(st.pending, job)
|
||||||
|
pos := len(st.pending)
|
||||||
|
q.mu.Unlock()
|
||||||
|
return pos
|
||||||
|
}
|
||||||
|
st.running = true
|
||||||
|
q.mu.Unlock()
|
||||||
|
|
||||||
|
q.start(serviceID, job)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) start(serviceID uuid.UUID, job Job) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if job.DeploymentID != uuid.Nil {
|
||||||
|
q.mu.Lock()
|
||||||
|
q.cancels[job.DeploymentID] = cancel
|
||||||
|
q.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if job.DeploymentID != uuid.Nil {
|
||||||
|
q.mu.Lock()
|
||||||
|
delete(q.cancels, job.DeploymentID)
|
||||||
|
q.mu.Unlock()
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
q.promote(serviceID)
|
||||||
|
}()
|
||||||
|
job.Run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// promote marks the service free and starts the next queued job, if any.
|
||||||
|
func (q *Queue) promote(serviceID uuid.UUID) {
|
||||||
|
q.mu.Lock()
|
||||||
|
st := q.states[serviceID]
|
||||||
|
if st == nil || len(st.pending) == 0 {
|
||||||
|
if st != nil {
|
||||||
|
st.running = false
|
||||||
|
}
|
||||||
|
q.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := st.pending[0]
|
||||||
|
st.pending = st.pending[1:]
|
||||||
|
q.mu.Unlock()
|
||||||
|
q.start(serviceID, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelState distinguishes what Cancel found.
|
||||||
|
type CancelState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
NotFound CancelState = iota
|
||||||
|
WasQueued // removed before it started — caller marks DB cancelled
|
||||||
|
WasActive // ctx cancelled — the running job marks DB itself
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cancel cancels a job by its deployment row id.
|
||||||
|
func (q *Queue) Cancel(serviceID, deploymentID uuid.UUID) CancelState {
|
||||||
|
q.mu.Lock()
|
||||||
|
if st := q.states[serviceID]; st != nil {
|
||||||
|
for i, j := range st.pending {
|
||||||
|
if j.DeploymentID == deploymentID {
|
||||||
|
st.pending = append(st.pending[:i], st.pending[i+1:]...)
|
||||||
|
q.mu.Unlock()
|
||||||
|
return WasQueued
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel, ok := q.cancels[deploymentID]
|
||||||
|
q.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
cancel()
|
||||||
|
return WasActive
|
||||||
|
}
|
||||||
|
return NotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do runs fn through the queue and waits for completion — for synchronous
|
||||||
|
// endpoints (redeploy) that still must not overlap an in-flight deployment.
|
||||||
|
// ctx bounds the wait, not the job; on timeout the job still runs to
|
||||||
|
// completion to avoid orphaned reconcile state.
|
||||||
|
func (q *Queue) Do(ctx context.Context, serviceID uuid.UUID, fn func(ctx context.Context)) {
|
||||||
|
done := make(chan struct{})
|
||||||
|
q.Enqueue(serviceID, Job{Run: func(jctx context.Context) {
|
||||||
|
defer close(done)
|
||||||
|
fn(jctx)
|
||||||
|
}})
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package deployqueue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSerializesPerService(t *testing.T) {
|
||||||
|
q := New()
|
||||||
|
svc := uuid.New()
|
||||||
|
|
||||||
|
var active, maxActive int32
|
||||||
|
finish := make(chan struct{})
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
q.Enqueue(svc, Job{Run: func(ctx context.Context) {
|
||||||
|
if n := atomic.AddInt32(&active, 1); n > atomic.LoadInt32(&maxActive) {
|
||||||
|
atomic.StoreInt32(&maxActive, n)
|
||||||
|
}
|
||||||
|
<-finish // hold until released
|
||||||
|
atomic.AddInt32(&active, -1)
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.After(2 * time.Second)
|
||||||
|
for atomic.LoadInt32(&active) < 1 {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("first job never started")
|
||||||
|
default:
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(finish)
|
||||||
|
deadline = time.After(2 * time.Second)
|
||||||
|
for atomic.LoadInt32(&active) > 0 {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("jobs did not drain")
|
||||||
|
default:
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&maxActive); got != 1 {
|
||||||
|
t.Fatalf("expected max concurrency 1, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDifferentServicesRunInParallel(t *testing.T) {
|
||||||
|
q := New()
|
||||||
|
started := make(chan struct{}, 2)
|
||||||
|
release := make(chan struct{})
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
q.Enqueue(uuid.New(), Job{Run: func(ctx context.Context) {
|
||||||
|
started <- struct{}{}
|
||||||
|
<-release
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("parallel services did not both start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelQueuedRemovesJob(t *testing.T) {
|
||||||
|
q := New()
|
||||||
|
svc := uuid.New()
|
||||||
|
release := make(chan struct{})
|
||||||
|
q.Enqueue(svc, Job{DeploymentID: uuid.New(), Run: func(ctx context.Context) { <-release }})
|
||||||
|
|
||||||
|
queuedID := uuid.New()
|
||||||
|
ran := make(chan struct{})
|
||||||
|
q.Enqueue(svc, Job{DeploymentID: queuedID, Run: func(ctx context.Context) { close(ran) }})
|
||||||
|
|
||||||
|
if got := q.Cancel(svc, queuedID); got != WasQueued {
|
||||||
|
t.Fatalf("expected WasQueued, got %v", got)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
select {
|
||||||
|
case <-ran:
|
||||||
|
t.Fatal("cancelled queued job still ran")
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelActiveCancelsContext(t *testing.T) {
|
||||||
|
q := New()
|
||||||
|
svc := uuid.New()
|
||||||
|
id := uuid.New()
|
||||||
|
ctxDone := make(chan struct{})
|
||||||
|
q.Enqueue(svc, Job{DeploymentID: id, Run: func(ctx context.Context) {
|
||||||
|
<-ctx.Done()
|
||||||
|
close(ctxDone)
|
||||||
|
}})
|
||||||
|
|
||||||
|
deadline := time.After(2 * time.Second)
|
||||||
|
for {
|
||||||
|
q.mu.Lock()
|
||||||
|
_, ok := q.cancels[id]
|
||||||
|
q.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("job never registered")
|
||||||
|
default:
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := q.Cancel(svc, id); got != WasActive {
|
||||||
|
t.Fatalf("expected WasActive, got %v", got)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctxDone:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("active job context not cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnqueuePosition(t *testing.T) {
|
||||||
|
q := New()
|
||||||
|
svc := uuid.New()
|
||||||
|
release := make(chan struct{})
|
||||||
|
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) { <-release }}); pos != 0 {
|
||||||
|
t.Fatalf("first job position = %d, want 0", pos)
|
||||||
|
}
|
||||||
|
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) {}}); pos != 1 {
|
||||||
|
t.Fatalf("second job position = %d, want 1", pos)
|
||||||
|
}
|
||||||
|
if pos := q.Enqueue(svc, Job{Run: func(ctx context.Context) {}}); pos != 2 {
|
||||||
|
t.Fatalf("third job position = %d, want 2", pos)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
}
|
||||||
@@ -297,6 +297,7 @@ func (c *Client) BuildImage(ctx context.Context, buildContext io.Reader, options
|
|||||||
BuildArgs: options.BuildArgs,
|
BuildArgs: options.BuildArgs,
|
||||||
Labels: options.Labels,
|
Labels: options.Labels,
|
||||||
Remove: options.Remove,
|
Remove: options.Remove,
|
||||||
|
NoCache: options.NoCache,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type BuildOptions struct {
|
|||||||
BuildArgs map[string]*string
|
BuildArgs map[string]*string
|
||||||
Labels map[string]string
|
Labels map[string]string
|
||||||
Remove bool
|
Remove bool
|
||||||
|
NoCache bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkConfig represents the configuration for creating a network
|
// NetworkConfig represents the configuration for creating a network
|
||||||
|
|||||||
@@ -64,15 +64,16 @@ func init() {
|
|||||||
|
|
||||||
// Variables
|
// Variables
|
||||||
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
|
register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_variables_set", "Replace all variables on a service. Args: id, variables = [{key, value, is_secret}]. Fetch the current list first and merge — the server replaces wholesale.", "PUT", "/services/{id}/variables", []string{"id"}, []string{"variables"}, false})
|
register(spec{"containr_variables_set", "Replace all variables on a service. Args: id, variables = [{key, value, is_secret}], redeploy (queue a redeploy to apply). Fetch the current list first and merge — the server replaces wholesale.", "PUT", "/services/{id}/variables", []string{"id"}, []string{"variables", "redeploy"}, false})
|
||||||
|
|
||||||
// Deployments
|
// Deployments
|
||||||
register(spec{"containr_deploy", "Trigger a deployment for a service. Args: id (service), commit_hash, branch.", "POST", "/services/{id}/deployments", []string{"id"}, []string{"commit_hash", "branch", "trigger"}, false})
|
register(spec{"containr_deploy", "Trigger a deployment for a service. Args: id (service), commit_hash, branch, no_cache.", "POST", "/services/{id}/deployments", []string{"id"}, []string{"commit_hash", "branch", "trigger", "no_cache"}, false})
|
||||||
register(spec{"containr_deployments_list", "List recent deployments across services", "GET", "/deployments", nil, nil, false})
|
register(spec{"containr_deployments_list", "List recent deployments across services", "GET", "/deployments", nil, nil, false})
|
||||||
register(spec{"containr_services_deployments", "List deployments for one service", "GET", "/services/{id}/deployments", []string{"id"}, nil, false})
|
register(spec{"containr_services_deployments", "List deployments for one service", "GET", "/services/{id}/deployments", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_deployments_get", "Get a deployment", "GET", "/deployments/{id}", []string{"id"}, nil, false})
|
register(spec{"containr_deployments_get", "Get a deployment", "GET", "/deployments/{id}", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_deployments_logs", "Get persisted build/runtime logs for a deployment. Args: id, type (all|build|runtime).", "GET", "/deployments/{id}/logs", []string{"id"}, nil, false})
|
register(spec{"containr_deployments_logs", "Get persisted build/runtime logs for a deployment. Args: id, type (all|build|runtime).", "GET", "/deployments/{id}/logs", []string{"id"}, nil, false})
|
||||||
register(spec{"containr_deployments_rollback", "Roll back to a deployment. Requires confirm:true.", "POST", "/deployments/{id}/rollback", []string{"id"}, nil, true})
|
register(spec{"containr_deployments_rollback", "Roll back to a deployment. Requires confirm:true.", "POST", "/deployments/{id}/rollback", []string{"id"}, nil, true})
|
||||||
|
register(spec{"containr_deployments_cancel", "Cancel a queued or running deployment", "POST", "/deployments/{id}/cancel", []string{"id"}, nil, false})
|
||||||
|
|
||||||
// Databases
|
// Databases
|
||||||
register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false})
|
register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false})
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package secrets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prefix marks AES-GCM-encrypted values in environment_variables.value.
|
||||||
|
// Plaintext rows keep working — Decrypt returns them unchanged, so the
|
||||||
|
// rollout is transparent encrypt-on-write / decrypt-on-read.
|
||||||
|
const Prefix = "enc:v1:"
|
||||||
|
|
||||||
|
var (
|
||||||
|
keyOnce sync.Once
|
||||||
|
key []byte
|
||||||
|
enabled bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configure derives the AES-256 key. SECRETS_KEY wins; JWT_SECRET is the
|
||||||
|
// fallback so existing installs need no new config. With neither, encrypt
|
||||||
|
// degrades to plaintext with a single warning.
|
||||||
|
func Configure(secretsKey, jwtSecret string) {
|
||||||
|
keyOnce.Do(func() {
|
||||||
|
seed := secretsKey
|
||||||
|
if seed == "" {
|
||||||
|
seed = jwtSecret
|
||||||
|
}
|
||||||
|
if seed == "" {
|
||||||
|
log.Printf("secrets: no SECRETS_KEY or JWT_SECRET — secret variables stay plaintext")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte("containr-env-secrets:" + seed))
|
||||||
|
key = sum[:]
|
||||||
|
enabled = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsEncrypted(value string) bool {
|
||||||
|
return strings.HasPrefix(value, Prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt returns value unchanged if disabled or already encrypted.
|
||||||
|
func Encrypt(value string) string {
|
||||||
|
if !enabled || IsEncrypted(value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
sealed := gcm.Seal(nonce, nonce, []byte(value), nil)
|
||||||
|
return Prefix + base64.StdEncoding.EncodeToString(sealed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt returns plaintext for enc:v1 values, the input otherwise.
|
||||||
|
// Corrupt ciphertext fails soft to the stored value rather than erroring
|
||||||
|
// the deploy path — an unreadable secret is no worse than a plaintext one.
|
||||||
|
func Decrypt(value string) string {
|
||||||
|
if !enabled || !IsEncrypted(value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, Prefix))
|
||||||
|
if err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if len(raw) < gcm.NonceSize() {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("secrets: decrypt failed (%v) — check SECRETS_KEY/JWT_SECRET rotation", fmt.Errorf("%w", err))
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return string(plain)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package secrets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resetForTest restores the package state so Configure can be re-run.
|
||||||
|
func resetForTest() {
|
||||||
|
keyOnce = sync.Once{}
|
||||||
|
key = nil
|
||||||
|
enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||||
|
resetForTest()
|
||||||
|
Configure("test-secrets-key", "")
|
||||||
|
|
||||||
|
enc := Encrypt("super-secret-value")
|
||||||
|
if !IsEncrypted(enc) {
|
||||||
|
t.Fatalf("expected encrypted prefix, got %q", enc)
|
||||||
|
}
|
||||||
|
if enc == "super-secret-value" {
|
||||||
|
t.Fatal("value stored as plaintext")
|
||||||
|
}
|
||||||
|
if got := Decrypt(enc); got != "super-secret-value" {
|
||||||
|
t.Fatalf("decrypt = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaintextPassThrough(t *testing.T) {
|
||||||
|
resetForTest()
|
||||||
|
Configure("test-secrets-key", "")
|
||||||
|
if got := Decrypt("legacy-plaintext"); got != "legacy-plaintext" {
|
||||||
|
t.Fatalf("legacy plaintext changed: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptIsIdempotent(t *testing.T) {
|
||||||
|
resetForTest()
|
||||||
|
Configure("test-secrets-key", "")
|
||||||
|
enc := Encrypt("v")
|
||||||
|
if got := Encrypt(enc); got != enc {
|
||||||
|
t.Fatal("double-encrypt changed the value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptFailsSoftOnCorruption(t *testing.T) {
|
||||||
|
resetForTest()
|
||||||
|
Configure("test-secrets-key", "")
|
||||||
|
corrupt := Prefix + "not-valid-base64!!!"
|
||||||
|
if got := Decrypt(corrupt); got != corrupt {
|
||||||
|
t.Fatal("corrupt ciphertext should pass through unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisabledWithoutKey(t *testing.T) {
|
||||||
|
resetForTest()
|
||||||
|
Configure("", "")
|
||||||
|
if got := Encrypt("v"); got != "v" {
|
||||||
|
t.Fatal("encrypt should pass through when no key configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ type BuildRequest struct {
|
|||||||
TriggeredBy string `json:"triggered_by"` // Who triggered the build
|
TriggeredBy string `json:"triggered_by"` // Who triggered the build
|
||||||
Branch string `json:"branch"` // Git branch
|
Branch string `json:"branch"` // Git branch
|
||||||
Commit string `json:"commit"` // Git commit SHA
|
Commit string `json:"commit"` // Git commit SHA
|
||||||
|
NoCache bool `json:"no_cache"` // Disable Docker layer cache
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildResponse represents the response from a build operation
|
// BuildResponse represents the response from a build operation
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
manualScaleService,
|
manualScaleService,
|
||||||
execInService,
|
execInService,
|
||||||
rollbackDeployment,
|
rollbackDeployment,
|
||||||
|
cancelDeployment,
|
||||||
updateServiceVariables,
|
updateServiceVariables,
|
||||||
updateService,
|
updateService,
|
||||||
restartService,
|
restartService,
|
||||||
@@ -66,6 +67,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Box,
|
Box,
|
||||||
|
Ban,
|
||||||
Play,
|
Play,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
@@ -104,7 +106,9 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
|
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
|
||||||
building: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
|
building: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
|
||||||
pending: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
|
pending: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
|
||||||
|
queued: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Clock, animate: false },
|
||||||
rolling_back: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: RefreshCw, animate: true },
|
rolling_back: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: RefreshCw, animate: true },
|
||||||
|
cancelled: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Ban, animate: false },
|
||||||
stopped: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false },
|
stopped: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false },
|
||||||
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false };
|
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false };
|
||||||
|
|
||||||
@@ -374,6 +378,14 @@ export function ServiceDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cancelDeploymentMutation = useMutation({
|
||||||
|
mutationFn: (deploymentId: string) => cancelDeployment(deploymentId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['service-deployments', serviceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const rollbackMutation = useMutation({
|
const rollbackMutation = useMutation({
|
||||||
mutationFn: (deploymentId: string) => rollbackDeployment(deploymentId),
|
mutationFn: (deploymentId: string) => rollbackDeployment(deploymentId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -514,7 +526,7 @@ export function ServiceDetailPage() {
|
|||||||
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
|
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
|
||||||
const total = deployments.length;
|
const total = deployments.length;
|
||||||
const active = deployments.filter((deployment) =>
|
const active = deployments.filter((deployment) =>
|
||||||
['pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status),
|
['queued', 'pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status),
|
||||||
).length;
|
).length;
|
||||||
const failed = deployments.filter((deployment) => deployment.status === 'failed').length;
|
const failed = deployments.filter((deployment) => deployment.status === 'failed').length;
|
||||||
|
|
||||||
@@ -2121,6 +2133,19 @@ export function ServiceDetailPage() {
|
|||||||
>
|
>
|
||||||
Logs
|
Logs
|
||||||
</button>
|
</button>
|
||||||
|
{['queued', 'pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status) && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Cancel deployment ${deployment.id.slice(0, 8)}?`)) {
|
||||||
|
cancelDeploymentMutation.mutate(deployment.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={cancelDeploymentMutation.isPending}
|
||||||
|
className="px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--error)] hover:border-[var(--error)]/50 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{cancelDeploymentMutation.isPending && cancelDeploymentMutation.variables === deployment.id ? '...' : 'Cancel'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(`Rollback to deployment ${deployment.id.slice(0, 8)}?`)) {
|
if (window.confirm(`Rollback to deployment ${deployment.id.slice(0, 8)}?`)) {
|
||||||
|
|||||||
@@ -1146,6 +1146,72 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/deployments/{id}/cancel": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Cancel a deployment
|
||||||
|
* @description Cancel a queued or in-flight deployment. Queued deployments are marked cancelled immediately; running ones get their build/reconcile aborted and settle as 'cancelled' shortly after.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Deployment ID */
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Queued deployment cancelled */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @example cancelled */
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Running deployment is cancelling */
|
||||||
|
202: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @example cancelling */
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Deployment already finished */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/deployments/{id}/rollback": {
|
"/deployments/{id}/rollback": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -7796,6 +7862,8 @@ export interface components {
|
|||||||
env_vars?: {
|
env_vars?: {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
|
/** @description Build without Docker layer cache */
|
||||||
|
no_cache?: boolean;
|
||||||
};
|
};
|
||||||
RollbackDeploymentResponse: {
|
RollbackDeploymentResponse: {
|
||||||
deployment?: components["schemas"]["Deployment"];
|
deployment?: components["schemas"]["Deployment"];
|
||||||
@@ -8347,6 +8415,8 @@ export interface components {
|
|||||||
};
|
};
|
||||||
UpdateVariablesRequest: {
|
UpdateVariablesRequest: {
|
||||||
variables: components["schemas"]["VariableInput"][];
|
variables: components["schemas"]["VariableInput"][];
|
||||||
|
/** @description Queue a service redeploy after saving so running containers pick up the new variables */
|
||||||
|
redeploy?: boolean;
|
||||||
};
|
};
|
||||||
ConnectGitHubAppRequest: {
|
ConnectGitHubAppRequest: {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
|||||||
"path": "guides/CLI.md",
|
"path": "guides/CLI.md",
|
||||||
"section": "guides",
|
"section": "guides",
|
||||||
"title": "Containr CLI",
|
"title": "Containr CLI",
|
||||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|delete|start|stop|restart|redeploy\ndeploy <service-id> trigger a deployment\ndeployments list|get|logs|rollback\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n"
|
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|delete|start|stop|restart|redeploy\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|delete|action|backup|restore|download-backup\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel <id>` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||||
@@ -58,7 +58,7 @@ export const bundledDocs: BundledDoc[] = [
|
|||||||
"path": "guides/OPERATIONS.md",
|
"path": "guides/OPERATIONS.md",
|
||||||
"section": "guides",
|
"section": "guides",
|
||||||
"title": "Operations",
|
"title": "Operations",
|
||||||
"markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c <command>`) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n\n## Personal access tokens\n\n**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the\nCLI, MCP server, and scripts — the same privileges as your account, bounded\nby scope. The raw token is shown once at creation; only its SHA-256 hash is\nstored.\n\nScopes:\n\n- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`.\n- `write` — everything except admin routes.\n- `admin` — full access; requires a platform admin account, and the token is\n still capped at the owner's privileges.\n\nA token can never outrank its owner: non-admin scopes report\n`is_admin = false` even on admin accounts.\n\n```\nGET /api/v1/user/tokens list (metadata only, never hashes)\nPOST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once\nDELETE /api/v1/user/tokens/:id revoke (owner-scoped)\n```\n\nUse as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.\nRevocation takes effect on the next request; `last_used_at` updates on each\nauthenticated call.\n"
|
"markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c <command>`) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n\n## Personal access tokens\n\n**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the\nCLI, MCP server, and scripts — the same privileges as your account, bounded\nby scope. The raw token is shown once at creation; only its SHA-256 hash is\nstored.\n\nScopes:\n\n- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`.\n- `write` — everything except admin routes.\n- `admin` — full access; requires a platform admin account, and the token is\n still capped at the owner's privileges.\n\nA token can never outrank its owner: non-admin scopes report\n`is_admin = false` even on admin accounts.\n\n```\nGET /api/v1/user/tokens list (metadata only, never hashes)\nPOST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once\nDELETE /api/v1/user/tokens/:id revoke (owner-scoped)\n```\n\nUse as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.\nRevocation takes effect on the next request; `last_used_at` updates on each\nauthenticated call.\n\n## Deployment queue\n\nDeployments are serialized per service: a deploy while another is active\nlands in `queued` state and promotes when it finishes — deploys, rollbacksLine truncated
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "guides/README_BACKEND.md",
|
"path": "guides/README_BACKEND.md",
|
||||||
|
|||||||
@@ -1320,6 +1320,12 @@ export async function rollbackDeployment(deploymentId: string): Promise<Rollback
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cancelDeployment(deploymentId: string): Promise<{ status: string }> {
|
||||||
|
return requestJson<{ status: string }>(`/deployments/${deploymentId}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listBuilds(input: ListBuildsInput = {}): Promise<ListBuildsResult> {
|
export async function listBuilds(input: ListBuildsInput = {}): Promise<ListBuildsResult> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (input.projectId) {
|
if (input.projectId) {
|
||||||
|
|||||||
@@ -811,6 +811,45 @@ paths:
|
|||||||
deployment:
|
deployment:
|
||||||
$ref: '#/components/schemas/Deployment'
|
$ref: '#/components/schemas/Deployment'
|
||||||
|
|
||||||
|
/deployments/{id}/cancel:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Deployments
|
||||||
|
summary: Cancel a deployment
|
||||||
|
description: Cancel a queued or in-flight deployment. Queued deployments are marked cancelled immediately; running ones get their build/reconcile aborted and settle as 'cancelled' shortly after.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Deployment ID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Queued deployment cancelled
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status: { type: string, example: cancelled }
|
||||||
|
'202':
|
||||||
|
description: Running deployment is cancelling
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status: { type: string, example: cancelling }
|
||||||
|
'409':
|
||||||
|
description: Deployment already finished
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
/deployments/{id}/rollback:
|
/deployments/{id}/rollback:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -5108,6 +5147,9 @@ components:
|
|||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: string
|
type: string
|
||||||
description: Runtime environment variable overrides
|
description: Runtime environment variable overrides
|
||||||
|
no_cache:
|
||||||
|
type: boolean
|
||||||
|
description: Build without Docker layer cache
|
||||||
|
|
||||||
RollbackDeploymentResponse:
|
RollbackDeploymentResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -5989,6 +6031,9 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/VariableInput'
|
$ref: '#/components/schemas/VariableInput'
|
||||||
|
redeploy:
|
||||||
|
type: boolean
|
||||||
|
description: Queue a service redeploy after saving so running containers pick up the new variables
|
||||||
|
|
||||||
ConnectGitHubAppRequest:
|
ConnectGitHubAppRequest:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
+8
-3
@@ -38,11 +38,11 @@ auth login|logout|status token auth and profiles
|
|||||||
tokens list|create|revoke personal access tokens
|
tokens list|create|revoke personal access tokens
|
||||||
projects list|get|create|delete
|
projects list|get|create|delete
|
||||||
services list|get|create|delete|start|stop|restart|redeploy
|
services list|get|create|delete|start|stop|restart|redeploy
|
||||||
deploy <service-id> trigger a deployment
|
deploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)
|
||||||
deployments list|get|logs|rollback
|
deployments list|get|logs|rollback|cancel
|
||||||
logs <service-id> [--follow] runtime logs (SSE follow)
|
logs <service-id> [--follow] runtime logs (SSE follow)
|
||||||
exec <service-id> -- <cmd> one-off container command
|
exec <service-id> -- <cmd> one-off container command
|
||||||
variables list|set|unset env vars (secrets masked)
|
variables list|set|unset env vars (secrets masked; --redeploy applies them)
|
||||||
databases list|get|create|delete|action|backup|restore|download-backup
|
databases list|get|create|delete|action|backup|restore|download-backup
|
||||||
cron list|get|create|delete|trigger|executions
|
cron list|get|create|delete|trigger|executions
|
||||||
templates list|get|create|delete|deploy
|
templates list|get|create|delete|deploy
|
||||||
@@ -95,3 +95,8 @@ Client configuration (Claude Code, Cursor, Devin, any stdio MCP host):
|
|||||||
|
|
||||||
Scope the token deliberately: `read` is enough for inspection agents,
|
Scope the token deliberately: `read` is enough for inspection agents,
|
||||||
`write` for deploy automation, `admin` only for platform operations.
|
`write` for deploy automation, `admin` only for platform operations.
|
||||||
|
|
||||||
|
Deployments are serialized per service: a second `deploy` on a busy
|
||||||
|
service lands in `queued` state and runs when the active one finishes.
|
||||||
|
`deployments cancel <id>` drops a queued deployment or aborts a running
|
||||||
|
one (builds/reconciles stop via context cancellation).
|
||||||
@@ -117,3 +117,20 @@ DELETE /api/v1/user/tokens/:id revoke (owner-scoped)
|
|||||||
Use as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.
|
Use as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.
|
||||||
Revocation takes effect on the next request; `last_used_at` updates on each
|
Revocation takes effect on the next request; `last_used_at` updates on each
|
||||||
authenticated call.
|
authenticated call.
|
||||||
|
|
||||||
|
## Deployment queue
|
||||||
|
|
||||||
|
Deployments are serialized per service: a deploy while another is active
|
||||||
|
lands in `queued` state and promotes when it finishes — deploys, rollbacks,
|
||||||
|
redeploys, and variable-apply reconciles can never race on the same service.
|
||||||
|
`POST /deployments/:id/cancel` drops queued work or aborts running builds.
|
||||||
|
Rows left in-flight by a server restart are marked `failed` on boot.
|
||||||
|
Webhook-triggered deploys build with `no_cache` by default.
|
||||||
|
|
||||||
|
## Secret variables
|
||||||
|
|
||||||
|
Variables with `is_secret` are AES-GCM-encrypted at rest (`enc:v1:` prefix).
|
||||||
|
Key derivation: `SECRETS_KEY` env var if set, else `JWT_SECRET`. Existing
|
||||||
|
plaintext secrets are encrypted on boot; reads for deploy/exec decrypt
|
||||||
|
transparently. Rotating the key makes stored secrets unreadable — re-save
|
||||||
|
them after rotation.
|
||||||
@@ -12,9 +12,10 @@ Base URL: `https://<host>/api/v1`. OpenAPI: `docs/api/openapi.yaml`.
|
|||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Errors**: `{"error": "msg", "code": "MACHINE_CODE"}`. Codes: `UNAUTHENTICATED`, `INVALID_TOKEN`, `FORBIDDEN`, `ADMIN_REQUIRED`, `SCOPE_INSUFFICIENT`, `NOT_FOUND`, `VALIDATION`, `CONFLICT`, `INTERNAL`, `DEPENDENCY_UNAVAILABLE`, `IDEMPOTENCY_IN_PROGRESS`.
|
- **Errors**: `{"error": "msg", "code": "MACHINE_CODE"}`. Codes: `UNAUTHENTICATED`, `INVALID_TOKEN`, `FORBIDDEN`, `ADMIN_REQUIRED`, `SCOPE_INSUFFICIENT`, `NOT_FOUND`, `VALIDATION`, `CONFLICT`, `INTERNAL`, `DEPENDENCY_UNAVAILABLE`, `IDEMPOTENCY_IN_PROGRESS`, `NOT_CANCELLABLE`.
|
||||||
- **Idempotency**: send `Idempotency-Key: <uuid>` on POSTs — the first response replays for 24h; concurrent duplicates get 409. Always safe to send.
|
- **Idempotency**: send `Idempotency-Key: <uuid>` on POSTs — the first response replays for 24h; concurrent duplicates get 409. Always safe to send.
|
||||||
- **Pagination**: `?limit=` + `?cursor=` (pass `pagination.next_cursor` verbatim; legacy `?page=` also works). Invalid cursors fall back to page 1.
|
- **Pagination**: `?limit=` + `?cursor=` (pass `pagination.next_cursor` verbatim; legacy `?page=` also works). Invalid cursors fall back to page 1.
|
||||||
|
- **Deployments**: serialized per service — a deploy on a busy service returns `status: queued` and runs when the active one finishes. `POST /deployments/{id}/cancel` drops queued (`200 cancelled`) or aborts running (`202 cancelling`) work. `no_cache: true` on create disables the Docker layer cache. `PUT /services/{id}/variables` accepts `redeploy: true` to apply env changes to running containers.
|
||||||
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
|
- **Identifiers**: all ids are UUIDs. Tokens: `cnp_` user PATs, `cagt_` agent onboarding tokens.
|
||||||
|
|
||||||
## Core objects
|
## Core objects
|
||||||
|
|||||||
Reference in new issue
Block a user