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
+294 -41

No files matched your search

+68
View File
@@ -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 <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
}