mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
a4bb7ad559
commit
1409186a1f
17 files changed
+294
-41
No files matched your search
@@ -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 <name>.<default_domain> 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user