From 1409186a1f2050e8074fbd40b7b5ab275d0458b4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?=
<150935816+Dvorinka@users.noreply.github.com>
Date: Sat, 10 Oct 2026 00:07:30 +0200
Subject: [PATCH] feat(nodes): per-node default domain with auto service
hostnames (#65)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
node_agents.default_domain gives each agent a base domain. Services
placed on it (explicit pin or spread) with no explicit service_domains
get . — 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>
---
ROADMAP2.md | 14 +++-
app/backend/internal/api/agents.go | 64 ++++++++++-------
app/backend/internal/api/domains.go | 68 +++++++++++++++++++
app/backend/internal/api/domains_test.go | 26 +++++++
app/backend/internal/api/runtime.go | 2 +
app/backend/internal/cli/commands/nodes.go | 8 ++-
.../internal/database/sqlcdb/agents.sql.go | 62 +++++++++++++++--
.../internal/database/sqlcdb/models.go | 2 +
.../internal/database/sqlcdb/querier.go | 4 ++
app/backend/internal/mcpserver/server.go | 2 +-
.../20261114000000_node_default_domain.sql | 8 +++
app/backend/sqlc/queries/agents.sql | 12 ++++
app/backend/sqlc/schema.sql | 3 +-
.../src/features/nodes/NodeDetailPage.tsx | 43 +++++++++++-
app/frontend/src/generated/api-types.ts | 4 +-
app/frontend/src/lib/api-client.ts | 4 +-
docs/api/openapi.yaml | 9 ++-
17 files changed, 294 insertions(+), 41 deletions(-)
create mode 100644 app/backend/internal/api/domains_test.go
create mode 100644 app/backend/migrations_goose/20261114000000_node_default_domain.sql
diff --git a/ROADMAP2.md b/ROADMAP2.md
index 8250069..778ab88 100644
--- a/ROADMAP2.md
+++ b/ROADMAP2.md
@@ -226,7 +226,11 @@ goroutines; this fixes correctness *and* unlocks the ops surface.
{target_node_id}`: dump → stream transfer → provision → restore →
re-point bound services. Deferred until Phase F lands real second
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).
## 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/
OpenTofu, `infra_connections`, Proxmox + AWS modules, + Hetzner module
(dflow's second provider), cloud-init → auto-enroll.
-- [ ] **Global/wildcard domain per node** (`nodes.default_domain`) —
- auto-domains for services on that node.
+- [x] **Global/wildcard domain per node** (`node_agents.default_domain`) —
+ `PUT /agents/:id {default_domain}`; domainless services placed on the
+ node get `.` (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
diff --git a/app/backend/internal/api/agents.go b/app/backend/internal/api/agents.go
index 990b38b..58320dc 100644
--- a/app/backend/internal/api/agents.go
+++ b/app/backend/internal/api/agents.go
@@ -30,21 +30,24 @@ import (
// NodeAgent represents a container orchestration agent
type NodeAgent struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Hostname string `json:"hostname"`
- IPAddress string `json:"ip_address"`
- Port int `json:"port"`
- Status string `json:"status"`
- Version string `json:"version"`
- Capabilities AgentCapabilities `json:"capabilities"`
- Resources NodeResources `json:"resources"`
- LastHeartbeat time.Time `json:"last_heartbeat"`
- AutoPrune bool `json:"auto_prune"`
- Schedulable bool `json:"schedulable"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Hostname string `json:"hostname"`
+ IPAddress string `json:"ip_address"`
+ Port int `json:"port"`
+ Status string `json:"status"`
+ Version string `json:"version"`
+ Capabilities AgentCapabilities `json:"capabilities"`
+ Resources NodeResources `json:"resources"`
+ LastHeartbeat time.Time `json:"last_heartbeat"`
+ AutoPrune bool `json:"auto_prune"`
+ Schedulable bool `json:"schedulable"`
// Tags are operator-set placement labels; services with placement_tags
// 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 . automatically.
+ DefaultDomain string `json:"default_domain"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata"`
@@ -272,17 +275,18 @@ func (h *NodeAgentHandler) ListNodeOptions(c *gin.Context) {
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
agent := NodeAgent{
- ID: row.ID,
- Name: row.Name,
- Hostname: row.Hostname,
- IPAddress: row.IpAddress,
- Port: int(row.Port),
- Status: row.Status.String,
- Version: row.Version.String,
- AutoPrune: row.AutoPrune,
- Schedulable: row.Schedulable,
- Tags: []string{},
- Metadata: map[string]interface{}{},
+ ID: row.ID,
+ Name: row.Name,
+ Hostname: row.Hostname,
+ IPAddress: row.IpAddress,
+ Port: int(row.Port),
+ Status: row.Status.String,
+ Version: row.Version.String,
+ AutoPrune: row.AutoPrune,
+ Schedulable: row.Schedulable,
+ Tags: []string{},
+ DefaultDomain: row.DefaultDomain,
+ Metadata: map[string]interface{}{},
}
if row.LastHeartbeat.Valid {
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
// register-path signature untouched.
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 {
var tags []string
if b, mErr := json.Marshal(raw); mErr == nil {
diff --git a/app/backend/internal/api/domains.go b/app/backend/internal/api/domains.go
index cb63360..0834c1c 100644
--- a/app/backend/internal/api/domains.go
+++ b/app/backend/internal/api/domains.go
@@ -3,6 +3,7 @@ package api
import (
"context"
"database/sql"
+ "encoding/json"
"net"
"net/http"
"regexp"
@@ -10,6 +11,7 @@ import (
"time"
"containr/internal/database"
+ "containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -68,6 +70,72 @@ func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) [
if len(out) == 0 && 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 . 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
}
diff --git a/app/backend/internal/api/domains_test.go b/app/backend/internal/api/domains_test.go
new file mode 100644
index 0000000..b065d2a
--- /dev/null
+++ b/app/backend/internal/api/domains_test.go
@@ -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))
+ }
+}
diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go
index 9652dfe..9f6125b 100644
--- a/app/backend/internal/api/runtime.go
+++ b/app/backend/internal/api/runtime.go
@@ -659,6 +659,8 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
persistPublishedPort(db, service.ID, state.Ports)
if 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 {
service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c))
}
diff --git a/app/backend/internal/cli/commands/nodes.go b/app/backend/internal/cli/commands/nodes.go
index f9979d4..c011504 100644
--- a/app/backend/internal/cli/commands/nodes.go
+++ b/app/backend/internal/cli/commands/nodes.go
@@ -84,7 +84,7 @@ var nodesDeleteCmd = &cobra.Command{
var nodesUpdateCmd = &cobra.Command{
Use: "update ",
- Short: "Update a node agent (--name, --auto-prune, --tags)",
+ Short: "Update a node agent (--name, --auto-prune, --tags, --domain)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
@@ -108,8 +108,11 @@ var nodesUpdateCmd = &cobra.Command{
}
body["tags"] = tags
}
+ if cmd.Flags().Changed("domain") {
+ body["default_domain"], _ = cmd.Flags().GetString("domain")
+ }
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)
if err != nil {
@@ -304,6 +307,7 @@ func init() {
nodesUpdateCmd.Flags().String("name", "", "node display name")
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("domain", "", "base domain for this node — domainless services placed here get . automatically (empty clears)")
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)")
nodeTokensCmd.AddCommand(nodeTokensIssueCmd, nodeTokensListCmd, nodeTokensRevokeCmd)
diff --git a/app/backend/internal/database/sqlcdb/agents.sql.go b/app/backend/internal/database/sqlcdb/agents.sql.go
index f553328..618fc32 100644
--- a/app/backend/internal/database/sqlcdb/agents.sql.go
+++ b/app/backend/internal/database/sqlcdb/agents.sql.go
@@ -75,7 +75,7 @@ INSERT INTO node_agents (
id, name, hostname, ip_address, port, status, version,
capabilities, resources, last_heartbeat, metadata
) 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 {
@@ -124,6 +124,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeA
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
+ &i.DefaultDomain,
)
return i, err
}
@@ -258,7 +259,7 @@ func (q *Queries) DeleteServiceContainersOnAgent(ctx context.Context, arg Delete
}
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) {
@@ -281,12 +282,13 @@ func (q *Queries) GetAgent(ctx context.Context, id string) (NodeAgent, error) {
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
+ &i.DefaultDomain,
)
return i, err
}
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
LIMIT 1
`
@@ -316,6 +318,7 @@ func (q *Queries) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAnd
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
+ &i.DefaultDomain,
)
return i, err
}
@@ -523,7 +526,7 @@ func (q *Queries) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHea
}
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) {
@@ -552,6 +555,7 @@ func (q *Queries) ListAgents(ctx context.Context) ([]NodeAgent, error) {
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
+ &i.DefaultDomain,
); err != nil {
return nil, err
}
@@ -695,6 +699,39 @@ func (q *Queries) ListPendingCommands(ctx context.Context, nodeAgentID string) (
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
SELECT id, name FROM node_agents
WHERE status = 'online' AND schedulable
@@ -910,6 +947,20 @@ func (q *Queries) ScrubCommandPayload(ctx context.Context, arg ScrubCommandPaylo
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
UPDATE node_agents SET schedulable = $2, updated_at = NOW() WHERE id = $1
`
@@ -954,7 +1005,7 @@ UPDATE node_agents SET
schedulable = $13,
updated_at = NOW()
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 {
@@ -1007,6 +1058,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeA
&i.AutoPrune,
&i.Schedulable,
&i.Tags,
+ &i.DefaultDomain,
)
return i, err
}
diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go
index d28a042..a80f64f 100644
--- a/app/backend/internal/database/sqlcdb/models.go
+++ b/app/backend/internal/database/sqlcdb/models.go
@@ -178,6 +178,7 @@ type NodeAgent struct {
AutoPrune bool `json:"auto_prune"`
Schedulable bool `json:"schedulable"`
Tags json.RawMessage `json:"tags"`
+ DefaultDomain string `json:"default_domain"`
}
type Notification struct {
@@ -279,6 +280,7 @@ type Service struct {
NodeID sql.NullString `json:"node_id"`
Spread bool `json:"spread"`
PlacementTags json.RawMessage `json:"placement_tags"`
+ TraefikLabels json.RawMessage `json:"traefik_labels"`
}
type ServiceDomain struct {
diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go
index 6d90634..656dc27 100644
--- a/app/backend/internal/database/sqlcdb/querier.go
+++ b/app/backend/internal/database/sqlcdb/querier.go
@@ -95,6 +95,9 @@ type Querier interface {
// 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.
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)
// Only agents carrying every required placement tag (jsonb array containment).
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)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
ScrubCommandPayload(ctx context.Context, arg ScrubCommandPayloadParams) error
+ SetAgentDefaultDomain(ctx context.Context, arg SetAgentDefaultDomainParams) error
SetAgentSchedulable(ctx context.Context, arg SetAgentSchedulableParams) error
SetAgentTags(ctx context.Context, arg SetAgentTagsParams) error
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go
index f19d379..df91459 100644
--- a/app/backend/internal/mcpserver/server.go
+++ b/app/backend/internal/mcpserver/server.go
@@ -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_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_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 .; 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_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})
diff --git a/app/backend/migrations_goose/20261114000000_node_default_domain.sql b/app/backend/migrations_goose/20261114000000_node_default_domain.sql
new file mode 100644
index 0000000..520915c
--- /dev/null
+++ b/app/backend/migrations_goose/20261114000000_node_default_domain.sql
@@ -0,0 +1,8 @@
+-- +goose Up
+-- Per-node base domain: services pinned/spread onto an agent with
+-- default_domain get auto-generated hostnames (.) 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;
diff --git a/app/backend/sqlc/queries/agents.sql b/app/backend/sqlc/queries/agents.sql
index 4358cd3..66bf14c 100644
--- a/app/backend/sqlc/queries/agents.sql
+++ b/app/backend/sqlc/queries/agents.sql
@@ -179,6 +179,18 @@ LIMIT 1;
-- name: SetAgentTags :exec
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
SELECT id, name, node_agent_id, status, ports, resources FROM container_instances
WHERE service_id = $1;
diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql
index 3118d21..b8f91bf 100644
--- a/app/backend/sqlc/schema.sql
+++ b/app/backend/sqlc/schema.sql
@@ -199,7 +199,8 @@ CREATE TABLE node_agents (
metadata JSONB,
auto_prune BOOLEAN NOT NULL DEFAULT false,
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 (
diff --git a/app/frontend/src/features/nodes/NodeDetailPage.tsx b/app/frontend/src/features/nodes/NodeDetailPage.tsx
index 100c464..c95332a 100644
--- a/app/frontend/src/features/nodes/NodeDetailPage.tsx
+++ b/app/frontend/src/features/nodes/NodeDetailPage.tsx
@@ -48,6 +48,7 @@ export function NodeDetailPage() {
const [confirmVolumes, setConfirmVolumes] = useState(false);
const [confirmDrain, setConfirmDrain] = useState(false);
const [tagDraft, setTagDraft] = useState(null);
+ const [domainDraft, setDomainDraft] = useState(null);
const agentQuery = useQuery({
queryKey: ['node-agent', id],
@@ -80,8 +81,8 @@ export function NodeDetailPage() {
queryClient.invalidateQueries({ queryKey: ['usage-agents'] });
};
const updateMutation = useMutation({
- mutationFn: (body: { name?: string; auto_prune?: boolean; tags?: string[] }) => updateAgent(id, body),
- onSuccess: () => { setRename(null); setTagDraft(null); invalidate(); },
+ mutationFn: (body: { name?: string; auto_prune?: boolean; tags?: string[]; default_domain?: string }) => updateAgent(id, body),
+ onSuccess: () => { setRename(null); setTagDraft(null); setDomainDraft(null); invalidate(); },
});
const pruneMutation = useMutation({
mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }),
@@ -218,6 +219,44 @@ export function NodeDetailPage() {
>
)}
+
+ domain:
+ {domainDraft === null ? (
+ <>
+
+ {agent.defaultDomain || 'none'}
+
+ {isAdmin && (
+
+ )}
+ >
+ ) : (
+ <>
+ 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"
+ />
+
+
+ >
+ )}
+
.` auto-hostnames; empty string clears it.
*/
put: {
parameters: {
@@ -12297,6 +12297,8 @@ export interface components {
schedulable?: boolean;
/** @description Operator-set placement labels; services with placement_tags require all of them on the target node. */
tags?: string[];
+ /** @description Node base domain — services placed on this node (pin or spread) with no explicit service_domains get `.` hostnames automatically. Wildcard DNS for `*.` must resolve to the Containr edge. */
+ default_domain?: string;
/** Format: date-time */
last_heartbeat?: string;
/** Format: date-time */
diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts
index e812bf0..2f381fa 100644
--- a/app/frontend/src/lib/api-client.ts
+++ b/app/frontend/src/lib/api-client.ts
@@ -362,6 +362,7 @@ export type NodeAgentEntity = {
autoPrune: boolean;
schedulable: boolean;
tags: string[];
+ defaultDomain?: string;
metadata: Record;
lastHeartbeat?: string;
createdAt?: string;
@@ -1107,6 +1108,7 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
autoPrune: agent.auto_prune ?? false,
schedulable: agent.schedulable ?? true,
tags: Array.isArray(agent.tags) ? agent.tags : [],
+ defaultDomain: agent.default_domain || '',
metadata: (agent.metadata ?? {}) as Record,
createdAt: agent.created_at,
updatedAt: agent.updated_at,
@@ -1146,7 +1148,7 @@ export async function listAgents(): Promise {
return normalizeAgentArray(payload.agents);
}
-export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean; tags?: string[] }): Promise {
+export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean; tags?: string[]; default_domain?: string }): Promise {
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index affcd69..2e90cbe 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -5216,7 +5216,7 @@ paths:
tags:
- Agents
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 `.` auto-hostnames; empty string clears it.
security:
- bearerAuth: []
parameters:
@@ -9178,6 +9178,13 @@ components:
type: array
items: { type: string }
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 `.`
+ hostnames automatically. Wildcard DNS for `*.`
+ must resolve to the Containr edge.
last_heartbeat:
type: string
format: date-time