From 32b52d121f398e321bb5b5621c41a1b798d70170 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: Sun, 4 Oct 2026 09:01:28 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20e2e=20QA=20hardening=20=E2=80=94=20hones?= =?UTF-8?q?t=20runtime=20state,=20stable=20public=20ports,=20read-only=20a?= =?UTF-8?q?non=20UI=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: e2e QA hardening — honest runtime URLs, health, and read-only anon UI Backend: - managed DB provisioning: attach DB containers to the platform network and probe the container IP, fixing the 127.0.0.1 reachability failure inside the backend container; log provisioning failures - stop fabricating connection URLs when provisioning produced none - template deploys now carry port + health_check into service runtime fields - service health probed over the project network (backend joins it) instead of an in-container wget that fails on shell-less images - runtime/public/connection URLs rewrite loopback to the request Host so links are usable for LAN/remote clients Frontend: - service Networking panel shows real runtime URLs/ports/health instead of hardcoded containr.dev + 8080->443 - anonymous views hide all mutation controls (new project, add service, group, context menu) - project cards: remove decorative svcN labels; badge says DOWN only when zero services run, IDLE when empty, DEGRADED for partial - mobile projects header no longer clips (fluid search width) - unknown routes render a not-found page instead of silently redirecting Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: telemetry scope label, chunk split, remove stale vite artifacts - /system/host now reports scope=host|docker-desktop-vm and the daemon OS; Projects dashboard labels cards VM.* when the daemon is a Desktop VM so memory/core numbers aren't mistaken for physical host values - manualChunks: split @xyflow/react and recharts out of the main bundle; removes the >1MB chunk warning - drop tracked vite.config.js/.d.ts build artifacts that shadowed the real vite.config.ts Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: persist service host port; demo-data scope for telemetry Public URLs were ephemeral: every restart/redeploy rebound a random host port, so endpoint links rotted. Persist published_port on the service and prefer it for replica 0 on create. If the port is taken, fall back to ephemeral and repersist the new assignment. Post-start the binding is verified — Docker Desktop accepts a conflicting publish silently, which previously left services running with no endpoint at all. demo-data.ts gains the required scope field for HostMonitoring (CI fix). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/backend/internal/api/databases.go | 102 ++++++++----- app/backend/internal/api/databases_test.go | 46 +++++- app/backend/internal/api/deployments.go | 7 + app/backend/internal/api/runtime.go | 33 ++++- app/backend/internal/api/services.go | 4 +- app/backend/internal/api/system.go | 8 ++ app/backend/internal/api/templates.go | 14 +- app/backend/internal/deployment/engine.go | 4 + app/backend/internal/deployment/runtime.go | 135 +++++++++++++++--- app/backend/internal/docker/client.go | 75 +++++++++- .../20261003120000_service_published_port.sql | 8 ++ app/frontend/src/app/App.tsx | 26 +++- .../features/projects/pages/ProjectsPage.tsx | 31 ++-- .../service/pages/ServiceDetailPage.tsx | 61 +++++--- .../workspace/canvas/ProjectCanvas.tsx | 96 +++++++------ .../workspace/pages/ProjectWorkspacePage.tsx | 4 +- app/frontend/src/generated/api-types.ts | 6 + app/frontend/src/lib/api-client.ts | 22 +-- app/frontend/src/lib/demo-data.ts | 1 + app/frontend/vite.config.d.ts | 2 - app/frontend/vite.config.js | 51 ------- app/frontend/vite.config.ts | 4 +- docs/api/openapi.yaml | 6 + 23 files changed, 541 insertions(+), 205 deletions(-) create mode 100644 app/backend/migrations_goose/20261003120000_service_published_port.sql delete mode 100644 app/frontend/vite.config.d.ts delete mode 100644 app/frontend/vite.config.js diff --git a/app/backend/internal/api/databases.go b/app/backend/internal/api/databases.go index 0fd2e85..d395c7d 100644 --- a/app/backend/internal/api/databases.go +++ b/app/backend/internal/api/databases.go @@ -23,6 +23,7 @@ import ( dockercontainer "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/registry" "github.com/docker/go-connections/nat" "github.com/gin-gonic/gin" @@ -196,7 +197,7 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) { db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} } db.Settings = h.generateMockSettings() - db.ConnectionURL = h.resolveConnectionURL(db) + db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c)) } else { db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.ConnectionURL = "" @@ -235,7 +236,7 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) { db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} } db.Settings = h.generateMockSettings() - db.ConnectionURL = h.resolveConnectionURL(db) + db.ConnectionURL = rewriteLoopbackURL(h.resolveConnectionURL(db), requestHostname(c)) } else { db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}} db.ConnectionURL = "" @@ -942,33 +943,47 @@ func (h *DatabaseHandler) generateMockSettings() DatabaseSettings { } } +// resolveConnectionURL returns the stored connection URL for privileged +// viewers, or "" when the runtime has not produced one (not provisioned yet). func (h *DatabaseHandler) resolveConnectionURL(db DatabaseService) string { - if strings.TrimSpace(db.ConnectionURL) != "" { - return db.ConnectionURL - } - return h.generateConnectionURL(db) + return strings.TrimSpace(db.ConnectionURL) } -func (h *DatabaseHandler) generateConnectionURL(db DatabaseService) string { - safeName := sanitizeDBIdentifier(db.Name, "app") - switch db.Type { - case "postgresql": - return fmt.Sprintf("postgresql://user:password@%s.containr.local:5432/%s", safeName, safeName) - case "redis": - return fmt.Sprintf("redis://%s.containr.local:6379", safeName) - case "dragonfly": - return fmt.Sprintf("redis://%s.containr.local:6379", safeName) - case "mysql": - return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName) - case "mariadb": - return fmt.Sprintf("mysql://user:password@%s.containr.local:3306/%s", safeName, safeName) - case "mongodb": - return fmt.Sprintf("mongodb://user:password@%s.containr.local:27017/%s", safeName, safeName) - case "clickhouse": - return fmt.Sprintf("http://%s.containr.local:8123", safeName) - default: +// requestHostname returns the host the client used to reach the API, without +// the port. Empty when it cannot be determined. +func requestHostname(c *gin.Context) string { + host := strings.TrimSpace(c.Request.Host) + if host == "" { return "" } + if h, _, err := net.SplitHostPort(host); err == nil { + return strings.Trim(h, "[]") + } + return strings.Trim(host, "[]") +} + +// rewriteLoopbackURL swaps loopback/unspecified hosts in rawURL for the host +// the caller actually used, so URLs handed to remote browsers are usable. +func rewriteLoopbackURL(rawURL, host string) string { + if rawURL == "" || host == "" { + return rawURL + } + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + switch strings.ToLower(u.Hostname()) { + case "localhost", "127.0.0.1", "0.0.0.0", "::1", "": + default: + return rawURL + } + port := u.Port() + if port == "" { + u.Host = host + } else { + u.Host = net.JoinHostPort(host, port) + } + return u.String() } func (h *DatabaseHandler) reconcileManagedDatabaseState(ctx context.Context, db DatabaseService) DatabaseService { @@ -1223,13 +1238,13 @@ func (h *DatabaseHandler) provisionDatabaseWithVariables(databaseID, databaseNam defer cancel() if h.dockerClient == nil { - fallbackURL := h.generateConnectionURL(DatabaseService{Name: databaseName, Type: dbType}) - _ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{String: fallbackURL, Valid: fallbackURL != ""}) + _ = h.setDatabaseStatusAndConnection(databaseID, "running", sql.NullString{}) return } connectionURL, err := h.provisionDatabaseRuntime(ctx, databaseID, databaseName, dbType, runtimeVariables) if err != nil { + log.Printf("containr: managed database %s (%s) provisioning failed: %v", databaseID, dbType, err) _ = h.setDatabaseStatusAndConnection(databaseID, "error", sql.NullString{}) return } @@ -1337,7 +1352,7 @@ func (h *DatabaseHandler) provisionDatabaseRuntime(ctx context.Context, database if err != nil { return "", err } - if err := waitForRuntimePortReadiness(ctx, hostPort); err != nil { + if err := h.waitForDatabaseReadiness(ctx, containerID, hostPort, plan.Port.Port()); err != nil { return "", err } @@ -1371,12 +1386,24 @@ func (h *DatabaseHandler) resolvePublishedHostPort(ctx context.Context, containe return "", fmt.Errorf("published host port not available") } -func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error { - addr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort)) - deadline := time.Now().Add(30 * time.Second) +// waitForDatabaseReadiness probes the database's TCP port until it accepts a +// connection. The published port lives on host loopback, which a containerized +// backend cannot reach; in that case the database is additionally attached to +// the backend's own network and probed by container IP. +func (h *DatabaseHandler) waitForDatabaseReadiness(ctx context.Context, containerID, hostPort, containerPort string) error { + probeAddr := net.JoinHostPort("127.0.0.1", strings.TrimSpace(hostPort)) + if backendNet := h.dockerClient.OwnNetworkName(ctx); backendNet != "" { + if err := h.dockerClient.ConnectNetwork(ctx, backendNet, containerID, network.EndpointSettings{}); err != nil { + log.Printf("containr: attach database %s to %s failed: %v", containerID, backendNet, err) + } + if ip := h.containerIPOnNetwork(ctx, containerID, backendNet); ip != "" { + probeAddr = net.JoinHostPort(ip, containerPort) + } + } + deadline := time.Now().Add(30 * time.Second) for time.Now().Before(deadline) { - conn, err := net.DialTimeout("tcp", addr, 1500*time.Millisecond) + conn, err := net.DialTimeout("tcp", probeAddr, 1500*time.Millisecond) if err == nil { _ = conn.Close() return nil @@ -1389,7 +1416,18 @@ func waitForRuntimePortReadiness(ctx context.Context, hostPort string) error { } } - return fmt.Errorf("database runtime did not become reachable on %s", addr) + return fmt.Errorf("database runtime did not become reachable on %s", probeAddr) +} + +func (h *DatabaseHandler) containerIPOnNetwork(ctx context.Context, containerID, networkName string) string { + inspect, err := h.dockerClient.GetContainer(ctx, containerID) + if err != nil || inspect.NetworkSettings == nil { + return "" + } + if ep, ok := inspect.NetworkSettings.Networks[networkName]; ok && ep != nil { + return strings.TrimSpace(ep.IPAddress) + } + return "" } func (h *DatabaseHandler) ensureImage(ctx context.Context, imageRef string) error { diff --git a/app/backend/internal/api/databases_test.go b/app/backend/internal/api/databases_test.go index ef3fd54..0c8e734 100644 --- a/app/backend/internal/api/databases_test.go +++ b/app/backend/internal/api/databases_test.go @@ -51,15 +51,16 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) { } for _, tc := range cases { - url := handler.generateConnectionURL(DatabaseService{ - Type: tc.dbType, - Name: "example", - }) - if url == "" { - t.Fatalf("generateConnectionURL returned empty for type %q", tc.dbType) + plan, err := buildDatabaseRuntimePlan(tc.dbType, "example", nil) + if err != nil { + t.Fatalf("buildDatabaseRuntimePlan(%q) failed: %v", tc.dbType, err) } + url := plan.ConnectionURL("15432") if len(tc.urlContains) > 0 && !strings.HasPrefix(url, tc.urlContains) { - t.Fatalf("generateConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains) + t.Fatalf("ConnectionURL(%q) = %q, expected prefix %q", tc.dbType, url, tc.urlContains) + } + if !strings.Contains(url, ":15432") { + t.Fatalf("ConnectionURL(%q) = %q, expected published port", tc.dbType, url) } gotVersion := handler.getDefaultVersion(tc.dbType) @@ -69,6 +70,17 @@ func TestDatabaseConnectionURLAndDefaultVersion(t *testing.T) { } } +func TestResolveConnectionURLNeverFabricates(t *testing.T) { + handler := &DatabaseHandler{} + + if got := handler.resolveConnectionURL(DatabaseService{Type: "postgresql", Name: "example"}); got != "" { + t.Fatalf("resolveConnectionURL with no stored URL = %q, want empty", got) + } + if got := handler.resolveConnectionURL(DatabaseService{ConnectionURL: "postgresql://u:p@localhost:15432/db"}); got != "postgresql://u:p@localhost:15432/db" { + t.Fatalf("resolveConnectionURL = %q, want stored URL", got) + } +} + func TestBuildDatabaseRuntimePlanSupportsAllTypes(t *testing.T) { cases := []struct { dbType string @@ -199,3 +211,23 @@ func TestHumanReadableBytes(t *testing.T) { } } } + +func TestRewriteLoopbackURL(t *testing.T) { + tests := []struct { + name, raw, host, want string + }{ + {"service url", "http://localhost:35981", "10.0.0.182", "http://10.0.0.182:35981"}, + {"db url keeps creds", "postgresql://u:p@localhost:35432/db?sslmode=disable", "10.0.0.182", "postgresql://u:p@10.0.0.182:35432/db?sslmode=disable"}, + {"ipv4 loopback", "redis://:pw@127.0.0.1:36379/0", "host.lan", "redis://:pw@host.lan:36379/0"}, + {"unspecified", "http://0.0.0.0:9000/x", "10.0.0.182", "http://10.0.0.182:9000/x"}, + {"external host untouched", "https://example.com:443/x", "10.0.0.182", "https://example.com:443/x"}, + {"empty raw", "", "10.0.0.182", ""}, + {"empty host", "http://localhost:35981", "", "http://localhost:35981"}, + {"no port", "http://localhost/path", "10.0.0.182", "http://10.0.0.182/path"}, + } + for _, tt := range tests { + if got := rewriteLoopbackURL(tt.raw, tt.host); got != tt.want { + t.Errorf("%s: rewriteLoopbackURL(%q,%q) = %q, want %q", tt.name, tt.raw, tt.host, got, tt.want) + } + } +} diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go index bbb4c5a..0b3b9d3 100644 --- a/app/backend/internal/api/deployments.go +++ b/app/backend/internal/api/deployments.go @@ -356,6 +356,12 @@ func runDeploymentAndSyncWithImage( replicas = 1 } + var publishedPort int32 + // Best effort: reuse the host port from the last live deployment so public + // URLs survive redeploys. Column exists post-migration; older DBs get + // ephemeral ports. + _ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort) + deployReq := &deployment.DeploymentRequest{ ProjectID: service.ProjectID.String(), ServiceID: service.ID.String(), @@ -367,6 +373,7 @@ func runDeploymentAndSyncWithImage( Environment: env, Replicas: replicas, PublicPort: int32(service.Port), + PublishedPort: publishedPort, Domain: service.Domain, HealthPath: service.HealthCheckPath, RestartPolicy: service.RestartPolicy, diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go index 48eaf2a..51fd126 100644 --- a/app/backend/internal/api/runtime.go +++ b/app/backend/internal/api/runtime.go @@ -36,6 +36,10 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe HealthPath: service.HealthCheckPath, RestartPolicy: service.RestartPolicy, } + // Best effort: reuse the host port from the last live deployment so the + // public URL survives restarts. Column exists post-migration; older DBs + // silently get ephemeral ports. + _ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&spec.PublishedPort) if cmd := strings.TrimSpace(service.Command); cmd != "" { spec.Command = strings.Fields(cmd) } @@ -283,6 +287,13 @@ func handleGetServiceRuntime(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + engine.ProbeServiceHealth(c.Request.Context(), state, service.ProjectID.String(), service.Name, service.Port, service.HealthCheckPath) + persistPublishedPort(db, service.ID, state.Ports) + if host := requestHostname(c); host != "" { + for i, u := range state.URLs { + state.URLs[i] = rewriteLoopbackURL(u, host) + } + } c.JSON(http.StatusOK, gin.H{"runtime": state}) } @@ -302,6 +313,7 @@ func reconcileNow(c *gin.Context, engine *deployment.DeploymentEngine, db *datab status := "stopped" if state != nil { status = state.Status + persistPublishedPort(db, service.ID, state.Ports) } _, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, status, time.Now(), service.ID) c.JSON(http.StatusOK, gin.H{"runtime": state}) @@ -397,6 +409,18 @@ func handleServiceRedeploy(c *gin.Context) { reconcileNow(c, engine, db, service) } +// persistPublishedPort records the host port Docker actually bound so later +// redeploys can reuse it. Idempotent — only writes when the value changed. +func persistPublishedPort(db *database.DB, serviceID uuid.UUID, ports []uint16) { + if len(ports) == 0 { + return + } + _, _ = db.Exec( + `UPDATE services SET published_port = $1 WHERE id = $2 AND published_port <> $1`, + int(ports[0]), serviceID, + ) +} + // liveServiceStatus reconciles the stored status with real container state. // No-op when Docker is unavailable — the stored status is returned as-is. func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) { @@ -415,10 +439,11 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) { service.Status = state.Status _, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID) } + persistPublishedPort(db, service.ID, state.Ports) if service.Domain != "" { service.PublicURL = "https://" + service.Domain } else if len(state.URLs) > 0 { - service.PublicURL = state.URLs[0] + service.PublicURL = rewriteLoopbackURL(state.URLs[0], requestHostname(c)) } } @@ -475,9 +500,13 @@ func runtimeScaleService(c *gin.Context, serviceID string, replicas int) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - if _, err := engine.ReconcileService(ctx, spec); err != nil { + state, err := engine.ReconcileService(ctx, spec) + if err != nil { return err } + if state != nil { + persistPublishedPort(db, service.ID, state.Ports) + } _, _ = db.Exec(`UPDATE services SET replicas = $1, updated_at = $2 WHERE id = $3`, replicas, time.Now(), service.ID) return nil } diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go index b4e2c47..b9d9b4d 100644 --- a/app/backend/internal/api/services.go +++ b/app/backend/internal/api/services.go @@ -520,7 +520,9 @@ func handleUpdateService(c *gin.Context) { } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - _, _ = engine.ReconcileService(ctx, spec) + if state, err := engine.ReconcileService(ctx, spec); err == nil && state != nil { + persistPublishedPort(db.(*database.DB), serviceCopy.ID, state.Ports) + } }() } } diff --git a/app/backend/internal/api/system.go b/app/backend/internal/api/system.go index fac4de0..b075867 100644 --- a/app/backend/internal/api/system.go +++ b/app/backend/internal/api/system.go @@ -40,6 +40,9 @@ type hostMonitoringResponse struct { UptimeSeconds int64 `json:"uptime_seconds"` DockerAvailable bool `json:"docker_available"` Docker map[string]any `json:"docker,omitempty"` + // Scope names what the telemetry actually measures: the physical host for + // native dockerd, or the Docker Desktop VM when the daemon runs in one. + Scope string `json:"scope"` CollectedAt string `json:"collected_at"` } @@ -159,6 +162,7 @@ func handleGetHostMonitoring(c *gin.Context) { Storage: storage, Load: load, UptimeSeconds: uptime, + Scope: "host", CollectedAt: time.Now().UTC().Format(time.RFC3339), } @@ -173,6 +177,10 @@ func handleGetHostMonitoring(c *gin.Context) { "images": info.Images, "driver": info.Driver, "server": info.ServerVersion, + "os": info.OperatingSystem, + } + if strings.Contains(strings.ToLower(info.OperatingSystem), "docker desktop") { + response.Scope = "docker-desktop-vm" } } } diff --git a/app/backend/internal/api/templates.go b/app/backend/internal/api/templates.go index d530cf5..936ab84 100644 --- a/app/backend/internal/api/templates.go +++ b/app/backend/internal/api/templates.go @@ -336,6 +336,16 @@ func handleCreateFromTemplate(c *gin.Context) { return } + // sqlc's schema predates the runtime columns; port and healthcheck_path are + // written via raw SQL like every other service mutation path. + if _, err = tx.ExecContext(ctx, + `UPDATE services SET port = $1, healthcheck_path = $2 WHERE id = $3`, + config.Port, strings.TrimSpace(config.HealthCheck), serviceID, + ); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"}) + return + } + for key, value := range envVars { if strings.TrimSpace(key) == "" { continue @@ -731,7 +741,7 @@ func SeedTemplates() []ServiceTemplate { Name: "Background Worker", Description: "Background job processing service", Category: "worker", - Logo: "https://cdn.simpleicons.org/terminal", + Logo: "https://cdn.simpleicons.org/gnubash", Config: `{"type":"worker","runtime":"node","build_command":"npm install","start_command":"npm run worker"}`, Variables: `[{"key":"WORKER_CONCURRENCY","label":"Concurrency","default":"4","required":false,"secret":false}]`, IsOfficial: true, @@ -741,7 +751,7 @@ func SeedTemplates() []ServiceTemplate { Name: "Cron Job", Description: "Scheduled task runner", Category: "cron", - Logo: "https://cdn.simpleicons.org/clock", + Logo: "https://cdn.simpleicons.org/clockify", Config: `{"type":"cron","runtime":"node","build_command":"npm install","start_command":"npm run cron"}`, Variables: `[{"key":"CRON_SCHEDULE","label":"Schedule","default":"0 * * * *","required":true,"secret":false}]`, IsOfficial: true, diff --git a/app/backend/internal/deployment/engine.go b/app/backend/internal/deployment/engine.go index a87d16c..4cd265a 100644 --- a/app/backend/internal/deployment/engine.go +++ b/app/backend/internal/deployment/engine.go @@ -59,6 +59,9 @@ type ServiceConfig struct { Domain string `json:"domain,omitempty"` // HealthPath is an HTTP path probed on PublicPort for container health. HealthPath string `json:"health_path,omitempty"` + // PublishedPort is the host port bound by the previous deployment; replica 0 + // reuses it so public URLs survive restarts. 0 = pick ephemeral. + PublishedPort int32 `json:"published_port,omitempty"` } type PortMapping struct { @@ -315,6 +318,7 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo Env: cfg.Environment, Replicas: cfg.Replicas, Port: cfg.PublicPort, + PublishedPort: cfg.PublishedPort, Domain: cfg.Domain, HealthPath: cfg.HealthPath, RestartPolicy: cfg.RestartPolicy, diff --git a/app/backend/internal/deployment/runtime.go b/app/backend/internal/deployment/runtime.go index 7c0bfaf..19d7833 100644 --- a/app/backend/internal/deployment/runtime.go +++ b/app/backend/internal/deployment/runtime.go @@ -5,8 +5,11 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" + "net/http" "sort" + "strconv" "strings" "time" @@ -38,6 +41,7 @@ type RuntimeSpec struct { Env map[string]string // already resolved Replicas int Port int32 // container port to expose publicly; 0 = none + PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral Domain string // public hostname routed via Traefik; empty = none HealthPath string // http path probed on Port for container healthcheck RestartPolicy string @@ -58,7 +62,9 @@ type RuntimeState struct { Desired int `json:"desired"` Containers []RuntimeContainer `json:"containers"` URLs []string `json:"urls"` - Status string `json:"status"` // running, degraded, stopped + Ports []uint16 `json:"ports"` + Health string `json:"health,omitempty"` // healthy, unhealthy, unreachable + Status string `json:"status"` // running, degraded, stopped } func projectNetworkName(projectID string) string { @@ -237,33 +243,72 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec, Networks: endpoints, } + var wantPort nat.Port if spec.Port > 0 { - port := nat.Port(fmt.Sprintf("%d/tcp", spec.Port)) - cfg.ExposedPorts = nat.PortSet{port: struct{}{}} - // Always publish an ephemeral host port for direct access; a configured - // domain is additionally routed via Traefik when the edge network exists. - cfg.PortBindings = nat.PortMap{port: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}} - if spec.HealthPath != "" { - cfg.Healthcheck = &container.HealthConfig{ - Test: []string{"CMD-SHELL", fmt.Sprintf("wget -q -O /dev/null http://127.0.0.1:%d%s || exit 1", spec.Port, spec.HealthPath)}, - Interval: 30 * time.Second, - Timeout: 5 * time.Second, - Retries: 3, - StartPeriod: 10 * time.Second, - } - } + wantPort = nat.Port(fmt.Sprintf("%d/tcp", spec.Port)) + cfg.ExposedPorts = nat.PortSet{wantPort: struct{}{}} } - id, err := de.dockerClient.CreateContainer(ctx, cfg) + // tryBind creates and starts a container publishing wantPort on hostPort + // ("" = ephemeral). A requested port is verified after start: Docker + // Desktop accepts a conflicting binding at create/start but publishes + // nothing, which would leave the service running with no endpoint. + tryBind := func(hostPort string) (string, error) { + if spec.Port > 0 { + cfg.PortBindings = nat.PortMap{wantPort: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: hostPort}}} + } + id, err := de.dockerClient.CreateContainer(ctx, cfg) + if err != nil { + return "", err + } + if err := de.dockerClient.StartContainer(ctx, id); err != nil { + _ = de.dockerClient.RemoveContainer(ctx, id, true) + return "", fmt.Errorf("start: %w", err) + } + if hostPort != "" { + info, err := de.dockerClient.GetContainer(ctx, id) + if err != nil { + _ = de.dockerClient.RemoveContainer(ctx, id, true) + return "", fmt.Errorf("inspect: %w", err) + } + if info.NetworkSettings == nil || len(info.NetworkSettings.Ports[wantPort]) == 0 { + _ = de.dockerClient.RemoveContainer(ctx, id, true) + return "", errPortNotBound + } + } + return id, nil + } + + preferred := "" + if index == 0 && spec.PublishedPort > 0 { + preferred = strconv.Itoa(int(spec.PublishedPort)) + } + id, err := tryBind(preferred) + if err != nil && preferred != "" && (isPortConflict(err) || errors.Is(err, errPortNotBound)) { + // Stored port got taken since last deploy — fall back to ephemeral; the + // new assignment is persisted on the next runtime read. + id, err = tryBind("") + } if err != nil { return "", err } - if err := de.dockerClient.StartContainer(ctx, id); err != nil { - return "", fmt.Errorf("start: %w", err) - } return id, nil } +// errPortNotBound marks a container that started but failed to publish the +// requested host port (seen on Docker Desktop, which reports success while +// vpnkit drops a conflicting binding). +var errPortNotBound = errors.New("requested host port not bound") + +// isPortConflict reports whether a container create failed because the +// requested host port was already bound. +func isPortConflict(err error) bool { + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "port is already allocated") || + strings.Contains(msg, "address already in use") || + strings.Contains(msg, "cannot assign requested address") +} + // RuntimeState reads live container state for a service. func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string, desired int) (*RuntimeState, error) { containers, err := de.ListServiceContainers(ctx, serviceID) @@ -282,6 +327,7 @@ func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string, for _, p := range c.Ports { if p.PublicPort != 0 { state.URLs = append(state.URLs, fmt.Sprintf("http://localhost:%d", p.PublicPort)) + state.Ports = append(state.Ports, p.PublicPort) } } } @@ -299,6 +345,57 @@ func (de *DeploymentEngine) RuntimeState(ctx context.Context, serviceID string, return state, nil } +// ProbeServiceHealth performs an HTTP GET on healthPath and records the +// outcome on state.Health. When the backend runs containerized it first +// attaches to the service's project network and probes the service by DNS +// name — this works for every image (no shell or wget required inside the +// container) and checks the service itself, not the published edge. A +// host-run backend falls back to probing the published host port. +func (de *DeploymentEngine) ProbeServiceHealth(ctx context.Context, state *RuntimeState, projectID, serviceName string, port int, healthPath string) { + if state == nil { + return + } + state.Health = "" + if healthPath == "" || port <= 0 { + return + } + if !strings.HasPrefix(healthPath, "/") { + healthPath = "/" + healthPath + } + + probeURL := "" + if networkName := projectNetworkName(projectID); networkName != "" { + if err := de.dockerClient.ConnectSelfToNetwork(ctx, networkName); err == nil { + probeURL = fmt.Sprintf("http://%s:%d%s", serviceName, port, healthPath) + } + } + if probeURL == "" && len(state.Ports) > 0 { + probeURL = fmt.Sprintf("http://%s:%d%s", de.dockerClient.HostProbeHost(ctx), state.Ports[0], healthPath) + } + if probeURL == "" { + return + } + + reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, probeURL, nil) + if err != nil { + state.Health = "unreachable" + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + state.Health = "unreachable" + return + } + defer resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 400 { + state.Health = "healthy" + return + } + state.Health = "unhealthy" +} + // StopService stops (but keeps) all replicas of a service. func (de *DeploymentEngine) StopService(ctx context.Context, serviceID string) error { containers, err := de.ListServiceContainers(ctx, serviceID) diff --git a/app/backend/internal/docker/client.go b/app/backend/internal/docker/client.go index e1e3f6e..3375108 100644 --- a/app/backend/internal/docker/client.go +++ b/app/backend/internal/docker/client.go @@ -6,6 +6,9 @@ import ( "encoding/json" "fmt" "io" + "os" + "sort" + "strings" "time" "github.com/docker/docker/api/types" @@ -23,7 +26,8 @@ import ( // Client wraps the Docker client with additional functionality type Client struct { - cli *client.Client + cli *client.Client + probeHost string } // NewClient creates a new Docker client @@ -45,6 +49,75 @@ func NewClient() (*Client, error) { return &Client{cli: cli}, nil } +// HostProbeHost returns an address through which host-published container +// ports are reachable. When this process runs inside a container, host +// loopback is unreachable; the bridge gateway IP routes to ports published +// on 0.0.0.0. Result is cached for the process lifetime. +func (c *Client) HostProbeHost(ctx context.Context) string { + if c.probeHost != "" { + return c.probeHost + } + host := "127.0.0.1" + if self, err := c.self(ctx); err == nil && self.NetworkSettings != nil { + for _, n := range self.NetworkSettings.Networks { + if n != nil && n.Gateway != "" { + host = n.Gateway + break + } + } + } + c.probeHost = host + return host +} + +// OwnNetworkName returns the name of the platform network this process's +// container is attached to, or "" when not running containerized. Per-project +// and default Docker networks are never selected; among remaining networks +// the lexicographically first wins so the choice is deterministic. +func (c *Client) OwnNetworkName(ctx context.Context) string { + self, err := c.self(ctx) + if err != nil || self.NetworkSettings == nil { + return "" + } + names := make([]string, 0, len(self.NetworkSettings.Networks)) + for name := range self.NetworkSettings.Networks { + switch { + case name == "bridge" || name == "host" || name == "none": + case strings.HasPrefix(name, "containr-proj-"): + default: + names = append(names, name) + } + } + sort.Strings(names) + if len(names) == 0 { + return "" + } + return names[0] +} + +// self inspects the container this process runs in (hostname == container ID). +func (c *Client) self(ctx context.Context) (types.ContainerJSON, error) { + hostname, err := os.Hostname() + if err != nil || hostname == "" { + return types.ContainerJSON{}, fmt.Errorf("hostname unavailable") + } + return c.cli.ContainerInspect(ctx, hostname) +} + +// ConnectSelfToNetwork attaches this process's container to the given network. +// No-op when already attached or when not running containerized. +func (c *Client) ConnectSelfToNetwork(ctx context.Context, networkName string) error { + hostname, err := os.Hostname() + if err != nil || hostname == "" { + return fmt.Errorf("hostname unavailable") + } + err = c.cli.NetworkConnect(ctx, networkName, hostname, &network.EndpointSettings{}) + if err != nil && strings.Contains(strings.ToLower(err.Error()), "already exists") { + return nil + } + return err +} + // ListContainers returns all containers func (c *Client) ListContainers(ctx context.Context, all bool) ([]types.Container, error) { return c.cli.ContainerList(ctx, container.ListOptions{ diff --git a/app/backend/migrations_goose/20261003120000_service_published_port.sql b/app/backend/migrations_goose/20261003120000_service_published_port.sql new file mode 100644 index 0000000..7939107 --- /dev/null +++ b/app/backend/migrations_goose/20261003120000_service_published_port.sql @@ -0,0 +1,8 @@ +-- +goose Up +-- Stable public host port for service replica 0. 0 means "not yet assigned"; +-- the runtime writes the ephemeral port it picked back here so restarts and +-- redeploys keep the same public endpoint instead of churning. +ALTER TABLE public.services ADD COLUMN IF NOT EXISTS published_port integer NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE public.services DROP COLUMN IF EXISTS published_port; diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index 5f71521..970e8dc 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -1,6 +1,6 @@ import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; -import { ShieldCheck } from 'lucide-react'; +import { Compass, ShieldCheck } from 'lucide-react'; import { PlatformShell } from './layout/PlatformShell'; import { ProjectsPage } from '@/features/projects/pages/ProjectsPage'; import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage'; @@ -103,6 +103,26 @@ function AdminRequired() { return ; } +function NotFoundPage() { + return ( +
+ +
+

Page not found

+

+ The page you are looking for does not exist or was moved. +

+
+ + Back to home + +
+ ); +} + export default function App() { return ( @@ -138,14 +158,14 @@ export default function App() { }> } /> + + } /> {/* Full-bleed design demo — renders its own chrome, no app shell */} }> } /> - - } /> ); diff --git a/app/frontend/src/features/projects/pages/ProjectsPage.tsx b/app/frontend/src/features/projects/pages/ProjectsPage.tsx index f962ae7..6f399f6 100644 --- a/app/frontend/src/features/projects/pages/ProjectsPage.tsx +++ b/app/frontend/src/features/projects/pages/ProjectsPage.tsx @@ -25,10 +25,10 @@ const demoDeploys = [ { id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' }, ]; -function getHealthStatus(stats: ProjectStats): 'healthy' | 'degraded' | 'critical' { - if (stats.service_count === 0) return 'healthy'; +function getHealthStatus(stats: ProjectStats): 'healthy' | 'idle' | 'degraded' | 'critical' { + if (stats.service_count === 0) return 'idle'; if (stats.running_services === stats.service_count) return 'healthy'; - if (stats.running_services >= stats.service_count / 2) return 'degraded'; + if (stats.running_services > 0) return 'degraded'; return 'critical'; } @@ -36,6 +36,8 @@ function healthBadge(health: ReturnType) { switch (health) { case 'healthy': return { label: 'RUNNING', cls: 'v-st-ok' }; + case 'idle': + return { label: 'IDLE', cls: '' }; case 'degraded': return { label: 'DEGRADED', cls: 'v-st-warn' }; case 'critical': @@ -70,7 +72,6 @@ function MiniCanvas({ project }: { project: ProjectEntity }) { {Array.from({ length: shown }).map((_, i) => (
- svc{i + 1}
))} {extra > 0 ? ( @@ -85,6 +86,7 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string } const health = getHealthStatus(project.stats); const badge = healthBadge(health); const envOk = health === 'healthy'; + const envLabel = envOk ? 'production' : health === 'idle' ? 'idle' : health === 'critical' ? 'down' : 'degraded'; return (
- {envOk ? 'production' : 'degraded'} + {envLabel} · @@ -179,6 +181,7 @@ export function ProjectsPage() { const totalServices = projects.reduce((sum, p) => sum + p.stats.service_count, 0); const runningServices = projects.reduce((sum, p) => sum + p.stats.running_services, 0); const host = hostQuery.data; + const hostLabel = host?.scope === 'docker-desktop-vm' ? 'VM' : 'HOST'; const cpuPct = isDemoMode ? 14 : host && host.cpu.cores > 0 ? Math.min(100, Math.round((host.load.load1m / host.cpu.cores) * 100)) : null; @@ -220,7 +223,7 @@ export function ProjectsPage() {

-
+
- + {signedIn && ( + + )}
{/* Host stat row */}
-
HOST.CPU
+
{hostLabel}.CPU
{cpuPct ?? '—'}%
{isDemoMode ? (
@@ -255,13 +260,13 @@ export function ProjectsPage() {
-
HOST.MEM
+
{hostLabel}.MEM
{memPct ?? '—'}%
85 ? 'r' : 'y'} style={{ width: `${memPct ?? 0}%` }} />
{isDemoMode ? '9.8G / 13G' : host ? `${fmtGB(host.memory.used)} / ${fmtGB(host.memory.total)}` : 'no telemetry'}
-
HOST.DISK
+
{hostLabel}.DISK
{diskPct ?? '—'}%
{isDemoMode ? '338G of 465G · high' : host ? `${fmtGB(host.storage.used)} of ${fmtGB(host.storage.total)}` : 'no telemetry'}
diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index 2f27626..cfb6760 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -972,31 +972,60 @@ export function ServiceDetailPage() {

Networking

Public endpoints and ports

+ {runtimeQuery.data?.health === 'healthy' && ( + HEALTHY + )} + {runtimeQuery.data?.health === 'unhealthy' && ( + UNHEALTHY + )}
- +
-
-
-
- HTTPS + {service.domain && ( + + )} + {(runtimeQuery.data?.urls ?? []).map((url) => ( +
+ + ))}
- Port + Internal
- 8080 → 443 + {service.name}{service.port ? `:${service.port}` : ''}
+ {!service.domain && (runtimeQuery.data?.urls ?? []).length === 0 && ( +

+ No public endpoint. Publish a port or set a domain in Settings → Networking to expose this service. +

+ )}
)}
diff --git a/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx b/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx index 508e92c..bbfd913 100644 --- a/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx +++ b/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx @@ -1099,15 +1099,17 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on ))}
)} - + {!readOnly && ( + + )}
{/* Floating: zoom controls (bottom-left) */} @@ -1170,15 +1172,17 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on Add a service from a git repo or Docker image - it runs on a private network with the rest of this project.

- + {!readOnly && ( + + )}
)} @@ -1191,32 +1195,36 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on > {contextMenu.kind === 'pane' && ( <> - - + {!readOnly && ( + <> + + + + )}
- {!isDemoMode && ( + {!isDemoMode && signedIn && (