diff --git a/cmd/kleffctl/main.go b/cmd/kleffctl/main.go index 486caca..99b11f1 100644 --- a/cmd/kleffctl/main.go +++ b/cmd/kleffctl/main.go @@ -178,7 +178,8 @@ func ensureProjectNetwork(ctx context.Context, cli *client.Client, name, project netLabels := map[string]string{ labels.ManagedBy: labels.ManagedByValue, - labels.ProjectID: projectID, + labels.ProjectID: projectID, + labels.EnvironmentID: projectID, } if projectSlug != "" { netLabels[labels.ProjectSlug] = projectSlug diff --git a/cmd/kleffd/main.go b/cmd/kleffd/main.go index 2baa51f..1e928cb 100644 --- a/cmd/kleffd/main.go +++ b/cmd/kleffd/main.go @@ -70,16 +70,18 @@ func main() { // --- Repository --- repo := memrepo.NewServerRepository() - // Reseed the in-memory repository from any workloads that were running - // before this daemon process started (e.g. after a daemon restart). - if existing, err := runtime.ListRunning(context.Background()); err != nil { - daemonLog.Warn("Failed to recover running workloads on startup", "error", err) + // Re-adopt containers from a previous daemon run so the tailer and scraper + // resume without waiting for the next provision job. + if existing, discErr := runtime.Discover(context.Background()); discErr != nil { + daemonLog.Warn("Failed to discover existing workloads on startup", "error", discErr) } else { - for _, srv := range existing { - _ = repo.Save(context.Background(), srv) + for _, rec := range existing { + if saveErr := repo.Save(context.Background(), rec); saveErr != nil { + daemonLog.Warn("Failed to restore workload record", "workload_id", rec.ID, "error", saveErr) + } } if len(existing) > 0 { - daemonLog.Info("Recovered running workloads", "count", len(existing)) + daemonLog.Info("Restored workload records from runtime", "count", len(existing)) } } @@ -91,6 +93,7 @@ func main() { } }() + // --- Platform registration + status reporting --- platformClient := platformadapter.NewClient(cfg.PlatformURL, cfg.SharedSecret, cfg.NodeID, cfg.FileAPIURL, daemonLog) if err := platformClient.RegisterNode(context.Background()); err != nil { @@ -155,7 +158,7 @@ func detectRuntime(cfg *config.Config, logger ports.Logger) (ports.RuntimeAdapte } // No Kubernetes — check if Docker is actually reachable before using it. - dockerAdapter, err := dockeradapter.New(cfg.NodeID, cfg.StoragePath) + dockerAdapter, err := dockeradapter.New(cfg.NodeID, cfg.StoragePath, cfg.NodeNetwork) if err != nil { return nil, fmt.Errorf("no runtime available: kubernetes not detected, docker client failed: %w", err) } diff --git a/cmd/testprovision/main.go b/cmd/testprovision/main.go index 9cfb951..b59c743 100644 --- a/cmd/testprovision/main.go +++ b/cmd/testprovision/main.go @@ -79,7 +79,7 @@ func main() { } func runDocker(cleanup bool, serverID string, spec ports.WorkloadSpec) { - adapter, err := dockeradapter.New("test-node", "/var/lib/kleffd/servers") + adapter, err := dockeradapter.New("test-node", "/var/lib/kleffd/servers", "") if err != nil { log.Fatalf("failed to create docker adapter: %v", err) } diff --git a/internal/adapters/out/platform/client.go b/internal/adapters/out/platform/client.go index 67267d0..fba5d4c 100644 --- a/internal/adapters/out/platform/client.go +++ b/internal/adapters/out/platform/client.go @@ -95,7 +95,7 @@ func (c *Client) RegisterNode(ctx context.Context) error { return nil } -func (c *Client) ShipLogs(ctx context.Context, workloadID, projectID string, lines []ports.LogEntry) error { +func (c *Client) ShipLogs(ctx context.Context, workloadID, projectID, environmentID string, lines []ports.LogEntry) error { if len(lines) == 0 { return nil } @@ -108,7 +108,7 @@ func (c *Client) ShipLogs(ctx context.Context, workloadID, projectID string, lin for i, l := range lines { dtos[i] = lineDTO{Ts: l.Ts.UTC().Format(time.RFC3339Nano), Stream: l.Stream, Line: l.Line} } - payload := map[string]any{"project_id": projectID, "lines": dtos} + payload := map[string]any{"project_id": projectID, "environment_id": environmentID, "lines": dtos} body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal log payload: %w", err) diff --git a/internal/adapters/out/repository/sqlite/server_repository.go b/internal/adapters/out/repository/sqlite/server_repository.go new file mode 100644 index 0000000..afbc213 --- /dev/null +++ b/internal/adapters/out/repository/sqlite/server_repository.go @@ -0,0 +1,83 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + + "github.com/kleffio/kleff-daemon/internal/application/ports" +) + +type ServerRepository struct { + db *sql.DB +} + +func NewServerRepository(db *sql.DB) *ServerRepository { + return &ServerRepository{db: db} +} + +func (r *ServerRepository) Save(ctx context.Context, s *ports.ServerRecord) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO servers (id, name, address, port, status, node_id, runtime, runtime_ref, project_id, environment_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + address = excluded.address, + port = excluded.port, + status = excluded.status, + node_id = excluded.node_id, + runtime = excluded.runtime, + runtime_ref = excluded.runtime_ref, + project_id = excluded.project_id, + environment_id = excluded.environment_id, + updated_at = CURRENT_TIMESTAMP + `, s.ID, s.Name, s.Address, s.Port, s.Status, s.NodeID, s.Runtime, s.RuntimeRef, s.ProjectID, s.EnvironmentID) + return err +} + +func (r *ServerRepository) FindByID(ctx context.Context, id string) (*ports.ServerRecord, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT id, name, address, port, status, node_id, runtime, runtime_ref, project_id, environment_id + FROM servers WHERE id = ? + `, id) + s := &ports.ServerRecord{} + err := row.Scan(&s.ID, &s.Name, &s.Address, &s.Port, &s.Status, &s.NodeID, &s.Runtime, &s.RuntimeRef, &s.ProjectID, &s.EnvironmentID) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("server not found: %s", id) + } + return s, err +} + +func (r *ServerRepository) UpdateStatus(ctx context.Context, id string, status string) error { + res, err := r.db.ExecContext(ctx, ` + UPDATE servers SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? + `, status, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("server not found: %s", id) + } + return nil +} + +func (r *ServerRepository) ListAll(ctx context.Context) ([]*ports.ServerRecord, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, name, address, port, status, node_id, runtime, runtime_ref, project_id, environment_id + FROM servers + `) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*ports.ServerRecord + for rows.Next() { + s := &ports.ServerRecord{} + if err := rows.Scan(&s.ID, &s.Name, &s.Address, &s.Port, &s.Status, &s.NodeID, &s.Runtime, &s.RuntimeRef, &s.ProjectID, &s.EnvironmentID); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} diff --git a/internal/adapters/out/runtime/docker/docker.go b/internal/adapters/out/runtime/docker/docker.go index 17a027a..f79d51f 100644 --- a/internal/adapters/out/runtime/docker/docker.go +++ b/internal/adapters/out/runtime/docker/docker.go @@ -32,6 +32,7 @@ type Adapter struct { nodeID string storageLocalPath string // path inside this container where server data is mounted storageHostPath string // corresponding host path passed to Docker for bind mounts + nodeNetwork string // optional fixed network (e.g. the compose default network in dev) } var errContainerNotFound = errors.New("container not found") @@ -40,7 +41,9 @@ var errContainerNotFound = errors.New("container not found") // daemon container where game server data lives (e.g. /var/lib/kleffd/servers). // The adapter auto-detects the matching host path by inspecting its own container // mounts so that bind mount sources are always valid host filesystem paths. -func New(nodeID, storageLocalPath string) (*Adapter, error) { +// nodeNetwork, if set, is an additional Docker network all workload containers are +// joined to (useful in local dev to keep containers on the compose default network). +func New(nodeID, storageLocalPath string, nodeNetwork string) (*Adapter, error) { c, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { return nil, fmt.Errorf("failed to create docker client: %w", err) @@ -64,6 +67,7 @@ func New(nodeID, storageLocalPath string) (*Adapter, error) { nodeID: nodeID, storageLocalPath: storageLocalPath, storageHostPath: storageHostPath, + nodeNetwork: nodeNetwork, }, nil } @@ -76,6 +80,57 @@ func (a *Adapter) Ping(ctx context.Context) error { return nil } +// EnsureNamespaceScope creates the per-namespace bridge network (kleff_ns_{nsID}) +// if it does not already exist. This is the Phase 1 replacement for per-project +// networking — one bridge per namespace keeps tenants isolated without bridge exhaustion. +func (a *Adapter) EnsureNamespaceScope(ctx context.Context, namespaceID string) (*ports.ProjectScope, error) { + if namespaceID == "" { + return nil, fmt.Errorf("namespace id is required") + } + name := namespaceNetworkName(namespaceID) + + existing, err := a.client.NetworkList(ctx, dnet.ListOptions{ + Filters: filters.NewArgs(filters.Arg("label", labels.NamespaceID+"="+namespaceID)), + }) + if err != nil { + return nil, fmt.Errorf("failed to list namespace networks: %w", err) + } + if len(existing) > 0 { + return &ports.ProjectScope{ + ProjectID: namespaceID, + NetworkName: existing[0].Name, + }, nil + } + + netLabels := map[string]string{ + labels.ManagedBy: labels.ManagedByValue, + labels.NamespaceID: namespaceID, + labels.NodeID: a.nodeID, + } + if _, err := a.client.NetworkCreate(ctx, name, dnet.CreateOptions{ + Driver: "bridge", + Labels: netLabels, + }); err != nil { + if !strings.Contains(err.Error(), "already exists") { + if isAddressPoolExhaustedError(err) { + _, _ = a.pruneUnusedProjectNetworks(ctx) + if _, retryErr := a.client.NetworkCreate(ctx, name, dnet.CreateOptions{ + Driver: "bridge", + Labels: netLabels, + }); retryErr != nil && !strings.Contains(retryErr.Error(), "already exists") { + return nil, fmt.Errorf("failed to create namespace network after prune: %w", retryErr) + } + } else { + return nil, fmt.Errorf("failed to create namespace network: %w", err) + } + } + } + return &ports.ProjectScope{ + ProjectID: namespaceID, + NetworkName: name, + }, nil +} + // EnsureProjectScope creates the per-project bridge network if it does not // already exist. The network is the isolation boundary between projects — all // containers in a project attach to it, and nothing else can reach them. @@ -101,7 +156,8 @@ func (a *Adapter) EnsureProjectScope(ctx context.Context, projectID, projectSlug netLabels := map[string]string{ labels.ManagedBy: labels.ManagedByValue, - labels.ProjectID: projectID, + labels.ProjectID: projectID, + labels.EnvironmentID: projectID, labels.NodeID: a.nodeID, } if projectSlug != "" { @@ -211,11 +267,25 @@ func (a *Adapter) TeardownProjectScope(ctx context.Context, projectID string) er // Deploy pulls the image and starts a new container. func (a *Adapter) Deploy(ctx context.Context, spec ports.WorkloadSpec) (*ports.RunningServer, error) { - if spec.ProjectID == "" { - return nil, fmt.Errorf("workload spec missing project_id") + if spec.NamespaceID == "" && spec.ProjectID == "" { + return nil, fmt.Errorf("workload spec missing namespace_id") + } + + // If the daemon is configured with a fixed node network (e.g. the compose default + // network in dev), use it directly so containers are visible within the same + // network as the daemon. Otherwise create a per-namespace bridge for isolation. + var scope *ports.ProjectScope + var err error + if a.nodeNetwork != "" { + scope = &ports.ProjectScope{ + ProjectID: spec.NamespaceID, + NetworkName: a.nodeNetwork, + } + } else if spec.NamespaceID != "" { + scope, err = a.EnsureNamespaceScope(ctx, spec.NamespaceID) + } else { + scope, err = a.EnsureProjectScope(ctx, spec.ProjectID, spec.ProjectSlug) } - - scope, err := a.EnsureProjectScope(ctx, spec.ProjectID, spec.ProjectSlug) if err != nil { return nil, err } @@ -252,6 +322,7 @@ func (a *Adapter) Deploy(ctx context.Context, spec ports.WorkloadSpec) (*ports.R ServerID: spec.ServerID, BlueprintID: spec.BlueprintID, NodeID: a.nodeID, + NamespaceID: spec.NamespaceID, ProjectID: spec.ProjectID, ProjectSlug: spec.ProjectSlug, }, @@ -262,10 +333,15 @@ func (a *Adapter) Deploy(ctx context.Context, spec ports.WorkloadSpec) (*ports.R // Start restarts a stopped container. If it no longer exists, re-creates it. func (a *Adapter) Start(ctx context.Context, spec ports.WorkloadSpec) (*ports.RunningServer, error) { - if spec.ProjectID == "" { - return nil, fmt.Errorf("workload spec missing project_id") + if spec.NamespaceID == "" && spec.ProjectID == "" { + return nil, fmt.Errorf("workload spec missing namespace_id") + } + // Use EffectiveNamespaceID for authorization: prefers new label, falls back to project_id. + effectiveID := spec.NamespaceID + if effectiveID == "" { + effectiveID = spec.ProjectID } - containerID, err := a.findContainer(ctx, spec.ProjectID, spec.ServerID) + containerID, err := a.findContainer(ctx, effectiveID, spec.ServerID) if err != nil { if errors.Is(err, ports.ErrProjectMismatch) { return nil, err @@ -443,18 +519,18 @@ func (a *Adapter) Logs(ctx context.Context, projectID, workloadID string, follow return rc, nil } -// ListRunning returns a ServerRecord for each container currently managed by -// this daemon that is in the "running" state. Used to reseed the in-memory -// repository after a daemon restart. -func (a *Adapter) ListRunning(ctx context.Context) ([]*ports.ServerRecord, error) { +// Discover lists all kleff-managed containers on this node and returns a ServerRecord for each. +// Called on startup to re-populate the in-memory server repository after a daemon restart. +func (a *Adapter) Discover(ctx context.Context) ([]*ports.ServerRecord, error) { containers, err := a.client.ContainerList(ctx, container.ListOptions{ + All: true, Filters: filters.NewArgs( filters.Arg("label", labels.ManagedBy+"="+labels.ManagedByValue), - filters.Arg("status", "running"), + filters.Arg("label", labels.NodeID+"="+a.nodeID), ), }) if err != nil { - return nil, fmt.Errorf("list managed containers: %w", err) + return nil, fmt.Errorf("discover containers: %w", err) } records := make([]*ports.ServerRecord, 0, len(containers)) @@ -463,12 +539,23 @@ func (a *Adapter) ListRunning(ctx context.Context) ([]*ports.ServerRecord, error if wl.ServerID == "" { continue } + // For namespace workloads, ProjectID = NamespaceID (matches provision_worker convention). + projectID := wl.NamespaceID + if projectID == "" { + projectID = wl.ProjectID + } + status := "Stopped" + if c.State == "running" { + status = "Running" + } records = append(records, &ports.ServerRecord{ - ID: wl.ServerID, - Status: "Running", - NodeID: wl.NodeID, - RuntimeRef: c.ID, - ProjectID: wl.ProjectID, + ID: wl.ServerID, + Name: wl.ServerID, + Status: status, + NodeID: wl.NodeID, + RuntimeRef: c.ID, + ProjectID: projectID, + EnvironmentID: wl.EnvironmentID, }) } return records, nil @@ -495,12 +582,14 @@ func (a *Adapter) createContainer(ctx context.Context, spec ports.WorkloadSpec, } wl := labels.WorkloadLabels{ - OwnerID: spec.OwnerID, - ServerID: spec.ServerID, - BlueprintID: spec.BlueprintID, - NodeID: a.nodeID, - ProjectID: spec.ProjectID, - ProjectSlug: spec.ProjectSlug, + OwnerID: spec.OwnerID, + ServerID: spec.ServerID, + BlueprintID: spec.BlueprintID, + NodeID: a.nodeID, + NamespaceID: spec.NamespaceID, + ProjectID: spec.ProjectID, + EnvironmentID: spec.EnvironmentID, + ProjectSlug: spec.ProjectSlug, } containerLabels := wl.ToMap() @@ -617,13 +706,24 @@ func (a *Adapter) findContainer(ctx context.Context, projectID, workloadID strin } c := containers[0] if projectID != "" { - if got := c.Labels[labels.ProjectID]; got != projectID { - return "", fmt.Errorf("%w: workload %s belongs to project %q, not %q", ports.ErrProjectMismatch, workloadID, got, projectID) + // Dual-read auth: accept match on namespace_id (new), project_id, or environment_id (legacy). + gotNS := c.Labels[labels.NamespaceID] + gotProject := c.Labels[labels.ProjectID] + gotEnvironment := c.Labels[labels.EnvironmentID] + if gotNS != projectID && gotProject != projectID && gotEnvironment != projectID { + return "", fmt.Errorf("%w: workload %s belongs to ns/proj/env %q/%q/%q, not %q", + ports.ErrProjectMismatch, workloadID, gotNS, gotProject, gotEnvironment, projectID) } } return c.ID, nil } +// namespaceNetworkName derives a short, deterministic bridge network name for +// the namespace. Format: kleff_ns_{first12charsOfNsID}. +func namespaceNetworkName(namespaceID string) string { + return "kleff_ns_" + shortID(namespaceID) +} + // projectNetworkName derives a short, deterministic bridge network name from // the project id. Docker network names are case-sensitive and allow [a-zA-Z0-9_.-]. func projectNetworkName(projectID string) string { @@ -706,36 +806,17 @@ func shortID(s string) string { return s } -// containerName builds a human-readable container name in the form -// username.projectslug.servername, falling back gracefully when fields are absent. +// containerName returns a human-readable Docker container name in the format +// {owner}.{namespace|project}.{serverName}, falling back to the workload ID. func containerName(spec ports.WorkloadSpec) string { - var parts []string - if spec.OwnerUsername != "" { - parts = append(parts, sanitizeNamePart(spec.OwnerUsername)) - } - if spec.ProjectSlug != "" { - parts = append(parts, sanitizeNamePart(spec.ProjectSlug)) + owner := spec.OwnerUsername + scope := spec.NamespaceSlug + if scope == "" { + scope = spec.ProjectSlug } - if spec.ServerName != "" { - parts = append(parts, sanitizeNamePart(spec.ServerName)) - } - if len(parts) == 0 { + name := spec.ServerName + if owner == "" || scope == "" || name == "" { return spec.ServerID } - return strings.Join(parts, ".") -} - -// sanitizeNamePart strips characters not allowed in Docker container names. -var nameReplacer = strings.NewReplacer(" ", "-", "@", "-", "/", "-") - -func sanitizeNamePart(s string) string { - s = nameReplacer.Replace(strings.ToLower(strings.TrimSpace(s))) - var out []byte - for i := 0; i < len(s); i++ { - c := s[i] - if c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.' { - out = append(out, c) - } - } - return strings.Trim(string(out), "-.") + return fmt.Sprintf("%s.%s.%s", owner, scope, name) } diff --git a/internal/adapters/out/runtime/kubernetes/kubernetes.go b/internal/adapters/out/runtime/kubernetes/kubernetes.go index ae28b2e..6f7557e 100644 --- a/internal/adapters/out/runtime/kubernetes/kubernetes.go +++ b/internal/adapters/out/runtime/kubernetes/kubernetes.go @@ -208,6 +208,10 @@ func (a *Adapter) Logs(_ context.Context, _ string, _ string, _ bool) (io.ReadCl return io.NopCloser(bytes.NewReader(nil)), nil } +func (a *Adapter) Discover(_ context.Context) ([]*ports.ServerRecord, error) { + return nil, nil +} + // --- Agones strategy --- func (a *Adapter) deployAgones(ctx context.Context, spec ports.WorkloadSpec) (*ports.RunningServer, error) { @@ -308,7 +312,7 @@ func (a *Adapter) deployStatefulSet(ctx context.Context, spec ports.WorkloadSpec serverLabels := labels.WorkloadLabels{ OwnerID: spec.OwnerID, ServerID: spec.ServerID, BlueprintID: spec.BlueprintID, NodeID: a.nodeID, ProjectID: spec.ProjectID, ProjectSlug: spec.ProjectSlug, } - selectorLabels := map[string]string{"app": spec.ServerID, labelStrategy: "statefulset", labels.ProjectID: spec.ProjectID} + selectorLabels := map[string]string{"app": spec.ServerID, labelStrategy: "statefulset", labels.ProjectID: spec.ProjectID, labels.EnvironmentID: spec.EnvironmentID} if spec.ProjectSlug != "" { selectorLabels[labels.ProjectSlug] = spec.ProjectSlug } @@ -350,7 +354,7 @@ func (a *Adapter) deployDeployment(ctx context.Context, spec ports.WorkloadSpec) serverLabels := labels.WorkloadLabels{ OwnerID: spec.OwnerID, ServerID: spec.ServerID, BlueprintID: spec.BlueprintID, NodeID: a.nodeID, ProjectID: spec.ProjectID, ProjectSlug: spec.ProjectSlug, } - selectorLabels := map[string]string{"app": spec.ServerID, labelStrategy: "deployment", labels.ProjectID: spec.ProjectID} + selectorLabels := map[string]string{"app": spec.ServerID, labelStrategy: "deployment", labels.ProjectID: spec.ProjectID, labels.EnvironmentID: spec.EnvironmentID} if spec.ProjectSlug != "" { selectorLabels[labels.ProjectSlug] = spec.ProjectSlug } @@ -422,11 +426,12 @@ func ensureProjectMatch(projectID string, resourceLabels map[string]string, work if projectID == "" { return nil } - got := strings.TrimSpace(resourceLabels[labels.ProjectID]) - if got == projectID { + gotProject := strings.TrimSpace(resourceLabels[labels.ProjectID]) + gotEnvironment := strings.TrimSpace(resourceLabels[labels.EnvironmentID]) + if gotProject == projectID || gotEnvironment == projectID { return nil } - return fmt.Errorf("%w: workload %s belongs to project %q, not %q", ports.ErrProjectMismatch, workloadID, got, projectID) + return fmt.Errorf("%w: workload %s belongs to project/environment %q/%q, not %q", ports.ErrProjectMismatch, workloadID, gotProject, gotEnvironment, projectID) } func buildEnvList(overrides map[string]string) []interface{} { diff --git a/internal/app/config/config.go b/internal/app/config/config.go index 5bb733e..1441be0 100644 --- a/internal/app/config/config.go +++ b/internal/app/config/config.go @@ -37,6 +37,10 @@ type Config struct { StoragePath string `mapstructure:"storage.path"` FileAPIPort int `mapstructure:"file_api.port"` FileAPIURL string `mapstructure:"file_api.url"` + // NodeNetwork, if set, is the Docker network workload containers are joined to + // instead of a per-namespace bridge. Set this to the compose network name in + // local dev (e.g. "kleff-local") so containers appear inside the compose group. + NodeNetwork string `mapstructure:"node.network"` } func (c *Config) Validate() error { @@ -87,6 +91,7 @@ func Load() (*Config, error) { v.SetDefault("storage.path", "/var/lib/kleffd/servers") v.SetDefault("file_api.port", 8083) v.SetDefault("file_api.url", "") + v.SetDefault("node.network", "") v.SetEnvPrefix("kleff") v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) @@ -135,6 +140,7 @@ func Load() (*Config, error) { cfg.StoragePath = v.GetString("storage.path") cfg.FileAPIPort = v.GetInt("file_api.port") cfg.FileAPIURL = v.GetString("file_api.url") + cfg.NodeNetwork = v.GetString("node.network") if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("configuration validation failed: %w", err) diff --git a/internal/application/ports/log_shipper.go b/internal/application/ports/log_shipper.go index 347e9f5..ecbb42f 100644 --- a/internal/application/ports/log_shipper.go +++ b/internal/application/ports/log_shipper.go @@ -14,10 +14,10 @@ type LogEntry struct { // LogShipper ships batches of log lines to the platform. type LogShipper interface { - ShipLogs(ctx context.Context, workloadID, projectID string, lines []LogEntry) error + ShipLogs(ctx context.Context, workloadID, projectID, environmentID string, lines []LogEntry) error } // NoopLogShipper discards all log lines. Used when log shipping is disabled. type NoopLogShipper struct{} -func (NoopLogShipper) ShipLogs(_ context.Context, _, _ string, _ []LogEntry) error { return nil } +func (NoopLogShipper) ShipLogs(_ context.Context, _, _, _ string, _ []LogEntry) error { return nil } diff --git a/internal/application/ports/runtime_adapter.go b/internal/application/ports/runtime_adapter.go index 77b835e..b68e90c 100644 --- a/internal/application/ports/runtime_adapter.go +++ b/internal/application/ports/runtime_adapter.go @@ -52,8 +52,7 @@ type RuntimeAdapter interface { // RemoveFile deletes a previously injected file from the workload's volume. RemoveFile(ctx context.Context, projectID, workloadID, storagePath, contentType, fileName string) error - // ListRunning returns server records for all workloads currently managed by - // this daemon that are in a running state. Used to reseed the in-memory - // repository after a daemon restart. - ListRunning(ctx context.Context) ([]*ServerRecord, error) + // Discover returns all workloads currently managed by this runtime node. + // Called on daemon startup to re-populate the server repository after a restart. + Discover(ctx context.Context) ([]*ServerRecord, error) } diff --git a/internal/application/ports/server_repository.go b/internal/application/ports/server_repository.go index f0126fd..8ae5eea 100644 --- a/internal/application/ports/server_repository.go +++ b/internal/application/ports/server_repository.go @@ -12,6 +12,7 @@ type ServerRecord struct { Runtime string RuntimeRef string ProjectID string + EnvironmentID string } type ServerRepository interface { diff --git a/internal/application/ports/workload.go b/internal/application/ports/workload.go index 6e1f2c2..972dbac 100644 --- a/internal/application/ports/workload.go +++ b/internal/application/ports/workload.go @@ -13,13 +13,21 @@ type RunningServer struct { // It is a superset of the old ServerOperationPayload — all existing fields carry over. type WorkloadSpec struct { // Identity / Tenancy + // Phase 1: NamespaceID is the authoritative tenancy key for network scoping. + NamespaceID string `json:"namespace_id"` + NamespaceSlug string `json:"namespace_slug,omitempty"` + OwnerID string `json:"owner_id"` OwnerUsername string `json:"owner_username,omitempty"` ServerID string `json:"server_id"` ServerName string `json:"server_name,omitempty"` BlueprintID string `json:"blueprint_id"` - ProjectID string `json:"project_id"` - ProjectSlug string `json:"project_slug"` + EnvironmentID string `json:"environment_id,omitempty"` + + // Legacy fields: retained for daemon compat layer while old containers + // still have kleff.io/project_id labels (Docker labels are immutable). + ProjectID string `json:"project_id,omitempty"` + ProjectSlug string `json:"project_slug,omitempty"` // Blueprint details required for provision/start Image string `json:"image"` diff --git a/internal/logs/tailer.go b/internal/logs/tailer.go index 79f2a2e..013e2f5 100644 --- a/internal/logs/tailer.go +++ b/internal/logs/tailer.go @@ -84,7 +84,7 @@ func (t *Tailer) reconcile(ctx context.Context) { t.mu.Lock() t.running[srv.ID] = cancel t.mu.Unlock() - go t.tail(wctx, srv.ID, srv.ProjectID) + go t.tail(wctx, srv.ID, srv.ProjectID, srv.EnvironmentID) } } @@ -109,14 +109,18 @@ func (t *Tailer) stopAll() { } // tail streams logs for one workload until ctx is cancelled or the container exits. -func (t *Tailer) tail(ctx context.Context, workloadID, projectID string) { +func (t *Tailer) tail(ctx context.Context, workloadID, projectID, environmentID string) { defer func() { t.mu.Lock() delete(t.running, workloadID) t.mu.Unlock() }() - rc, err := t.runtime.Logs(ctx, projectID, workloadID, true) + scopeID := projectID + if scopeID == "" { + scopeID = environmentID + } + rc, err := t.runtime.Logs(ctx, scopeID, workloadID, true) if err != nil { t.logger.Warn("log tailer: failed to open log stream", "workload_id", workloadID, "error", err) return @@ -150,26 +154,26 @@ func (t *Tailer) tail(ctx context.Context, workloadID, projectID string) { select { case <-ctx.Done(): if len(batch) > 0 { - _ = t.shipper.ShipLogs(context.Background(), workloadID, projectID, batch) + _ = t.shipper.ShipLogs(context.Background(), workloadID, projectID, environmentID, batch) } return case entry, ok := <-lines: if !ok { if len(batch) > 0 { - _ = t.shipper.ShipLogs(context.Background(), workloadID, projectID, batch) + _ = t.shipper.ShipLogs(context.Background(), workloadID, projectID, environmentID, batch) } return } batch = append(batch, entry) if len(batch) >= batchSize { - if err := t.shipper.ShipLogs(ctx, workloadID, projectID, batch); err != nil { + if err := t.shipper.ShipLogs(ctx, workloadID, projectID, environmentID, batch); err != nil { t.logger.Warn("log tailer: ship failed", "workload_id", workloadID, "error", err) } batch = batch[:0] } case <-flush.C: if len(batch) > 0 { - if err := t.shipper.ShipLogs(ctx, workloadID, projectID, batch); err != nil { + if err := t.shipper.ShipLogs(ctx, workloadID, projectID, environmentID, batch); err != nil { t.logger.Warn("log tailer: ship failed", "workload_id", workloadID, "error", err) } batch = batch[:0] diff --git a/internal/metrics/scraper.go b/internal/metrics/scraper.go index c3b5449..a0cd28a 100644 --- a/internal/metrics/scraper.go +++ b/internal/metrics/scraper.go @@ -75,7 +75,11 @@ func (s *Scraper) scrape(ctx context.Context) { } activeIDs[srv.ID] = struct{}{} - health, err := s.runtime.Status(ctx, srv.ProjectID, srv.ID) + scopeID := srv.ProjectID + if scopeID == "" { + scopeID = srv.EnvironmentID + } + health, err := s.runtime.Status(ctx, scopeID, srv.ID) if err != nil { s.logger.Warn("metrics scraper: failed to get status", "workload_id", srv.ID, "error", err) continue diff --git a/internal/workers/delete_worker.go b/internal/workers/delete_worker.go index ea00b75..8def720 100644 --- a/internal/workers/delete_worker.go +++ b/internal/workers/delete_worker.go @@ -33,6 +33,11 @@ func (w *DeleteWorker) Handle(ctx context.Context, job *jobs.Job) error { log.Info("Deleting server", ports.LogKeyServerID, spec.ServerID) + if spec.ProjectID == "" { + if spec.EnvironmentID != "" { + spec.ProjectID = spec.EnvironmentID + } + } if spec.ProjectID == "" { return fmt.Errorf("invalid payload: project_id is required") } diff --git a/internal/workers/mocks_test.go b/internal/workers/mocks_test.go index 2326f50..773d722 100644 --- a/internal/workers/mocks_test.go +++ b/internal/workers/mocks_test.go @@ -46,6 +46,9 @@ func (m *mockRuntime) Endpoint(ctx context.Context, projectID, workloadID string func (m *mockRuntime) Logs(ctx context.Context, projectID, workloadID string, follow bool) (io.ReadCloser, error) { return nil, nil } +func (m *mockRuntime) Discover(ctx context.Context) ([]*ports.ServerRecord, error) { + return nil, nil +} type mockRepository struct { saveCalled bool @@ -66,3 +69,10 @@ func (m *mockRepository) FindByID(ctx context.Context, id string) (*ports.Server func (m *mockRepository) UpdateStatus(ctx context.Context, id string, status string) error { return nil } + +func (m *mockRepository) ListAll(ctx context.Context) ([]*ports.ServerRecord, error) { + if m.savedRecord == nil { + return []*ports.ServerRecord{}, m.returnErr + } + return []*ports.ServerRecord{m.savedRecord}, m.returnErr +} diff --git a/internal/workers/provision_worker.go b/internal/workers/provision_worker.go index 7ddcd10..a6ec2dd 100644 --- a/internal/workers/provision_worker.go +++ b/internal/workers/provision_worker.go @@ -33,7 +33,14 @@ func (w *ProvisionWorker) Handle(ctx context.Context, job *jobs.Job) error { } if spec.ProjectID == "" { - return fmt.Errorf("invalid payload: project_id is required") + if spec.EnvironmentID != "" { + spec.ProjectID = spec.EnvironmentID + } else if spec.NamespaceID != "" { + spec.ProjectID = spec.NamespaceID + } + } + if spec.ProjectID == "" { + return fmt.Errorf("invalid payload: project_id or namespace_id is required") } report := func(status, runtimeRef, endpoint, errMsg string) { @@ -65,6 +72,7 @@ func (w *ProvisionWorker) Handle(ctx context.Context, job *jobs.Job) error { NodeID: server.Labels.NodeID, RuntimeRef: server.RuntimeRef, ProjectID: spec.ProjectID, + EnvironmentID: spec.EnvironmentID, } if err := w.repository.Save(ctx, record); err != nil { diff --git a/internal/workers/restart_worker.go b/internal/workers/restart_worker.go index 1311069..125cc4e 100644 --- a/internal/workers/restart_worker.go +++ b/internal/workers/restart_worker.go @@ -32,6 +32,11 @@ func (w *RestartWorker) Handle(ctx context.Context, job *jobs.Job) error { return fmt.Errorf("invalid payload: %w", err) } + if spec.ProjectID == "" { + if spec.EnvironmentID != "" { + spec.ProjectID = spec.EnvironmentID + } + } if spec.ProjectID == "" { return fmt.Errorf("invalid payload: project_id is required") } diff --git a/internal/workers/start_worker.go b/internal/workers/start_worker.go index fd98db4..876d617 100644 --- a/internal/workers/start_worker.go +++ b/internal/workers/start_worker.go @@ -33,7 +33,14 @@ func (w *StartWorker) Handle(ctx context.Context, job *jobs.Job) error { } if spec.ProjectID == "" { - return fmt.Errorf("invalid payload: project_id is required") + if spec.EnvironmentID != "" { + spec.ProjectID = spec.EnvironmentID + } else if spec.NamespaceID != "" { + spec.ProjectID = spec.NamespaceID + } + } + if spec.ProjectID == "" { + return fmt.Errorf("invalid payload: project_id or namespace_id is required") } report := func(status, runtimeRef, endpoint, errMsg string) { diff --git a/internal/workers/stop_worker.go b/internal/workers/stop_worker.go index 765d4a1..59aaf72 100644 --- a/internal/workers/stop_worker.go +++ b/internal/workers/stop_worker.go @@ -33,6 +33,11 @@ func (w *StopWorker) Handle(ctx context.Context, job *jobs.Job) error { log.Info("Stopping server", ports.LogKeyServerID, spec.ServerID) + if spec.ProjectID == "" { + if spec.EnvironmentID != "" { + spec.ProjectID = spec.EnvironmentID + } + } if spec.ProjectID == "" { return fmt.Errorf("invalid payload: project_id is required") } diff --git a/pkg/labels/labels.go b/pkg/labels/labels.go index f2f9079..6a5d89d 100644 --- a/pkg/labels/labels.go +++ b/pkg/labels/labels.go @@ -6,9 +6,17 @@ const ( ServerID = "kleff.io/server_id" // Deprecated: use WorkloadID; kept for reconcile during rollout BlueprintID = "kleff.io/blueprint_id" NodeID = "kleff.io/node_id" - ProjectID = "kleff.io/project_id" - ProjectSlug = "kleff.io/project_slug" - ManagedBy = "kleff.io/managed_by" + + // Phase 1: NamespaceID is the new authoritative tenancy label. + // New containers get this label; legacy containers only have ProjectID. + NamespaceID = "kleff.io/namespace_id" + + // Legacy labels — preserved for dual-read compat. + // Old containers have these; new containers get NamespaceID instead. + ProjectID = "kleff.io/project_id" + EnvironmentID = "kleff.io/environment_id" + ProjectSlug = "kleff.io/project_slug" + ManagedBy = "kleff.io/managed_by" ManagedByValue = "kleff-daemon" @@ -18,12 +26,14 @@ const ( ) type WorkloadLabels struct { - OwnerID string - ServerID string - BlueprintID string - NodeID string - ProjectID string - ProjectSlug string + OwnerID string + ServerID string + BlueprintID string + NodeID string + NamespaceID string // Phase 1: primary tenancy key + ProjectID string // Legacy fallback + EnvironmentID string + ProjectSlug string } func (l *WorkloadLabels) ToMap() map[string]string { @@ -36,15 +46,31 @@ func (l *WorkloadLabels) ToMap() map[string]string { ManagedBy: ManagedByValue, ComposeProject: ComposeProjectValue, } + if l.NamespaceID != "" { + m[NamespaceID] = l.NamespaceID + } if l.ProjectID != "" { m[ProjectID] = l.ProjectID } + if l.EnvironmentID != "" { + m[EnvironmentID] = l.EnvironmentID + } if l.ProjectSlug != "" { m[ProjectSlug] = l.ProjectSlug } return m } +// EffectiveNamespaceID returns the namespace ID for authorization. +// It prefers the new namespace_id label and falls back to project_id +// for legacy containers whose labels cannot be updated (Docker immutability). +func (l *WorkloadLabels) EffectiveNamespaceID() string { + if l.NamespaceID != "" { + return l.NamespaceID + } + return l.ProjectID // legacy fallback +} + func FromMap(m map[string]string) WorkloadLabels { if m[ManagedBy] != ManagedByValue { return WorkloadLabels{} @@ -55,11 +81,13 @@ func FromMap(m map[string]string) WorkloadLabels { workloadID = m[ServerID] } return WorkloadLabels{ - OwnerID: m[OwnerID], - ServerID: workloadID, - BlueprintID: m[BlueprintID], - NodeID: m[NodeID], - ProjectID: m[ProjectID], - ProjectSlug: m[ProjectSlug], + OwnerID: m[OwnerID], + ServerID: workloadID, + BlueprintID: m[BlueprintID], + NodeID: m[NodeID], + NamespaceID: m[NamespaceID], + ProjectID: m[ProjectID], + EnvironmentID: m[EnvironmentID], + ProjectSlug: m[ProjectSlug], } } diff --git a/pkg/labels/labels_test.go b/pkg/labels/labels_test.go index 292ec24..d8f1779 100644 --- a/pkg/labels/labels_test.go +++ b/pkg/labels/labels_test.go @@ -61,6 +61,7 @@ func TestFromMap_Valid(t *testing.T) { labels.BlueprintID: "blue-789", labels.NodeID: "node-001", labels.ProjectID: "proj-abc", + labels.EnvironmentID: "proj-abc", labels.ProjectSlug: "my-project", labels.ManagedBy: labels.ManagedByValue, "some-other-label": "ignored",