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