feat(workload): switch ListProxyRoutes to containers index
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).
This commit is contained in:
@@ -139,17 +139,24 @@ type ProxyRoute struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
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) {
|
func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT i.id, i.project_id, p.name, i.stage_id, s.name,
|
SELECT c.id, p.id, p.name, s.id, s.name,
|
||||||
i.image_tag, i.subdomain, i.container_id, i.port,
|
c.image_tag, c.subdomain, c.container_id, c.port,
|
||||||
i.proxy_route_id, i.npm_proxy_id, i.status, i.created_at
|
c.proxy_route_id, c.npm_proxy_id, c.state, c.created_at
|
||||||
FROM instances i
|
FROM containers c
|
||||||
JOIN projects p ON p.id = i.project_id
|
JOIN workloads w ON w.id = c.workload_id AND w.kind = 'project'
|
||||||
JOIN stages s ON s.id = i.stage_id
|
JOIN projects p ON p.id = w.ref_id
|
||||||
WHERE i.subdomain != '' AND (i.proxy_route_id != '' OR i.npm_proxy_id > 0)
|
JOIN stages s ON s.project_id = p.id AND s.name = c.role
|
||||||
ORDER BY p.name, s.name, i.created_at DESC`,
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query proxy routes: %w", err)
|
return nil, fmt.Errorf("query proxy routes: %w", err)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user