Compare commits

...
3 Commits
Author SHA1 Message Date
Tomáš DvořákandDevin dd7b735b38 feat(preview): real preview environments — clone, deploy, TTL, promote (#67)
Preview environments were bookkeeping only: a fabricated deployment row
marked "running" and a fake *.preview.containr.local URL. Now a preview
is a real service clone — full config copy (source, env, volumes, access),
git_branch pinned to the requested branch, its own preview hostname under
the clone's node default domain, and a real deployment through the queue.

- cloneServiceRow extracted from handleCloneService for reuse
- preview_environments.preview_service_id links to the clone
- minute sweeper: expires past-TTL previews (removes clone runtime + row)
  and syncs preview status from the clone's live service status
- delete/cleanup paths tear the clone down, not just the row
- promote redeploys the SOURCE service at the preview branch and retires
  the clone — a real deployment, not a pending stub

Verified live: create → clone → deploy → running container; TTL expiry →
clone + container gone; promote → real deploy on the source, preview
stopped. Unit test covers the DNS-safe clone naming.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 00:54:07 +02:00
Tomáš DvořákandDevin d5909172ae feat(gateway): /g/:slug proxy data plane with key auth and rate limits (#66)
The admin surface under /admin/gateway managed api_services and api_keys,
but no public route ever consumed them. Mounts /g/:slug and /g/:slug/* on
the root router (outside /api/v1 so route_prefix stays clean) and runs the
full pipeline: slug lookup -> X-API-Key/Bearer extraction -> prefix-indexed
bcrypt key auth -> allowed_service_ids scope -> per-minute buckets (key and
service caps) -> monthly usage_counters quotas -> gateway.ProxyRequest with
per-service timeout and upstream auth headers -> usage/metrics/incident
recording.

Live-verified: proxied GET/POST, 401/404/429 boundaries, counter upserts,
UPSTREAM_5XX incidents. Unit tests cover URL building, the rpm bucket, key
extraction, and the service allowlist.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 00:29:57 +02:00
Tomáš DvořákandDevin 1409186a1f feat(nodes): per-node default domain with auto service hostnames (#65)
node_agents.default_domain gives each agent a base domain. Services
placed on it (explicit pin or spread) with no explicit service_domains
get <name>.<default_domain> — resolved in serviceDomainNames so edge
routing, wake placeholders, and public_url all see the same names.
Spread services union every eligible node's base domain; explicit
domains always win.

Surfaces: PUT /agents/:id {default_domain} (hostname-validated), CLI
nodes update --domain, MCP arg docs, OpenAPI + regenerated types,
node-detail inline editor.

Also marks the already-shipped backup-import roadmap item.

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

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-10 00:07:30 +02:00
25 changed files with 1156 additions and 165 deletions

No files matched your search

+17 -7
View File
@@ -226,7 +226,11 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
{target_node_id}`: dump → stream transfer → provision → restore → {target_node_id}`: dump → stream transfer → provision → restore →
re-point bound services. Deferred until Phase F lands real second re-point bound services. Deferred until Phase F lands real second
nodes — nothing to migrate between today. nodes — nothing to migrate between today.
- [ ] **Backup import** — upload archive → new restore point (pairs with - [x] **Backup import** — `POST /databases/:id/backups/import` accepts a
`.tar.gz` (multipart `file` or raw body, 2 GiB cap, gzip-magic
validated), stores it in the backups volume via a holder container,
registers a completed restore point, and ships offsite when a backup
target is configured. `api/backuptargets.go`. (pairs with
migrators). migrators).
## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates) ## 6. Phase E — Templates v2 (absorbs compose-catalog spec + dflow graph templates)
@@ -354,8 +358,12 @@ and wired refs, one click or one CLI call.
- [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/ - [ ] **IaC provisioning (ROADMAP §2.2, unchanged)** — Terraform/
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
(dflow's second provider), cloud-init → auto-enroll. (dflow's second provider), cloud-init → auto-enroll.
- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) — - [x] **Global/wildcard domain per node** (`node_agents.default_domain`) —
auto-domains for services on that node. `PUT /agents/:id {default_domain}`; domainless services placed on the
node get `<name>.<default_domain>` (pinned → node's base; spread →
union of eligible nodes). `serviceDomainNames` resolves them so
routing, wake placeholders, and `public_url` all pick them up.
CLI `nodes update --domain`, MCP arg, node-detail editor.
## 8. Phase G — Platform polish ## 8. Phase G — Platform polish
@@ -509,10 +517,12 @@ and wired refs, one click or one CLI call.
## 10. Carried-over debt (ROADMAP.md items still open) ## 10. Carried-over debt (ROADMAP.md items still open)
- [ ] **Gateway traffic path** — mount `/g/:slug/*` → key → rate-limit → - [x] **Gateway traffic path** — `/g/:slug/*` mounted → api_key auth →
`gateway/proxy.go` → metrics. Management API exists; proxy unwired. rpm + monthly quota → `gateway/proxy.go` → usage_counters /
- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL metrics_timeseries / incident_events.
(bookkeeping only now). Pairs with clone + multi-domain. - [x] **Preview environments runtime** — previews are real service clones:
branch override, queued deployment, node-domain hostnames, TTL sweeper,
promote = redeploy of the source at the preview branch.
- [ ] **Environments** — env switcher, per-env vars/domains, promote. - [ ] **Environments** — env switcher, per-env vars/domains, promote.
- [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`. - [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`.
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/ - [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
+40 -24
View File
@@ -30,21 +30,24 @@ import (
// NodeAgent represents a container orchestration agent // NodeAgent represents a container orchestration agent
type NodeAgent struct { type NodeAgent struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"` IPAddress string `json:"ip_address"`
Port int `json:"port"` Port int `json:"port"`
Status string `json:"status"` Status string `json:"status"`
Version string `json:"version"` Version string `json:"version"`
Capabilities AgentCapabilities `json:"capabilities"` Capabilities AgentCapabilities `json:"capabilities"`
Resources NodeResources `json:"resources"` Resources NodeResources `json:"resources"`
LastHeartbeat time.Time `json:"last_heartbeat"` LastHeartbeat time.Time `json:"last_heartbeat"`
AutoPrune bool `json:"auto_prune"` AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"` Schedulable bool `json:"schedulable"`
// Tags are operator-set placement labels; services with placement_tags // Tags are operator-set placement labels; services with placement_tags
// only schedule onto agents carrying every required tag. // only schedule onto agents carrying every required tag.
Tags []string `json:"tags"` Tags []string `json:"tags"`
// DefaultDomain is this node's base domain — services placed here with
// no explicit domains get <name>.<default_domain> automatically.
DefaultDomain string `json:"default_domain"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata"` Metadata map[string]interface{} `json:"metadata"`
@@ -272,17 +275,18 @@ func (h *NodeAgentHandler) ListNodeOptions(c *gin.Context) {
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent { func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
agent := NodeAgent{ agent := NodeAgent{
ID: row.ID, ID: row.ID,
Name: row.Name, Name: row.Name,
Hostname: row.Hostname, Hostname: row.Hostname,
IPAddress: row.IpAddress, IPAddress: row.IpAddress,
Port: int(row.Port), Port: int(row.Port),
Status: row.Status.String, Status: row.Status.String,
Version: row.Version.String, Version: row.Version.String,
AutoPrune: row.AutoPrune, AutoPrune: row.AutoPrune,
Schedulable: row.Schedulable, Schedulable: row.Schedulable,
Tags: []string{}, Tags: []string{},
Metadata: map[string]interface{}{}, DefaultDomain: row.DefaultDomain,
Metadata: map[string]interface{}{},
} }
if row.LastHeartbeat.Valid { if row.LastHeartbeat.Valid {
agent.LastHeartbeat = row.LastHeartbeat.Time agent.LastHeartbeat = row.LastHeartbeat.Time
@@ -572,6 +576,18 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
// Tags persist through their own column write — UpdateAgent keeps the // Tags persist through their own column write — UpdateAgent keeps the
// register-path signature untouched. // register-path signature untouched.
resp := agentFromRow(updated) resp := agentFromRow(updated)
if raw, ok := updates["default_domain"]; ok {
domain := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", raw)))
if domain != "" && !validHostname(domain) {
c.JSON(http.StatusBadRequest, gin.H{"error": "default_domain must be a valid hostname", "code": "VALIDATION"})
return
}
if err := h.q.SetAgentDefaultDomain(ctx, sqlcdb.SetAgentDefaultDomainParams{ID: id, DefaultDomain: domain}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update default domain"})
return
}
resp.DefaultDomain = domain
}
if raw, ok := updates["tags"]; ok { if raw, ok := updates["tags"]; ok {
var tags []string var tags []string
if b, mErr := json.Marshal(raw); mErr == nil { if b, mErr := json.Marshal(raw); mErr == nil {
+68
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"net" "net"
"net/http" "net/http"
"regexp" "regexp"
@@ -10,6 +11,7 @@ import (
"time" "time"
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@@ -68,6 +70,72 @@ func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) [
if len(out) == 0 && fallback != "" { if len(out) == 0 && fallback != "" {
out = []string{fallback} out = []string{fallback}
} }
if len(out) == 0 {
out = serviceAutoDomains(db, serviceID)
}
return out
}
// dnsLabel normalizes a service name into a DNS label: lowercase alnum
// plus single dashes, never leading/trailing, ≤63 chars.
func dnsLabel(name string) string {
var b strings.Builder
lastWasSep := false
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastWasSep = false
continue
}
if !lastWasSep && b.Len() > 0 {
b.WriteByte('-')
lastWasSep = true
}
}
out := strings.Trim(b.String(), "-")
if len(out) > 63 {
out = strings.Trim(out[:63], "-")
}
return out
}
// serviceAutoDomains generates <service>.<node-default-domain> hostnames
// for services with no explicit domains. Pinned services take their node's
// base domain; spread services union every eligible node's base domain.
// Local services get nothing — there is no instance-level base domain yet.
func serviceAutoDomains(db *database.DB, serviceID uuid.UUID) []string {
var name string
var nodeID sql.NullString
var spread bool
if err := db.QueryRow(
`SELECT name, node_id, COALESCE(spread, false) FROM services WHERE id = $1`, serviceID,
).Scan(&name, &nodeID, &spread); err != nil {
return nil
}
label := dnsLabel(name)
if label == "" {
return nil
}
var bases []string
ctx := context.Background()
q := sqlcdb.New(db.DB)
switch {
case nodeID.Valid && nodeID.String != "":
if a, err := q.GetAgent(ctx, nodeID.String); err == nil && a.DefaultDomain != "" {
bases = []string{a.DefaultDomain}
}
case spread:
var err error
bases, err = q.ListSchedulableAgentDomains(ctx, json.RawMessage(tagsJSON(servicePlacementTags(db, serviceID))))
if err != nil {
bases = nil
}
}
out := make([]string, 0, len(bases))
for _, base := range bases {
out = append(out, label+"."+base)
}
return out return out
} }
+26
View File
@@ -0,0 +1,26 @@
package api
import "testing"
func TestDNSLabel(t *testing.T) {
cases := map[string]string{
"my-app": "my-app",
"My App_2": "my-app-2",
" web ": "web",
"__weird__name": "weird-name",
"---": "",
"": "",
}
for in, want := range cases {
if got := dnsLabel(in); got != want {
t.Errorf("dnsLabel(%q) = %q, want %q", in, got, want)
}
}
}
func TestDNSLabelLengthCap(t *testing.T) {
long := "a" + string(make([]byte, 0)) + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
if got := dnsLabel(long); len(got) > 63 {
t.Fatalf("label over 63 chars: %d", len(got))
}
}
+311
View File
@@ -0,0 +1,311 @@
package api
// Data plane for the public API gateway: /g/:slug/* proxies to the
// upstream_url of the matching api_services row. Callers authenticate with
// api_keys (X-API-Key or Authorization: Bearer), pass the rpm + monthly
// quota gates, and every request lands in usage_counters /
// metrics_timeseries / incident_events for the /admin/gateway analytics
// surfaces.
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"containr/internal/database"
"containr/internal/gateway"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
// jarvis: ceiling — a single shared transport; per-request timeouts come
// from api_services.request_timeout_ms on the request context.
var gatewayHTTPClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
// gatewayRPM is a process-local per-minute counter map. Containr runs a
// single API process, so an in-memory bucket is sufficient — a multi-node
// control plane would need a Redis-backed limiter instead.
var gatewayRPM = struct {
sync.Mutex
buckets map[string]*rpmBucket
}{buckets: map[string]*rpmBucket{}}
type rpmBucket struct {
window time.Time
count int
}
// gatewayRateAllow returns false when the bucket for the current minute has
// already reached limit. A limit <= 0 disables the check. `now` is injected
// so tests can freeze the window.
func gatewayRateAllow(bucketKey string, limit int, now time.Time) bool {
if limit <= 0 {
return true
}
window := now.Truncate(time.Minute)
gatewayRPM.Lock()
defer gatewayRPM.Unlock()
b := gatewayRPM.buckets[bucketKey]
if b == nil || !b.window.Equal(window) {
b = &rpmBucket{window: window}
gatewayRPM.buckets[bucketKey] = b
// Best-effort sweep so the map cannot grow without bound.
for k, old := range gatewayRPM.buckets {
if old.window.Before(window.Add(-time.Minute)) {
delete(gatewayRPM.buckets, k)
}
}
}
b.count++
return b.count <= limit
}
type gatewayService struct {
ID string
UpstreamURL string
RoutePrefix string
UpstreamAuthHeader sql.NullString
UpstreamAuthValue sql.NullString
Enabled bool
RPMLimit sql.NullInt64
MonthlyQuota sql.NullInt64
RequestTimeoutMS int
}
type gatewayKey struct {
ID string
Hash string
Enabled bool
RPMLimit sql.NullInt64
MonthlyQuota sql.NullInt64
AllowedServiceIDs string
}
// extractGatewayKey reads the caller's API key from X-API-Key first, then
// Authorization: Bearer.
func extractGatewayKey(c *gin.Context) string {
if k := strings.TrimSpace(c.GetHeader("X-API-Key")); k != "" {
return k
}
auth := strings.TrimSpace(c.GetHeader("Authorization"))
if len(auth) > 7 && strings.EqualFold(auth[:7], "Bearer ") {
return strings.TrimSpace(auth[7:])
}
return ""
}
// lookupGatewayService resolves the slug to an enabled service row.
func lookupGatewayService(ctx context.Context, db *database.DB, slug string) (*gatewayService, error) {
var s gatewayService
var enabled int
err := db.QueryRowContext(ctx, `
SELECT id, upstream_url, route_prefix, upstream_auth_header,
upstream_auth_value, enabled, rpm_limit, monthly_quota,
request_timeout_ms
FROM api_services WHERE slug = $1
`, slug).Scan(&s.ID, &s.UpstreamURL, &s.RoutePrefix,
&s.UpstreamAuthHeader, &s.UpstreamAuthValue, &enabled,
&s.RPMLimit, &s.MonthlyQuota, &s.RequestTimeoutMS)
if err != nil {
return nil, err
}
s.Enabled = enabled != 0
return &s, nil
}
// authenticateGatewayKey resolves a presented key by its indexed prefix and
// bcrypt-verifies candidates. Returns nil when nothing matches.
func authenticateGatewayKey(ctx context.Context, db *database.DB, presented string) (*gatewayKey, error) {
prefix := presented
if len(prefix) > 8 {
prefix = prefix[:8]
}
rows, err := db.QueryContext(ctx, `
SELECT id, key_hash, enabled, rpm_limit, monthly_quota, allowed_service_ids
FROM api_keys WHERE key_prefix = $1
`, prefix)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var k gatewayKey
var enabled int
if err := rows.Scan(&k.ID, &k.Hash, &enabled, &k.RPMLimit, &k.MonthlyQuota, &k.AllowedServiceIDs); err != nil {
return nil, err
}
k.Enabled = enabled != 0
if bcrypt.CompareHashAndPassword([]byte(k.Hash), []byte(presented)) == nil {
return &k, nil
}
}
return nil, rows.Err()
}
// keyAllowsService applies the allowed_service_ids JSON allowlist. An empty
// list means the key may call every service.
func keyAllowsService(k *gatewayKey, serviceID string) bool {
var allowed []string
if err := json.Unmarshal([]byte(k.AllowedServiceIDs), &allowed); err != nil || len(allowed) == 0 {
return true
}
for _, id := range allowed {
if id == serviceID {
return true
}
}
return false
}
// monthlyUsage sums a counter dimension for the current calendar month.
func monthlyUsage(ctx context.Context, db *database.DB, column, id, period string) (int, error) {
var total int
err := db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(request_count), 0) FROM usage_counters
WHERE `+column+` = $1 AND period_month = $2
`, id, period).Scan(&total)
return total, err
}
// recordGatewayRequest persists usage, metrics, and failure incidents for a
// proxied call. Runs in the request path but is three small writes.
func recordGatewayRequest(ctx context.Context, db *database.DB, svc *gatewayService, k *gatewayKey, status int, period string) {
_, _ = db.ExecContext(ctx, `
INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
VALUES ($1, $2, $3, 1, NOW())
ON CONFLICT (api_key_id, service_id, period_month)
DO UPDATE SET request_count = usage_counters.request_count + 1, updated_at = NOW()
`, k.ID, svc.ID, period)
_, _ = db.ExecContext(ctx, `
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
`, k.ID)
labels, _ := json.Marshal(map[string]interface{}{
"service_id": svc.ID,
"key_id": k.ID,
"status": status,
})
_, _ = db.ExecContext(ctx, `
INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
VALUES ('gateway_request', 1, $1, NOW())
`, string(labels))
}
func recordGatewayIncident(ctx context.Context, db *database.DB, svcID string, keyID *string, code, msg, severity string, status int) {
var kid interface{}
if keyID != nil {
kid = *keyID
}
_, _ = db.ExecContext(ctx, `
INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`, svcID, kid, code, msg, severity, status)
}
// handleGatewayProxy is mounted at /g/:slug and /g/:slug/*path on the root
// router — outside /api/v1 so route_prefix stays clean.
func handleGatewayProxy(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context()
slug := c.Param("slug")
svc, err := lookupGatewayService(ctx, db, slug)
if err == sql.ErrNoRows {
respondError(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Gateway service not found")
return
}
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to resolve gateway service")
return
}
if !svc.Enabled {
respondError(c, http.StatusServiceUnavailable, "SERVICE_DISABLED", "Gateway service is disabled")
return
}
presented := extractGatewayKey(c)
if presented == "" {
recordGatewayIncident(ctx, db, svc.ID, nil, "MISSING_API_KEY", "Request without API key", "low", http.StatusUnauthorized)
respondError(c, http.StatusUnauthorized, "API_KEY_REQUIRED", "An API key is required for this service")
return
}
key, err := authenticateGatewayKey(ctx, db, presented)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to authenticate API key")
return
}
// Disabled and unknown keys collapse to the same 401 — a disabled key
// must not leak its existence.
if key == nil || !key.Enabled {
recordGatewayIncident(ctx, db, svc.ID, nil, "INVALID_API_KEY", "Request with invalid or disabled API key", "low", http.StatusUnauthorized)
respondError(c, http.StatusUnauthorized, "INVALID_API_KEY", "Invalid API key")
return
}
if !keyAllowsService(key, svc.ID) {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "KEY_SCOPE_DENIED", "API key not allowed on this service", "medium", http.StatusForbidden)
respondError(c, http.StatusForbidden, "KEY_SCOPE_DENIED", "API key is not permitted for this service")
return
}
now := time.Now()
if !gatewayRateAllow("key:"+key.ID, int(key.RPMLimit.Int64), now) ||
!gatewayRateAllow("svc:"+svc.ID, int(svc.RPMLimit.Int64), now) {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "RATE_LIMITED", "Per-minute rate limit exceeded", "medium", http.StatusTooManyRequests)
respondError(c, http.StatusTooManyRequests, "RATE_LIMITED", "Rate limit exceeded")
return
}
period := now.Format("2006-01")
if key.MonthlyQuota.Valid && key.MonthlyQuota.Int64 > 0 {
if used, err := monthlyUsage(ctx, db, "api_key_id", key.ID, period); err == nil && int64(used) >= key.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Monthly request quota exceeded")
return
}
}
if svc.MonthlyQuota.Valid && svc.MonthlyQuota.Int64 > 0 {
if used, err := monthlyUsage(ctx, db, "service_id", svc.ID, period); err == nil && int64(used) >= svc.MonthlyQuota.Int64 {
respondError(c, http.StatusTooManyRequests, "QUOTA_EXCEEDED", "Service monthly quota exceeded")
return
}
}
target, err := gateway.BuildTargetURL(svc.UpstreamURL, svc.RoutePrefix, c.Request.URL.Path, c.Request.URL.RawQuery)
if err != nil {
respondError(c, http.StatusBadGateway, "UPSTREAM_URL_INVALID", "Gateway upstream is misconfigured")
return
}
headers := map[string]string{}
if svc.UpstreamAuthHeader.Valid && svc.UpstreamAuthHeader.String != "" {
headers[svc.UpstreamAuthHeader.String] = svc.UpstreamAuthValue.String
}
timeout := time.Duration(svc.RequestTimeoutMS) * time.Millisecond
if timeout <= 0 {
timeout = 8 * time.Second
}
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
status, _, err := gateway.ProxyRequest(gatewayHTTPClient, c.Writer, c.Request.WithContext(reqCtx), target, headers)
if err != nil {
recordGatewayIncident(reqCtx, db, svc.ID, &key.ID, "UPSTREAM_ERROR", err.Error(), "high", http.StatusBadGateway)
recordGatewayRequest(ctx, db, svc, key, http.StatusBadGateway, period)
respondError(c, http.StatusBadGateway, "UPSTREAM_ERROR", "Upstream request failed")
return
}
recordGatewayRequest(ctx, db, svc, key, status, period)
if status >= 500 {
recordGatewayIncident(ctx, db, svc.ID, &key.ID, "UPSTREAM_5XX", "Upstream returned "+http.StatusText(status), "high", status)
}
}
@@ -0,0 +1,81 @@
package api
import (
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestGatewayRateAllow(t *testing.T) {
now := time.Date(2026, 10, 15, 12, 0, 30, 0, time.UTC)
for i := 0; i < 3; i++ {
if !gatewayRateAllow("test:a", 3, now) {
t.Fatalf("call %d within limit rejected", i+1)
}
}
if gatewayRateAllow("test:a", 3, now) {
t.Fatal("call beyond limit allowed")
}
if !gatewayRateAllow("test:a", 3, now.Add(time.Minute)) {
t.Fatal("new minute window still rejected")
}
if !gatewayRateAllow("test:unlimited", 0, now) {
t.Fatal("non-positive limit should disable the check")
}
}
func TestExtractGatewayKey(t *testing.T) {
mkctx := func(headers map[string]string) *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/g/x", nil)
for k, v := range headers {
c.Request.Header.Set(k, v)
}
return c
}
if got := extractGatewayKey(mkctx(map[string]string{"X-API-Key": "ap_abc"})); got != "ap_abc" {
t.Fatalf("X-API-Key not extracted: %q", got)
}
if got := extractGatewayKey(mkctx(map[string]string{"Authorization": "Bearer ap_def"})); got != "ap_def" {
t.Fatalf("Bearer not extracted: %q", got)
}
// X-API-Key wins when both are present.
if got := extractGatewayKey(mkctx(map[string]string{
"X-API-Key": "ap_first",
"Authorization": "Bearer ap_second",
})); got != "ap_first" {
t.Fatalf("X-API-Key should take precedence: %q", got)
}
if got := extractGatewayKey(mkctx(map[string]string{})); got != "" {
t.Fatalf("expected empty key, got %q", got)
}
}
func TestKeyAllowsService(t *testing.T) {
svcID := "11111111-2222-3333-4444-555555555555"
other := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
cases := []struct {
name string
allowed string
want bool
}{
{"empty list permits all", "[]", true},
{"blank permits all", "", true},
{"invalid json permits all", "not-json", true},
{"listed service allowed", `["` + svcID + `"]`, true},
{"unlisted service denied", `["` + other + `"]`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
k := &gatewayKey{AllowedServiceIDs: tc.allowed}
if got := keyAllowsService(k, svcID); got != tc.want {
t.Fatalf("keyAllowsService(%q) = %v, want %v", tc.allowed, got, tc.want)
}
})
}
}
+301 -96
View File
@@ -2,7 +2,12 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/deployment"
"containr/internal/deployqueue"
"context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -26,6 +31,10 @@ type PreviewEnvironment struct {
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// PreviewServiceID is the cloned service this preview actually deploys;
// its status is the source of truth while the preview is active.
PreviewServiceID *uuid.UUID `json:"preview_service_id,omitempty" db:"preview_service_id"`
// Related data // Related data
Service *Service `json:"service,omitempty"` Service *Service `json:"service,omitempty"`
DeploymentID *uuid.UUID `json:"deployment_id,omitempty"` DeploymentID *uuid.UUID `json:"deployment_id,omitempty"`
@@ -78,12 +87,12 @@ func handleGetPreviewEnvironments(c *gin.Context) {
// Get preview environments for the project with service info // Get preview environments for the project with service info
rows, err := db.(*database.DB).Query( rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number, `SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at, pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id LEFT JOIN services s ON pe.service_id = s.id
WHERE pe.project_id = $1 WHERE pe.project_id = $1
ORDER BY pe.created_at DESC`, ORDER BY pe.created_at DESC`,
projectID, projectID,
) )
@@ -101,7 +110,7 @@ func handleGetPreviewEnvironments(c *gin.Context) {
var serviceType sql.NullString var serviceType sql.NullString
err := rows.Scan( err := rows.Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber, &env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt, &env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
&serviceID, &serviceName, &serviceType, &serviceID, &serviceName, &serviceType,
) )
@@ -215,87 +224,111 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
ttlHours = 24 // Default 24 hours ttlHours = 24 // Default 24 hours
} }
// Create preview environment // A preview is a real service: clone the source, pin it to the
// requested branch, swap its domains for a preview hostname, and run a
// normal deployment through the queue. Expiry sweeps the clone away.
envName := generatePreviewEnvironmentName(req.BranchName)
cloneName := previewServiceName(service.Name, req.BranchName)
cloneID, err := cloneServiceRow(db.(*database.DB), req.ServiceID, req.ProjectID, cloneName, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service for preview"})
return
}
// The clone's branch override is what makes it a preview — everything
// else (env vars, volumes, builder, resources) stays identical.
if _, err := db.(*database.DB).Exec(
`UPDATE services SET git_branch = $1, domain = '', updated_at = NOW() WHERE id = $2`,
req.BranchName, cloneID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview service"})
return
}
// Cloned service_domains would collide with the parent's Traefik
// router — previews get their own hostname or none.
if _, err := db.(*database.DB).Exec(
`DELETE FROM service_domains WHERE service_id = $1`, cloneID,
); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview domains"})
return
}
previewHost := resolvePreviewHost(db.(*database.DB), cloneID, envName)
if previewHost != "" {
_, _ = db.(*database.DB).Exec(
`INSERT INTO service_domains (service_id, domain, is_default, cert_type) VALUES ($1, $2, true, 'tls')`,
cloneID, previewHost,
)
}
expiresAt := time.Now().Add(time.Duration(ttlHours) * time.Hour)
env := PreviewEnvironment{ env := PreviewEnvironment{
ID: uuid.New(), ID: uuid.New(),
ProjectID: req.ProjectID, ProjectID: req.ProjectID,
ServiceID: req.ServiceID, ServiceID: req.ServiceID,
BranchName: req.BranchName, BranchName: req.BranchName,
PRNumber: req.PRNumber, PRNumber: req.PRNumber,
Environment: generatePreviewEnvironmentName(req.BranchName), Environment: envName,
Status: "building", Status: "building",
ExpiresAt: &[]time.Time{time.Now().Add(time.Duration(ttlHours) * time.Hour)}[0], ExpiresAt: &expiresAt,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
if previewHost != "" {
env.URL = "https://" + previewHost
}
// Insert preview environment into database
_, err = db.(*database.DB).Exec( _, err = db.(*database.DB).Exec(
`INSERT INTO preview_environments `INSERT INTO preview_environments
(id, project_id, service_id, branch_name, pr_number, environment, (id, project_id, service_id, preview_service_id, branch_name, pr_number, environment,
status, url, expires_at, created_at, updated_at) status, url, expires_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
env.ID, env.ProjectID, env.ServiceID, env.BranchName, env.PRNumber, env.ID, env.ProjectID, env.ServiceID, cloneID, env.BranchName, env.PRNumber,
env.Environment, env.Status, env.URL, env.ExpiresAt, env.CreatedAt, env.UpdatedAt, env.Environment, env.Status, env.URL, env.ExpiresAt, env.CreatedAt, env.UpdatedAt,
) )
if err != nil { if err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create preview environment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create preview environment"})
return return
} }
deploymentID := uuid.New() deploymentID := uuid.New()
sanitizedBranch := strings.ReplaceAll(req.BranchName, "/", "-") _, err = db.(*database.DB).Exec(
sanitizedBranch = strings.ReplaceAll(sanitizedBranch, "_", "-")
version := fmt.Sprintf("preview-%s-%d", sanitizedBranch, time.Now().Unix())
startedAt := time.Now().UTC()
completedAt := startedAt
_, deployErr := db.(*database.DB).Exec(
`INSERT INTO deployments `INSERT INTO deployments
(id, service_id, version, commit_hash, status, started_at, completed_at, deployment_log, created_at, updated_at) (id, service_id, version, commit_hash, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())`, VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())`,
deploymentID, deploymentID, cloneID, "preview-"+env.Environment, req.BranchName,
req.ServiceID,
version,
req.BranchName,
"running",
startedAt,
completedAt,
fmt.Sprintf("Preview environment %s activated for branch %s", env.Environment, req.BranchName),
) )
if deployErr != nil { if err != nil {
_, _ = db.(*database.DB).Exec( _, _ = db.(*database.DB).Exec(`DELETE FROM preview_environments WHERE id = $1`, env.ID)
`UPDATE preview_environments _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID)
SET status = 'failed', updated_at = NOW()
WHERE id = $1`,
env.ID,
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to provision preview deployment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to provision preview deployment"})
return return
} }
if strings.TrimSpace(env.URL) == "" {
env.URL = fmt.Sprintf("https://%s.preview.containr.local", env.Environment)
}
env.Status = "running"
env.UpdatedAt = time.Now().UTC()
env.DeploymentID = &deploymentID env.DeploymentID = &deploymentID
_, err = db.(*database.DB).Exec( if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
`UPDATE preview_environments if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
SET status = $1, url = $2, updated_at = $3 cloneService, loadErr := loadServiceForDeploy(db.(*database.DB), cloneID)
WHERE id = $4`, if loadErr == nil {
env.Status, engineInstance := engine
env.URL, d := DeploymentModel{ID: deploymentID, ServiceID: cloneID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()}
env.UpdatedAt, getDeployQueue(c).Enqueue(cloneID, deployqueue.Job{
env.ID, DeploymentID: deploymentID,
) Run: func(jctx context.Context) {
if err != nil { runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, cloneService, CreateDeploymentRequest{Branch: req.BranchName, Trigger: "preview"}, userID.(string))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to finalize preview environment"}) },
return })
}
}
} }
LogAuditWithRequest(c, "service", req.ServiceID.String(), "preview.create", map[string]interface{}{
"preview_environment_id": env.ID.String(),
"preview_service_id": cloneID.String(),
"branch": req.BranchName,
})
c.JSON(http.StatusCreated, gin.H{"preview_environment": env}) c.JSON(http.StatusCreated, gin.H{"preview_environment": env})
} }
@@ -321,7 +354,7 @@ func handleGetPreviewEnvironment(c *gin.Context) {
var serviceName sql.NullString var serviceName sql.NullString
var serviceType sql.NullString var serviceType sql.NullString
err = db.(*database.DB).QueryRow( err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number, `SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at, pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe FROM preview_environments pe
@@ -331,7 +364,7 @@ func handleGetPreviewEnvironment(c *gin.Context) {
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`, OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
envID, userID, contextIsAdmin(c), envID, userID, contextIsAdmin(c),
).Scan( ).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber, &env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt, &env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
&serviceID, &serviceName, &serviceType, &serviceID, &serviceName, &serviceType,
) )
@@ -480,22 +513,19 @@ func handleDeletePreviewEnvironment(c *gin.Context) {
return return
} }
cleanupID := uuid.New() // Tear down the clone's runtime and row — the preview is the clone.
cleanupStartedAt := time.Now().UTC() var cloneID *uuid.UUID
cleanupCompletedAt := cleanupStartedAt _ = db.(*database.DB).QueryRow(
`SELECT preview_service_id FROM preview_environments WHERE id = $1`, envID,
_, _ = db.(*database.DB).Exec( ).Scan(&cloneID)
`INSERT INTO deployments if cloneID != nil {
(id, service_id, version, status, started_at, completed_at, deployment_log, created_at, updated_at) if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
VALUES ($1, (SELECT service_id FROM preview_environments WHERE id = $2), $3, $4, $5, $6, $7, NOW(), NOW())`, if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
cleanupID, removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *cloneID)
envID, }
"preview-cleanup", }
"rolled_back", _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *cloneID)
cleanupStartedAt, }
cleanupCompletedAt,
"Preview environment resources cleaned up",
)
// Delete preview environment // Delete preview environment
_, err = db.(*database.DB).Exec( _, err = db.(*database.DB).Exec(
@@ -542,13 +572,13 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
// Get preview environment details // Get preview environment details
var env PreviewEnvironment var env PreviewEnvironment
err = db.(*database.DB).QueryRow( err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status `SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.environment, pe.status
FROM preview_environments pe FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`, WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
envID, userID, contextIsAdmin(c), envID, userID, contextIsAdmin(c),
).Scan( ).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status, &env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.Environment, &env.Status,
) )
if err != nil { if err != nil {
@@ -562,38 +592,53 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
return return
} }
// Promote is a real redeploy: the source service builds the preview's
// branch onto its own deployment track.
deploymentID := uuid.New() deploymentID := uuid.New()
promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix()) promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix())
_, err = db.(*database.DB).Exec( _, err = db.(*database.DB).Exec(
`INSERT INTO deployments (id, service_id, version, status, created_at, updated_at) `INSERT INTO deployments (id, service_id, version, commit_hash, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())`, VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())`,
deploymentID, env.ServiceID, promotionVersion, "pending", deploymentID, env.ServiceID, promotionVersion, env.BranchName,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"})
return return
} }
if _, err := db.(*database.DB).Exec( sourceService, loadErr := loadServiceForDeploy(db.(*database.DB), env.ServiceID)
`UPDATE services if loadErr != nil {
SET environment = $1, updated_at = NOW() c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load promotion target service"})
WHERE id = $2`,
req.TargetEnvironment, env.ServiceID,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service target environment"})
return return
} }
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
engineInstance := engine
d := DeploymentModel{ID: deploymentID, ServiceID: env.ServiceID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()}
getDeployQueue(c).Enqueue(env.ServiceID, deployqueue.Job{
DeploymentID: deploymentID,
Run: func(jctx context.Context) {
runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, sourceService, CreateDeploymentRequest{Branch: env.BranchName, Trigger: "promote"}, userID.(string))
},
})
}
}
previewStatus := "stopped" // Promotion retires the preview — the clone's containers and row go.
if req.CreateBackup { if env.PreviewServiceID != nil {
previewStatus = "expired" if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *env.PreviewServiceID)
}
}
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *env.PreviewServiceID)
} }
if _, err := db.(*database.DB).Exec( if _, err := db.(*database.DB).Exec(
`UPDATE preview_environments `UPDATE preview_environments
SET status = $1, updated_at = NOW() SET status = 'stopped', updated_at = NOW()
WHERE id = $2`, WHERE id = $1`,
previewStatus, env.ID, env.ID,
); err != nil { ); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment status"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment status"})
return return
@@ -608,7 +653,7 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
"create_backup": req.CreateBackup, "create_backup": req.CreateBackup,
"deployment_id": deploymentID, "deployment_id": deploymentID,
"status": "queued", "status": "queued",
"preview_status": previewStatus, "preview_status": "stopped",
}, },
}) })
} }
@@ -639,7 +684,7 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
// Find expired preview environments for user's projects // Find expired preview environments for user's projects
rows, err := db.(*database.DB).Query( rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment `SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.environment
FROM preview_environments pe FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id JOIN projects p ON pe.project_id = p.id
WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`, WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`,
@@ -655,7 +700,7 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
for rows.Next() { for rows.Next() {
var env PreviewEnvironment var env PreviewEnvironment
err := rows.Scan( err := rows.Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.Environment,
) )
if err != nil { if err != nil {
continue continue
@@ -663,10 +708,9 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
expiredEnvs = append(expiredEnvs, env) expiredEnvs = append(expiredEnvs, env)
} }
// Mark expired environments as expired and trigger cleanup // Mark expired environments as expired and tear down their clones
cleanupCount := 0 cleanupCount := 0
for _, env := range expiredEnvs { for _, env := range expiredEnvs {
// Update status to expired
_, err := db.(*database.DB).Exec( _, err := db.(*database.DB).Exec(
"UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1", "UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1",
env.ID, env.ID,
@@ -674,7 +718,14 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
if err != nil { if err != nil {
continue continue
} }
if env.PreviewServiceID != nil {
if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil {
if engine, ok := engineValue.(*deployment.DeploymentEngine); ok {
removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *env.PreviewServiceID)
}
}
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *env.PreviewServiceID)
}
cleanupCount++ cleanupCount++
} }
@@ -684,3 +735,157 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
"expired_environments": expiredEnvs, "expired_environments": expiredEnvs,
}) })
} }
// previewServiceName builds the clone's service name — deterministic enough
// to spot in lists, short enough for dnsLabel-derived hostnames.
func previewServiceName(serviceName, branch string) string {
label := dnsLabel(serviceName + "-" + branch)
if label == "" {
return "preview-" + uuid.NewString()[:8]
}
return label + "-preview"
}
// resolvePreviewHost picks a preview hostname under the clone's node default
// domain — same source as serviceAutoDomains. Returns "" when no node
// carries a default domain; the preview still runs, reachable internally.
func resolvePreviewHost(db *database.DB, cloneID uuid.UUID, envName string) string {
label := dnsLabel(envName)
if label == "" {
return ""
}
var nodeID sql.NullString
var spread bool
if err := db.QueryRow(
`SELECT node_id, COALESCE(spread, false) FROM services WHERE id = $1`, cloneID,
).Scan(&nodeID, &spread); err != nil {
return ""
}
q := sqlcdb.New(db.DB)
ctx := context.Background()
var base string
switch {
case nodeID.Valid && nodeID.String != "":
if a, err := q.GetAgent(ctx, nodeID.String); err == nil {
base = a.DefaultDomain
}
case spread:
if bases, err := q.ListSchedulableAgentDomains(ctx, json.RawMessage(tagsJSON(servicePlacementTags(db, cloneID)))); err == nil && len(bases) > 0 {
base = bases[0]
}
default:
// Local preview: borrow the first node default domain if any —
// still better than no hostname for a Traefik-fronted local host.
if bases, err := q.ListSchedulableAgentDomains(ctx, json.RawMessage("[]")); err == nil && len(bases) > 0 {
base = bases[0]
}
}
if base == "" {
return ""
}
return label + "." + base
}
// loadServiceForDeploy hydrates the Service fields runDeploymentAndSync
// needs — mirrors the loader in handleDeployService.
func loadServiceForDeploy(db *database.DB, serviceID uuid.UUID) (Service, error) {
var service Service
err := db.QueryRow(
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''),
COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''),
COALESCE(s.static_dir, ''),
s.created_at, s.updated_at
FROM services s
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.Builder, &service.CPUReserve,
&service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir,
&service.CreatedAt, &service.UpdatedAt,
)
return service, err
}
// StartPreviewSweeper expires preview environments past their TTL — tears
// down each clone's runtime and row, marks the preview expired.
func StartPreviewSweeper(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine) {
if db == nil || engine == nil {
return
}
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
sweepExpiredPreviews(ctx, db, engine)
syncPreviewStatuses(ctx, db)
}
}
}()
}
// sweepExpiredPreviews retires previews whose TTL elapsed.
func sweepExpiredPreviews(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine) {
rows, err := db.QueryContext(ctx,
`SELECT id, preview_service_id FROM preview_environments
WHERE expires_at < NOW() AND status NOT IN ('expired', 'stopped')`)
if err != nil {
return
}
var ids []uuid.UUID
var clones []uuid.UUID
for rows.Next() {
var id uuid.UUID
var cloneID *uuid.UUID
if err := rows.Scan(&id, &cloneID); err == nil {
ids = append(ids, id)
if cloneID != nil {
clones = append(clones, *cloneID)
}
}
}
rows.Close()
for _, cloneID := range clones {
removeServiceRuntime(ctx, db, engine, cloneID)
_, _ = db.ExecContext(ctx, `DELETE FROM services WHERE id = $1`, cloneID)
}
for _, id := range ids {
_, _ = db.ExecContext(ctx,
`UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1`, id)
}
}
// syncPreviewStatuses folds the clone's live service status into the
// preview row so list/detail endpoints reflect the real deploy state.
func syncPreviewStatuses(ctx context.Context, db *database.DB) {
_, _ = db.ExecContext(ctx, `
UPDATE preview_environments pe
SET status = derived.st, updated_at = NOW()
FROM (
SELECT pe2.id,
CASE
WHEN s.status IN ('running', 'deployed') THEN 'running'
WHEN s.status IN ('failed', 'error') THEN 'failed'
WHEN s.status IN ('building', 'deploying', 'queued', 'pending', 'cloning') THEN 'building'
ELSE pe2.status
END AS st
FROM preview_environments pe2
JOIN services s ON pe2.preview_service_id = s.id
) derived
WHERE pe.id = derived.id
AND pe.status IN ('building', 'running', 'failed')
AND pe.status <> derived.st
`)
}
@@ -0,0 +1,30 @@
package api
import (
"strings"
"testing"
)
func TestPreviewServiceName(t *testing.T) {
cases := []struct {
svc, branch string
want string
}{
{"whoami-web", "feature-x", "whoami-web-feature-x-preview"},
{"My App", "feat/JARV-123_foo", "my-app-feat-jarv-123-foo-preview"},
{"svc", "", "svc-preview"},
}
for _, tc := range cases {
got := previewServiceName(tc.svc, tc.branch)
if got != tc.want {
t.Fatalf("previewServiceName(%q,%q) = %q, want %q", tc.svc, tc.branch, got, tc.want)
}
if len(got) > 63 {
t.Fatalf("name exceeds DNS label limit: %q", got)
}
}
// A name that sanitizes to nothing must still be usable.
if got := previewServiceName("!!!", "%%%"); !strings.HasPrefix(got, "preview-") {
t.Fatalf("expected fallback name, got %q", got)
}
}
+7
View File
@@ -111,6 +111,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Sleep sweeper — scales idle services to zero and prunes wake containers. // Sleep sweeper — scales idle services to zero and prunes wake containers.
StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port) StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port)
StartPreviewSweeper(context.Background(), db, deploymentEngine)
// Audit retention — prunes rows past AUDIT_RETENTION_DAYS (default 90). // Audit retention — prunes rows past AUDIT_RETENTION_DAYS (default 90).
StartAuditRetention(context.Background(), db) StartAuditRetention(context.Background(), db)
@@ -269,6 +270,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// endpoint, verified against the provisioned webhook secret. // endpoint, verified against the provisioned webhook secret.
router.POST("/api/git/github-app/webhook", handleGitHubAppWebhook) router.POST("/api/git/github-app/webhook", handleGitHubAppWebhook)
// Public API gateway data plane. Lives outside /api/v1 so each service's
// route_prefix (/g/<slug>) maps cleanly; auth is the caller's api_key,
// not a session.
router.Any("/g/:slug", handleGatewayProxy)
router.Any("/g/:slug/*path", handleGatewayProxy)
v1 := router.Group("/api/v1") v1 := router.Group("/api/v1")
{ {
// Public routes (no authentication required) // Public routes (no authentication required)
+2
View File
@@ -659,6 +659,8 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
persistPublishedPort(db, service.ID, state.Ports) persistPublishedPort(db, service.ID, state.Ports)
if service.Domain != "" { if service.Domain != "" {
service.PublicURL = "https://" + service.Domain service.PublicURL = "https://" + service.Domain
} else if auto := serviceAutoDomains(db, service.ID); len(auto) > 0 {
service.PublicURL = "https://" + auto[0]
} else if len(state.URLs) > 0 { } else if len(state.URLs) > 0 {
service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c)) service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c))
} }
+42 -24
View File
@@ -67,22 +67,45 @@ func handleCloneService(c *gin.Context) {
if req.Environment != "" { if req.Environment != "" {
environment = req.Environment environment = req.Environment
} }
if environment == "" {
environment = "production" newID, err := cloneServiceRow(db.(*database.DB), source.ID, targetProjectID, name, environment)
}
environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"})
return return
} }
maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID) LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
}
// cloneServiceRow copies a service's full configuration — source, build
// settings, volumes, access gates, domains, and variables — under a new id
// in the target project. The clone starts stopped. An empty environment
// inherits the source's. Used by the clone endpoint and preview
// environments, which additionally override git_branch and domains.
func cloneServiceRow(db *database.DB, sourceID uuid.UUID, targetProjectID uuid.UUID, name, environment string) (uuid.UUID, error) {
var sourceEnv string
if err := db.QueryRow(`SELECT COALESCE(environment, '') FROM services WHERE id = $1`, sourceID).Scan(&sourceEnv); err != nil {
return uuid.Nil, err
}
if environment == "" {
environment = sourceEnv
}
if environment == "" {
environment = "production"
}
environmentID, err := getProjectEnvironmentID(db, targetProjectID, environment)
if err != nil {
return uuid.Nil, err
}
maintenance, basicAuth := serviceAccess(db, sourceID)
newID := uuid.New() newID := uuid.New()
now := time.Now() now := time.Now()
// string() so pq sends text — []byte would go as bytea and fail ::jsonb. // string() so pq sends text — []byte would go as bytea and fail ::jsonb.
volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID)) volumesJSON := string(loadServiceVolumesJSON(db, sourceID))
_, err = db.(*database.DB).Exec( _, err = db.Exec(
`INSERT INTO services `INSERT INTO services
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name, (id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
build_command, start_command, type, status, image, command, environment, build_command, start_command, type, status, image, command, environment,
@@ -98,35 +121,30 @@ func handleCloneService(c *gin.Context) {
$9, $9 $9, $9
FROM services WHERE id = $10`, FROM services WHERE id = $10`,
newID, targetProjectID, name, environmentID, environment, volumesJSON, newID, targetProjectID, name, environmentID, environment, volumesJSON,
maintenance, basicAuth, now, source.ID, maintenance, basicAuth, now, sourceID,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"}) return uuid.Nil, err
return
} }
if _, err := db.(*database.DB).Exec( if _, err := db.Exec(
`INSERT INTO service_domains (service_id, domain, is_default, cert_type) `INSERT INTO service_domains (service_id, domain, is_default, cert_type)
SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`, SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`,
newID, source.ID, newID, sourceID,
); err != nil { ); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID) _, _ = db.Exec(`DELETE FROM services WHERE id = $1`, newID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"}) return uuid.Nil, err
return
} }
// Variable values are copied as stored — ciphertext stays ciphertext. // Variable values are copied as stored — ciphertext stays ciphertext.
if _, err := db.(*database.DB).Exec( if _, err := db.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret) `INSERT INTO environment_variables (id, service_id, key, value, is_secret)
SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`, SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`,
newID, source.ID, newID, sourceID,
); err != nil { ); err != nil {
_, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID) _, _ = db.Exec(`DELETE FROM services WHERE id = $1`, newID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"}) return uuid.Nil, err
return
} }
return newID, nil
LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name})
c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"})
} }
type MoveServiceRequest struct { type MoveServiceRequest struct {
+6 -2
View File
@@ -84,7 +84,7 @@ var nodesDeleteCmd = &cobra.Command{
var nodesUpdateCmd = &cobra.Command{ var nodesUpdateCmd = &cobra.Command{
Use: "update <id>", Use: "update <id>",
Short: "Update a node agent (--name, --auto-prune, --tags)", Short: "Update a node agent (--name, --auto-prune, --tags, --domain)",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := client() c, err := client()
@@ -108,8 +108,11 @@ var nodesUpdateCmd = &cobra.Command{
} }
body["tags"] = tags body["tags"] = tags
} }
if cmd.Flags().Changed("domain") {
body["default_domain"], _ = cmd.Flags().GetString("domain")
}
if len(body) == 0 { if len(body) == 0 {
return &APIError{Message: "nothing to update — pass --name, --auto-prune or --tags", ExitCode: ExitError} return &APIError{Message: "nothing to update — pass --name, --auto-prune, --tags or --domain", ExitCode: ExitError}
} }
data, err := c.Do("PUT", "/agents/"+args[0], body) data, err := c.Do("PUT", "/agents/"+args[0], body)
if err != nil { if err != nil {
@@ -304,6 +307,7 @@ func init() {
nodesUpdateCmd.Flags().String("name", "", "node display name") nodesUpdateCmd.Flags().String("name", "", "node display name")
nodesUpdateCmd.Flags().Bool("auto-prune", false, "enqueue a daily bounded docker prune") nodesUpdateCmd.Flags().Bool("auto-prune", false, "enqueue a daily bounded docker prune")
nodesUpdateCmd.Flags().String("tags", "", "comma-separated placement tags (services with placement_tags only schedule onto matching nodes)") nodesUpdateCmd.Flags().String("tags", "", "comma-separated placement tags (services with placement_tags only schedule onto matching nodes)")
nodesUpdateCmd.Flags().String("domain", "", "base domain for this node — domainless services placed here get <name>.<domain> automatically (empty clears)")
nodesPruneCmd.Flags().String("until", "", "only prune objects older than this duration (e.g. 168h)") nodesPruneCmd.Flags().String("until", "", "only prune objects older than this duration (e.g. 168h)")
nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)") nodesPruneCmd.Flags().Bool("volumes", false, "also prune volumes (destructive — asks twice)")
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd) nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
@@ -75,7 +75,7 @@ INSERT INTO node_agents (
id, name, hostname, ip_address, port, status, version, id, name, hostname, ip_address, port, status, version,
capabilities, resources, last_heartbeat, metadata capabilities, resources, last_heartbeat, metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain
` `
type CreateAgentParams struct { type CreateAgentParams struct {
@@ -124,6 +124,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeA
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
) )
return i, err return i, err
} }
@@ -258,7 +259,7 @@ func (q *Queries) DeleteServiceContainersOnAgent(ctx context.Context, arg Delete
} }
const getAgent = `-- name: GetAgent :one const getAgent = `-- name: GetAgent :one
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents WHERE id = $1 SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents WHERE id = $1
` `
func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) { func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
@@ -281,12 +282,13 @@ func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
) )
return i, err return i, err
} }
const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one const getAgentByHostAndIP = `-- name: GetAgentByHostAndIP :one
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents
WHERE hostname = $1 AND ip_address = $2 WHERE hostname = $1 AND ip_address = $2
LIMIT 1 LIMIT 1
` `
@@ -316,6 +318,7 @@ func (q *Queries) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAnd
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
) )
return i, err return i, err
} }
@@ -523,7 +526,7 @@ func (q *Queries) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHea
} }
const listAgents = `-- name: ListAgents :many const listAgents = `-- name: ListAgents :many
SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags FROM node_agents ORDER BY created_at ASC SELECT id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain FROM node_agents ORDER BY created_at ASC
` `
func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) { func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
@@ -552,6 +555,7 @@ func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -695,6 +699,39 @@ func (q *Queries) ListPendingCommands(ctx context.Context, nodeAgentID string) (
return items, nil return items, nil
} }
const listSchedulableAgentDomains = `-- name: ListSchedulableAgentDomains :many
SELECT DISTINCT default_domain FROM node_agents
WHERE status = 'online' AND schedulable
AND default_domain <> ''
AND tags @> $1::jsonb
ORDER BY default_domain
`
// Base domains of every online, schedulable agent carrying the required
// placement tags — auto-domain candidates for spread services.
func (q *Queries) ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listSchedulableAgentDomains, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var default_domain string
if err := rows.Scan(&default_domain); err != nil {
return nil, err
}
items = append(items, default_domain)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSchedulableAgents = `-- name: ListSchedulableAgents :many const listSchedulableAgents = `-- name: ListSchedulableAgents :many
SELECT id, name FROM node_agents SELECT id, name FROM node_agents
WHERE status = 'online' AND schedulable WHERE status = 'online' AND schedulable
@@ -910,6 +947,20 @@ func (q *Queries) ScrubCommandPayload(ctx context.Context, arg ScrubCommandPaylo
return err return err
} }
const setAgentDefaultDomain = `-- name: SetAgentDefaultDomain :exec
UPDATE node_agents SET default_domain = $2, updated_at = NOW() WHERE id = $1
`
type SetAgentDefaultDomainParams struct {
ID string `json:"id"`
DefaultDomain string `json:"default_domain"`
}
func (q *Queries) SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error {
_, err := q.db.ExecContext(ctx, setAgentDefaultDomain, arg.ID, arg.DefaultDomain)
return err
}
const setAgentSchedulable = `-- name: SetAgentSchedulable :exec const setAgentSchedulable = `-- name: SetAgentSchedulable :exec
UPDATE node_agents SET schedulable = $2, updated_at = NOW() WHERE id = $1 UPDATE node_agents SET schedulable = $2, updated_at = NOW() WHERE id = $1
` `
@@ -954,7 +1005,7 @@ UPDATE node_agents SET
schedulable = $13, schedulable = $13,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags RETURNING id, name, hostname, ip_address, port, status, version, capabilities, resources, last_heartbeat, created_at, updated_at, metadata, auto_prune, schedulable, tags, default_domain
` `
type UpdateAgentParams struct { type UpdateAgentParams struct {
@@ -1007,6 +1058,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
&i.AutoPrune, &i.AutoPrune,
&i.Schedulable, &i.Schedulable,
&i.Tags, &i.Tags,
&i.DefaultDomain,
) )
return i, err return i, err
} }
@@ -178,6 +178,7 @@ type NodeAgent struct {
AutoPrune bool `json:"auto_prune"` AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"` Schedulable bool `json:"schedulable"`
Tags json.RawMessage `json:"tags"` Tags json.RawMessage `json:"tags"`
DefaultDomain string `json:"default_domain"`
} }
type Notification struct { type Notification struct {
@@ -279,6 +280,7 @@ type Service struct {
NodeID sql.NullString `json:"node_id"` NodeID sql.NullString `json:"node_id"`
Spread bool `json:"spread"` Spread bool `json:"spread"`
PlacementTags json.RawMessage `json:"placement_tags"` PlacementTags json.RawMessage `json:"placement_tags"`
TraefikLabels json.RawMessage `json:"traefik_labels"`
} }
type ServiceDomain struct { type ServiceDomain struct {
@@ -95,6 +95,9 @@ type Querier interface {
// display, when the caller owns or is a member of it, or when the caller is a // display, when the caller owns or is a member of it, or when the caller is a
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false. // platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
// Base domains of every online, schedulable agent carrying the required
// placement tags — auto-domain candidates for spread services.
ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error)
ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error) ListSchedulableAgents(ctx context.Context) ([]ListSchedulableAgentsRow, error)
// Only agents carrying every required placement tag (jsonb array containment). // Only agents carrying every required placement tag (jsonb array containment).
ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error) ListSchedulableAgentsMatching(ctx context.Context, dollar_1 json.RawMessage) ([]ListSchedulableAgentsMatchingRow, error)
@@ -118,6 +121,7 @@ type Querier interface {
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error
SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error
SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error
SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
@@ -0,0 +1,64 @@
package gateway
import "testing"
func TestBuildTargetURL(t *testing.T) {
cases := []struct {
name string
upstream, prefix, path, query, want string
}{
{
name: "strips route prefix",
upstream: "https://api.internal:8080",
prefix: "/g/weather",
path: "/g/weather/v1/current",
query: "city=oslo",
want: "https://api.internal:8080/v1/current?city=oslo",
},
{
name: "prefix root maps to upstream root",
upstream: "https://api.internal",
prefix: "/g/weather",
path: "/g/weather",
want: "https://api.internal/",
},
{
name: "upstream base path is preserved",
upstream: "https://api.internal/base",
prefix: "/g/x",
path: "/g/x/ping",
want: "https://api.internal/base/ping",
},
{
name: "root prefix leaves path untouched",
upstream: "https://api.internal",
prefix: "/",
path: "/g/raw",
want: "https://api.internal/g/raw",
},
{
name: "path without prefix is still proxied",
upstream: "https://api.internal",
prefix: "/g/x",
path: "/other",
want: "https://api.internal/other",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := BuildTargetURL(tc.upstream, tc.prefix, tc.path, tc.query)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
func TestBuildTargetURL_InvalidUpstream(t *testing.T) {
if _, err := BuildTargetURL("://bad", "/g/x", "/g/x/y", ""); err == nil {
t.Fatal("expected error for unparsable upstream")
}
}
+1 -1
View File
@@ -174,7 +174,7 @@ func init() {
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false}) register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false}) register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false})
register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false}) register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false})
register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name, tags (string array — placement labels services can require via placement_tags; [] clears).", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name", "tags"}, false}) register(spec{"containr_nodes_update", "Update a node agent (admin). Args: id; optional auto_prune, name, tags (string array — placement labels services can require via placement_tags; [] clears), default_domain (base domain — services placed on this node with no explicit domains get <name>.<default_domain>; empty clears).", "PUT", "/agents/{id}", []string{"id"}, []string{"auto_prune", "name", "tags", "default_domain"}, false})
register(spec{"containr_nodes_cordon", "Cordon a node: blocks new pins and 'auto' placements; running replicas stay (admin). Args: id.", "POST", "/agents/{id}/cordon", []string{"id"}, nil, false}) register(spec{"containr_nodes_cordon", "Cordon a node: blocks new pins and 'auto' placements; running replicas stay (admin). Args: id.", "POST", "/agents/{id}/cordon", []string{"id"}, nil, false})
register(spec{"containr_nodes_uncordon", "Re-open a node for placement (admin). Args: id.", "POST", "/agents/{id}/uncordon", []string{"id"}, nil, false}) register(spec{"containr_nodes_uncordon", "Re-open a node for placement (admin). Args: id.", "POST", "/agents/{id}/uncordon", []string{"id"}, nil, false})
register(spec{"containr_nodes_drain", "Drain a node: cordon + remove all service containers via the agent + unpin affected services so they redeploy locally. Requires the node online (admin). Args: id.", "POST", "/agents/{id}/drain", []string{"id"}, nil, true}) register(spec{"containr_nodes_drain", "Drain a node: cordon + remove all service containers via the agent + unpin affected services so they redeploy locally. Requires the node online (admin). Args: id.", "POST", "/agents/{id}/drain", []string{"id"}, nil, true})
@@ -0,0 +1,8 @@
-- +goose Up
-- Per-node base domain: services pinned/spread onto an agent with
-- default_domain get auto-generated hostnames (<service>.<domain>) when
-- they have no explicit service_domains rows.
ALTER TABLE node_agents ADD COLUMN IF NOT EXISTS default_domain VARCHAR(255) NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE node_agents DROP COLUMN IF EXISTS default_domain;
@@ -0,0 +1,11 @@
-- +goose Up
ALTER TABLE public.preview_environments
ADD COLUMN IF NOT EXISTS preview_service_id uuid REFERENCES public.services(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_preview_environments_expiry
ON public.preview_environments (expires_at)
WHERE status NOT IN ('expired', 'stopped');
-- +goose Down
DROP INDEX IF EXISTS public.idx_preview_environments_expiry;
ALTER TABLE public.preview_environments DROP COLUMN IF EXISTS preview_service_id;
+12
View File
@@ -179,6 +179,18 @@ LIMIT 1;
-- name: SetAgentTags :exec -- name: SetAgentTags :exec
UPDATE node_agents SET tags = $2::jsonb, updated_at = NOW() WHERE id = $1; UPDATE node_agents SET tags = $2::jsonb, updated_at = NOW() WHERE id = $1;
-- name: SetAgentDefaultDomain :exec
UPDATE node_agents SET default_domain = $2, updated_at = NOW() WHERE id = $1;
-- name: ListSchedulableAgentDomains :many
-- Base domains of every online, schedulable agent carrying the required
-- placement tags — auto-domain candidates for spread services.
SELECT DISTINCT default_domain FROM node_agents
WHERE status = 'online' AND schedulable
AND default_domain <> ''
AND tags @> $1::jsonb
ORDER BY default_domain;
-- name: ListServiceContainers :many -- name: ListServiceContainers :many
SELECT id, name, node_agent_id, status, ports, resources FROM container_instances SELECT id, name, node_agent_id, status, ports, resources FROM container_instances
WHERE service_id = $1; WHERE service_id = $1;
+2 -1
View File
@@ -199,7 +199,8 @@ CREATE TABLE node_agents (
metadata JSONB, metadata JSONB,
auto_prune BOOLEAN NOT NULL DEFAULT false, auto_prune BOOLEAN NOT NULL DEFAULT false,
schedulable BOOLEAN NOT NULL DEFAULT true, schedulable BOOLEAN NOT NULL DEFAULT true,
tags JSONB NOT NULL DEFAULT '[]' tags JSONB NOT NULL DEFAULT '[]',
default_domain VARCHAR(255) NOT NULL DEFAULT ''
); );
CREATE TABLE container_instances ( CREATE TABLE container_instances (
@@ -48,6 +48,7 @@ export function NodeDetailPage() {
const [confirmVolumes, setConfirmVolumes] = useState(false); const [confirmVolumes, setConfirmVolumes] = useState(false);
const [confirmDrain, setConfirmDrain] = useState(false); const [confirmDrain, setConfirmDrain] = useState(false);
const [tagDraft, setTagDraft] = useState<string | null>(null); const [tagDraft, setTagDraft] = useState<string | null>(null);
const [domainDraft, setDomainDraft] = useState<string | null>(null);
const agentQuery = useQuery({ const agentQuery = useQuery({
queryKey: ['node-agent', id], queryKey: ['node-agent', id],
@@ -80,8 +81,8 @@ export function NodeDetailPage() {
queryClient.invalidateQueries({ queryKey: ['usage-agents'] }); queryClient.invalidateQueries({ queryKey: ['usage-agents'] });
}; };
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: (body: { name?: string; auto_prune?: boolean; tags?: string[] }) => updateAgent(id, body), mutationFn: (body: { name?: string; auto_prune?: boolean; tags?: string[]; default_domain?: string }) => updateAgent(id, body),
onSuccess: () => { setRename(null); setTagDraft(null); invalidate(); }, onSuccess: () => { setRename(null); setTagDraft(null); setDomainDraft(null); invalidate(); },
}); });
const pruneMutation = useMutation({ const pruneMutation = useMutation({
mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }), mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }),
@@ -218,6 +219,44 @@ export function NodeDetailPage() {
</> </>
)} )}
</p> </p>
<p className="v-mono mt-1 text-[11px] text-[var(--text-tertiary)] flex items-center gap-1.5 flex-wrap">
domain:
{domainDraft === null ? (
<>
<span className={agent.defaultDomain ? 'text-[var(--text-secondary)]' : 'text-[var(--text-muted)]'}>
{agent.defaultDomain || 'none'}
</span>
{isAdmin && (
<button
onClick={() => setDomainDraft(agent.defaultDomain || '')}
className="text-[var(--text-muted)] hover:text-[var(--text-primary)]"
title="Node base domain — domainless services placed here get <name>.<domain> hostnames"
>
<Pencil size={11} />
</button>
)}
</>
) : (
<>
<input
value={domainDraft}
onChange={(e) => setDomainDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() });
if (e.key === 'Escape') setDomainDraft(null);
}}
autoFocus
placeholder="apps.node1.example.com — empty clears"
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-64"
/>
<button
onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })}
className="text-[var(--accent-primary)] hover:underline"
>save</button>
<button onClick={() => setDomainDraft(null)} className="text-[var(--text-muted)] hover:underline">cancel</button>
</>
)}
</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online <span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
+8 -1
View File
@@ -8179,7 +8179,7 @@ export interface paths {
}; };
/** /**
* Update node agent * Update node agent
* @description Partial update — accepts any subset of agent fields (name, status, auto_prune, tags). `tags` sets the node's placement labels; services with placement_tags only schedule onto agents carrying every required tag. * @description Partial update — accepts any subset of agent fields (name, status, auto_prune, tags, default_domain). `tags` sets the node's placement labels; services with placement_tags only schedule onto agents carrying every required tag. `default_domain` gives the node a base domain — domainless services placed there get `<name>.<default_domain>` auto-hostnames; empty string clears it.
*/ */
put: { put: {
parameters: { parameters: {
@@ -12297,6 +12297,8 @@ export interface components {
schedulable?: boolean; schedulable?: boolean;
/** @description Operator-set placement labels; services with placement_tags require all of them on the target node. */ /** @description Operator-set placement labels; services with placement_tags require all of them on the target node. */
tags?: string[]; tags?: string[];
/** @description Node base domain — services placed on this node (pin or spread) with no explicit service_domains get `<name>.<default_domain>` hostnames automatically. Wildcard DNS for `*.<default_domain>` must resolve to the Containr edge. */
default_domain?: string;
/** Format: date-time */ /** Format: date-time */
last_heartbeat?: string; last_heartbeat?: string;
/** Format: date-time */ /** Format: date-time */
@@ -12493,6 +12495,11 @@ export interface components {
project_id?: string; project_id?: string;
/** Format: uuid */ /** Format: uuid */
service_id?: string; service_id?: string;
/**
* Format: uuid
* @description The cloned service the preview actually deploys; its live status drives the preview status.
*/
preview_service_id?: string;
branch_name?: string; branch_name?: string;
pr_number?: number; pr_number?: number;
/** @description Generated name, e.g. preview-{branch}-{ts} */ /** @description Generated name, e.g. preview-{branch}-{ts} */
+3 -1
View File
@@ -362,6 +362,7 @@ export type NodeAgentEntity = {
autoPrune: boolean; autoPrune: boolean;
schedulable: boolean; schedulable: boolean;
tags: string[]; tags: string[];
defaultDomain?: string;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
lastHeartbeat?: string; lastHeartbeat?: string;
createdAt?: string; createdAt?: string;
@@ -1107,6 +1108,7 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
autoPrune: agent.auto_prune ?? false, autoPrune: agent.auto_prune ?? false,
schedulable: agent.schedulable ?? true, schedulable: agent.schedulable ?? true,
tags: Array.isArray(agent.tags) ? agent.tags : [], tags: Array.isArray(agent.tags) ? agent.tags : [],
defaultDomain: agent.default_domain || '',
metadata: (agent.metadata ?? {}) as Record<string, unknown>, metadata: (agent.metadata ?? {}) as Record<string, unknown>,
createdAt: agent.created_at, createdAt: agent.created_at,
updatedAt: agent.updated_at, updatedAt: agent.updated_at,
@@ -1146,7 +1148,7 @@ export async function listAgents(): Promise<NodeAgentEntity[]> {
return normalizeAgentArray(payload.agents); return normalizeAgentArray(payload.agents);
} }
export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean; tags?: string[] }): Promise<NodeAgentEntity | null> { export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean; tags?: string[]; default_domain?: string }): Promise<NodeAgentEntity | null> {
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, { const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(body), body: JSON.stringify(body),
+12 -1
View File
@@ -5216,7 +5216,7 @@ paths:
tags: tags:
- Agents - Agents
summary: Update node agent summary: Update node agent
description: Partial update — accepts any subset of agent fields (name, status, auto_prune, tags). `tags` sets the node's placement labels; services with placement_tags only schedule onto agents carrying every required tag. description: Partial update — accepts any subset of agent fields (name, status, auto_prune, tags, default_domain). `tags` sets the node's placement labels; services with placement_tags only schedule onto agents carrying every required tag. `default_domain` gives the node a base domain — domainless services placed there get `<name>.<default_domain>` auto-hostnames; empty string clears it.
security: security:
- bearerAuth: [] - bearerAuth: []
parameters: parameters:
@@ -9178,6 +9178,13 @@ components:
type: array type: array
items: { type: string } items: { type: string }
description: Operator-set placement labels; services with placement_tags require all of them on the target node. description: Operator-set placement labels; services with placement_tags require all of them on the target node.
default_domain:
type: string
description: >-
Node base domain — services placed on this node (pin or spread)
with no explicit service_domains get `<name>.<default_domain>`
hostnames automatically. Wildcard DNS for `*.<default_domain>`
must resolve to the Containr edge.
last_heartbeat: last_heartbeat:
type: string type: string
format: date-time format: date-time
@@ -9557,6 +9564,10 @@ components:
service_id: service_id:
type: string type: string
format: uuid format: uuid
preview_service_id:
type: string
format: uuid
description: The cloned service the preview actually deploys; its live status drives the preview status.
branch_name: branch_name:
type: string type: string
pr_number: pr_number: