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:
Tomáš Dvořák authored and GitHub committed 2026-10-09 00:48:37 +02:00
commit 971773f80c
29 files changed
+963 -40

No files matched your search

+1
View File
@@ -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.
+114 -13
View File
@@ -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{
+19 -5
View File
@@ -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++
} }
+44
View File
@@ -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)
+77 -3
View File
@@ -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
+2 -2
View File
@@ -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 {
+23 -1
View File
@@ -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)
} }
+1
View File
@@ -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,
} }
+2 -1
View File
@@ -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)
+1
View File
@@ -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,
} }
+28 -1
View File
@@ -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)
} }
+18 -5
View File
@@ -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)
+146
View File
@@ -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)
}
+1
View File
@@ -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,
}) })
} }
+1
View File
@@ -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
+3 -2
View File
@@ -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})
+98
View File
@@ -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")
}
}
+1
View File
@@ -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)}?`)) {
+70
View File
@@ -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: {
/** /**
+2 -2
View File
@@ -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",
+6
View File
@@ -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) {
+45
View File
@@ -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
View File
@@ -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).
+17
View File
@@ -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.
+2 -1
View File
@@ -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