mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
fix: e2e QA hardening — honest runtime state, stable public ports, read-only anon UI (#12)
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
CI / Infra (Compose + Scripts) (push) Canceled after 0s
CI / Backend (Go) (push) Canceled after 0s
CI / Frontend (React) (push) Canceled after 0s
CI / Compose boot smoke (push) Canceled after 0s
CI / Build & publish images (push) Canceled after 0s
CI / Release (push) Canceled after 0s
* 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>
This commit is contained in:
1 parent
d968eb2315
commit
32b52d121f
23 files changed
+541
-205
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user