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>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-10 00:07:30 +02:00
1 parent a4bb7ad559
commit 1409186a1f
17 files changed
+270 -17

No files matched your search

+11 -3
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
+16
View File
@@ -45,6 +45,9 @@ type NodeAgent struct {
// 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"`
@@ -282,6 +285,7 @@ func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
AutoPrune: row.AutoPrune, AutoPrune: row.AutoPrune,
Schedulable: row.Schedulable, Schedulable: row.Schedulable,
Tags: []string{}, Tags: []string{},
DefaultDomain: row.DefaultDomain,
Metadata: map[string]interface{}{}, Metadata: map[string]interface{}{},
} }
if row.LastHeartbeat.Valid { if row.LastHeartbeat.Valid {
@@ -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))
}
}
+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))
} }
+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
+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;
+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
+3 -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 */
+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),
+8 -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