mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(deploy): webhook queue+nocache, restart sweep, secrets at rest
- Webhook-triggered deploys now go through the per-service deploy queue and default to no_cache builds (dflow convention — prevents stale layer surprises on push). - Boot sweep marks deployments interrupted by restart as failed instead of leaving phantom queued/building rows. - is_secret environment variables are AES-GCM-encrypted at rest (enc:v1: prefix); key from SECRETS_KEY or JWT_SECRET fallback. Existing plaintext secrets encrypt on boot; deploy/exec paths decrypt transparently. Masked-echo writes preserve stored ciphertext. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
7839dc48a3
commit
747b714a02
8 files changed
+242
-8
No files matched your search
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -178,11 +179,24 @@ func handleGitWebhookPush(c *gin.Context) {
|
||||
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||
time.Now(), service.ID,
|
||||
)
|
||||
go runDeploymentAndSync(
|
||||
context.Background(), db, engine, &d, service,
|
||||
CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook"},
|
||||
repoUserID,
|
||||
)
|
||||
// Webhook pushes default to a clean build — stale layers are the
|
||||
// classic silent-rollback failure (dflow convention).
|
||||
webhookReq := CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook", NoCache: true}
|
||||
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++
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"containr/internal/build"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"containr/internal/metrics"
|
||||
"containr/internal/middleware"
|
||||
"containr/internal/scaling"
|
||||
"containr/internal/secrets"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -103,6 +105,41 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// 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
|
||||
securityHandler := NewSecurityHandler(db, cfg.JWTSecret)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/deployment"
|
||||
"containr/internal/deployqueue"
|
||||
"containr/internal/secrets"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -79,7 +80,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env[k] = v
|
||||
env[k] = secrets.Decrypt(v)
|
||||
}
|
||||
|
||||
var refServices []struct{ ID, Name string }
|
||||
@@ -127,7 +128,7 @@ func resolveServiceEnv(db *database.DB, service Service) (map[string]string, err
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
return secrets.Decrypt(value), true
|
||||
}
|
||||
|
||||
for k, v := range env {
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/secrets"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -160,6 +161,9 @@ func handleUpdateVariables(c *gin.Context) {
|
||||
value = stored
|
||||
}
|
||||
}
|
||||
if v.IsSecret {
|
||||
value = secrets.Encrypt(value)
|
||||
}
|
||||
varID := uuid.New()
|
||||
_, err = tx.Exec(
|
||||
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||
|
||||
Reference in new issue
Block a user