From d516462750214a425c1809d7b3730788f608c374 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 9 May 2026 14:05:19 +0300 Subject: [PATCH] feat(workload): switch ListProxyRoutes to containers index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Proxies page consumer (and the secondary callers in internal/api/health.go and internal/api/settings.go) now read from the normalized containers index instead of the instances table. Stage ID is recovered through a (project_id, role=stage_name) join — uniquely-indexed via the existing UNIQUE(project_id, name) constraint on stages. Source field stays "instance" for back-compat with the Proxies page filter (the frontend keys off the literal string). Three new tests pin the join shape, verify the npm_proxy_id-only WHERE branch survives, and check that an orphan-role row falls out of the join cleanly (catches a regression to LEFT JOIN). --- internal/store/instances.go | 25 ++-- internal/store/proxy_routes_test.go | 173 ++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 internal/store/proxy_routes_test.go diff --git a/internal/store/instances.go b/internal/store/instances.go index c2f3954..9280170 100644 --- a/internal/store/instances.go +++ b/internal/store/instances.go @@ -139,17 +139,24 @@ type ProxyRoute struct { CreatedAt string `json:"created_at"` } -// ListProxyRoutes returns all instances that have a proxy configured, joined with project/stage names. +// ListProxyRoutes returns proxy-enabled project containers joined with +// project + stage names. Reads from the normalized containers index — the +// instances table is no longer queried. Stage ID is resolved through a +// (project_id, role=stage_name) join, which is uniquely indexed. +// +// Source is reported as "instance" for back-compat with the Proxies page +// filter (which keys off the literal string). func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) { rows, err := s.db.Query(` - SELECT i.id, i.project_id, p.name, i.stage_id, s.name, - i.image_tag, i.subdomain, i.container_id, i.port, - i.proxy_route_id, i.npm_proxy_id, i.status, i.created_at - FROM instances i - JOIN projects p ON p.id = i.project_id - JOIN stages s ON s.id = i.stage_id - WHERE i.subdomain != '' AND (i.proxy_route_id != '' OR i.npm_proxy_id > 0) - ORDER BY p.name, s.name, i.created_at DESC`, + SELECT c.id, p.id, p.name, s.id, s.name, + c.image_tag, c.subdomain, c.container_id, c.port, + c.proxy_route_id, c.npm_proxy_id, c.state, c.created_at + FROM containers c + JOIN workloads w ON w.id = c.workload_id AND w.kind = 'project' + JOIN projects p ON p.id = w.ref_id + JOIN stages s ON s.project_id = p.id AND s.name = c.role + WHERE c.subdomain != '' AND (c.proxy_route_id != '' OR c.npm_proxy_id > 0) + ORDER BY p.name, s.name, c.created_at DESC`, ) if err != nil { return nil, fmt.Errorf("query proxy routes: %w", err) diff --git a/internal/store/proxy_routes_test.go b/internal/store/proxy_routes_test.go new file mode 100644 index 0000000..2cc353b --- /dev/null +++ b/internal/store/proxy_routes_test.go @@ -0,0 +1,173 @@ +package store + +import "testing" + +// TestListProxyRoutesJoinShape verifies the new containers-based join produces +// the same ProxyRoute shape the /api/proxies frontend has consumed since this +// query was instances-based. Without this test, a missing column or a wrong +// join condition would silently break the Proxies page. +func TestListProxyRoutesJoinShape(t *testing.T) { + s := newTestStore(t) + + p, err := s.CreateProject(Project{ + Name: "wf", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}", + }) + if err != nil { + t.Fatalf("CreateProject: %v", err) + } + stage, err := s.CreateStage(Stage{ + ProjectID: p.ID, Name: "prod", TagPattern: "*", MaxInstances: 1, EnableProxy: true, + }) + if err != nil { + t.Fatalf("CreateStage: %v", err) + } + + w, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID) + if err != nil { + t.Fatalf("workload: %v", err) + } + + // Container with both subdomain and proxy_route_id populated — the rule + // the WHERE clause filters on. + if _, err := s.CreateContainer(Container{ + WorkloadID: w.ID, + WorkloadKind: "project", + Role: stage.Name, + ContainerID: "docker-abc", + ImageTag: "v1", + State: "running", + Port: 8080, + Subdomain: "wf-prod", + ProxyRouteID: "route-1", + }); err != nil { + t.Fatalf("CreateContainer: %v", err) + } + + // Container without subdomain — must be filtered OUT. + if _, err := s.CreateContainer(Container{ + WorkloadID: w.ID, + WorkloadKind: "project", + Role: stage.Name, + ContainerID: "docker-def", + ImageTag: "v2", + State: "running", + }); err != nil { + t.Fatalf("CreateContainer 2: %v", err) + } + + routes, err := s.ListProxyRoutes("example.test") + if err != nil { + t.Fatalf("ListProxyRoutes: %v", err) + } + + if len(routes) != 1 { + t.Fatalf("expected 1 route, got %d (filter wrong?)", len(routes)) + } + r := routes[0] + if r.Source != "instance" { + t.Errorf("Source: got %q, want 'instance' (back-compat)", r.Source) + } + if r.ProjectID != p.ID { + t.Errorf("ProjectID: got %q, want %q", r.ProjectID, p.ID) + } + if r.ProjectName != "wf" { + t.Errorf("ProjectName: got %q, want 'wf'", r.ProjectName) + } + if r.StageID != stage.ID { + t.Errorf("StageID: got %q, want %q", r.StageID, stage.ID) + } + if r.StageName != "prod" { + t.Errorf("StageName: got %q, want 'prod'", r.StageName) + } + if r.ImageTag != "v1" { + t.Errorf("ImageTag: got %q, want 'v1'", r.ImageTag) + } + if r.Subdomain != "wf-prod" { + t.Errorf("Subdomain: got %q, want 'wf-prod'", r.Subdomain) + } + if r.Domain != "wf-prod.example.test" { + t.Errorf("Domain: got %q, want 'wf-prod.example.test'", r.Domain) + } + if r.ContainerID != "docker-abc" { + t.Errorf("ContainerID: got %q, want 'docker-abc'", r.ContainerID) + } + if r.Port != 8080 { + t.Errorf("Port: got %d, want 8080", r.Port) + } + if r.ProxyRouteID != "route-1" { + t.Errorf("ProxyRouteID: got %q, want 'route-1'", r.ProxyRouteID) + } + if r.Status != "running" { + t.Errorf("Status (state): got %q, want 'running'", r.Status) + } +} + +func TestListProxyRoutesNpmOnly(t *testing.T) { + // NPM-only routes (npm_proxy_id > 0, proxy_route_id == "") must still be + // returned — that's the original WHERE-clause OR branch. + s := newTestStore(t) + + p, _ := s.CreateProject(Project{ + Name: "npm-only", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}", + }) + stage, _ := s.CreateStage(Stage{ + ProjectID: p.ID, Name: "dev", TagPattern: "*", MaxInstances: 1, EnableProxy: true, + }) + w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID) + + if _, err := s.CreateContainer(Container{ + WorkloadID: w.ID, + WorkloadKind: "project", + Role: stage.Name, + ContainerID: "docker-1", + Subdomain: "npm-only-dev", + NpmProxyID: 42, + }); err != nil { + t.Fatalf("CreateContainer: %v", err) + } + + routes, err := s.ListProxyRoutes("") + if err != nil { + t.Fatalf("ListProxyRoutes: %v", err) + } + if len(routes) != 1 { + t.Fatalf("expected 1 npm route, got %d", len(routes)) + } + if routes[0].NpmProxyID != 42 { + t.Errorf("NpmProxyID: got %d, want 42", routes[0].NpmProxyID) + } +} + +func TestListProxyRoutesIgnoresWrongRole(t *testing.T) { + // Belt-and-suspenders: a container whose role doesn't match a stage name + // would orphan the JOIN. Verify the row falls out cleanly (LEFT JOIN + // would expose a real bug here). + s := newTestStore(t) + + p, _ := s.CreateProject(Project{ + Name: "wf", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}", + }) + _, _ = s.CreateStage(Stage{ + ProjectID: p.ID, Name: "prod", TagPattern: "*", MaxInstances: 1, + }) + w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID) + + if _, err := s.CreateContainer(Container{ + WorkloadID: w.ID, + WorkloadKind: "project", + Role: "ghost-stage", // intentionally not a real stage name + ContainerID: "docker-x", + Subdomain: "wf-ghost", + ProxyRouteID: "route-x", + }); err != nil { + t.Fatalf("CreateContainer: %v", err) + } + + routes, err := s.ListProxyRoutes("") + if err != nil { + t.Fatalf("ListProxyRoutes: %v", err) + } + if len(routes) != 0 { + t.Fatalf("orphan-role row leaked into result: got %d", len(routes)) + } +}