package activator import ( "context" "encoding/json" "net" "net/http" "os" "path/filepath" "slices" "strings" "testing" "time" "github.com/carlosframework/platform/internal/instancectl" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" ) // shortRunDir is a RunDirOverride short enough for sockets under it: the // test name is not part of the path (t.TempDir's would be), so a long test // name cannot push a socket past sockaddr_un's limit. Run socket tests with // TMPDIR=/tmp/k. func shortRunDir(t *testing.T) string { t.Helper() dir, err := os.MkdirTemp("", "g") if err != nil { t.Fatal(err) } t.Cleanup(func() { os.RemoveAll(dir) }) return dir } // flatGenRoute is a route that classifies as FLAT against b: the default // db path and /.sock. func flatGenRoute(b *ExecBackend, host string) registry.Route { return registry.Route{Host: host, Kind: "instance", Hibernate: true, DBPath: provision.DefaultDBPath(b.DataDir, host), Socket: filepath.Join(b.RunDirOverride, host+".sock")} } func isSocket(path string) bool { fi, err := os.Lstat(path) return err == nil && fi.Mode()&os.ModeSocket != 0 } func isRegular(path string) bool { fi, err := os.Lstat(path) return err == nil && fi.Mode().IsRegular() } // Flag off: StartGen(perGen=false) is Start, byte for byte. The child gets // the canonical socket in argv and the canonical control socket in env. func TestStartGenWithGenerationsOffIsStart(t *testing.T) { b := testExecBackend(t) host := "gen-off.example.com" argvPath, envPath := stageRecordingLive(t, b, host, "v1") rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(shortRunDir(t), "instance.sock")} ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() done := make(chan struct{}) var proc Proc go func() { proc, _, _ = b.StartGen(ctx, rt, 5, false) // the recorder never binds; a health error is expected close(done) }() gotArgv := strings.Fields(waitForFile(t, argvPath, time.Second)) gotEnv := strings.TrimSpace(waitForFile(t, envPath, time.Second)) cancel() // recorded: end the health wait the recorder never answers <-done if proc != nil { defer proc.Stop(context.Background()) } wantArgv := []string{"--socket", rt.Socket, "--db", filepath.Join(b.DataDir, host+".db")} if !slices.Equal(gotArgv, wantArgv) { t.Fatalf("argv = %v, want %v (today's argv, unchanged)", gotArgv, wantArgv) } if gotEnv != rt.Socket+".control" { t.Fatalf("CARLOS_CONTROL_SOCKET = %q, want %q", gotEnv, rt.Socket+".control") } } // Flag off, healthy: the handle names the canonical socket the app bound. func TestStartGenWithGenerationsOffBindsTheCanonicalSocket(t *testing.T) { requireUnixSockets(t) b := testExecBackend(t) b.RunDirOverride = shortRunDir(t) host := "canon.example.com" stageServingLive(t, b, host) rt := flatGenRoute(b, host) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() proc, g, err := b.StartGen(ctx, rt, 11, false) if err != nil { t.Fatalf("StartGen: %v", err) } defer proc.Stop(context.Background()) if g.N != 11 || g.Socket != rt.Socket || g.Control != rt.Socket+".control" { t.Fatalf("handle = %+v, want the canonical socket and control", g) } if !isSocket(rt.Socket) { t.Fatalf("%s is not a bound socket", rt.Socket) } if _, err := os.Stat(filepath.Join(b.RunDirOverride, "g")); !os.IsNotExist(err) { t.Fatalf("the generation directory exists with generations off (stat: %v)", err) } } // Flag on, flat: the host binds its generation socket, g//.sock // (rev 19), and its control socket beside it, and only the generation's OWN // stale file is removed. func TestStartGenFlatBindsItsOwnGenerationSocket(t *testing.T) { requireUnixSockets(t) b := testExecBackend(t) b.RunDirOverride = shortRunDir(t) host := "shop.example.com" stageServingLive(t, b, host) rt := flatGenRoute(b, host) if l, _ := b.Layout(rt); l != LayoutFlat { t.Fatalf("fixture classifies %q, want flat", l) } own := genSocketPathIn(filepath.Join(b.RunDirOverride, "g"), host, 9) sibling := genSocketPathIn(filepath.Join(b.RunDirOverride, "g"), host, 8) if want := filepath.Join(b.RunDirOverride, "g", "9", host+".sock"); own != want { t.Fatalf("own generation path %s, want %s", own, want) } for _, p := range []string{own, sibling} { if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte("stale"), 0o600); err != nil { t.Fatal(err) } } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() proc, g, err := b.StartGen(ctx, rt, 9, true) if err != nil { t.Fatalf("StartGen: %v", err) } defer proc.Stop(context.Background()) if g.N != 9 || g.Socket != own || g.Control != own+".control" { t.Fatalf("handle = %+v, want socket %s", g, own) } if !isSocket(own) { t.Fatalf("own generation path %s is not a bound socket: the stale file was not removed", own) } if !isSocket(g.Control) { t.Fatalf("control socket %s was not bound", g.Control) } if !isRegular(sibling) { t.Fatalf("sibling generation's file %s was touched; only the own path may be removed", sibling) } if _, err := os.Lstat(rt.Socket); !os.IsNotExist(err) { t.Fatalf("canonical path %s exists with generations on (lstat: %v)", rt.Socket, err) } } // Flag on, nested: the launch file names g/app.sock (rev 19), the // control env follows it, and prepare removes only that generation's stale // file. func TestStartGenNestedUsesItsOwnDirectory(t *testing.T) { t.Parallel() ctl := &fakeControl{inv: "inv1"} unit := &fakeUnit{} b, rt := nestedBackend(t, ctl, unit) runDir := filepath.Dir(rt.Socket) own := filepath.Join(runDir, "g9", "app.sock") sibling := filepath.Join(runDir, "g8", "app.sock") for _, p := range []string{rt.Socket, sibling, own} { if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte("stale"), 0o600); err != nil { t.Fatal(err) } } var stopServing func() ctl.onStart = func() { stopServing = serveOK(t, own) } defer func() { if stopServing != nil { stopServing() } }() unit.set("active", "inv1", true, true) p, g, err := b.StartGen(context.Background(), rt, 9, true) if err != nil { t.Fatalf("StartGen: %v", err) } if g.Socket != own || g.Control != own+".control" { t.Fatalf("handle = %+v, want %s", g, own) } raw, err := os.ReadFile(filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), "launch.run.json")) if err != nil { t.Fatal(err) } var l instancectl.Launch if err := json.Unmarshal(raw, &l); err != nil { t.Fatal(err) } if len(l.Argv) != 5 || l.Argv[1] != "--socket" || l.Argv[2] != own { t.Fatalf("launch argv = %v, want --socket %s", l.Argv, own) } if !slices.Contains(l.Env, "CARLOS_CONTROL_SOCKET="+own+".control") { t.Fatalf("launch env lacks CARLOS_CONTROL_SOCKET=%s.control: %v", own, l.Env) } if !isRegular(rt.Socket) || !isRegular(sibling) { t.Fatal("prepare removed a file that is not this generation's own socket") } unit.set("inactive", "inv1", false, false) waitFor(t, "the unit to exit", func() bool { return p.Exited() }) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() _ = p.Stop(ctx) } // NextDueVia dials the socket it is given, not rt.Socket. func TestNextDueViaDialsTheGivenSocket(t *testing.T) { requireUnixSockets(t) b := testExecBackend(t) host := "due.example.com" if err := os.WriteFile(filepath.Join(b.DataDir, host+".env"), []byte("CARLOS_ADMIN_TOKEN=tok\n"), 0o600); err != nil { t.Fatal(err) } sock := tmpSock(t) ln, err := net.Listen("unix", sock) if err != nil { t.Fatal(err) } srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/next-due" || r.Header.Get("Authorization") != "Bearer tok" { http.Error(w, "no", http.StatusForbidden) return } w.Write([]byte(`{"due":42}`)) })} go srv.Serve(ln) defer srv.Close() rt := registry.Route{Host: host, Kind: "instance", Socket: "/nonexistent/canonical.sock"} due, err := b.NextDueVia(context.Background(), rt, sock) if err != nil || due != 42 { t.Fatalf("NextDueVia = %d, %v; want 42 from the generation socket", due, err) } if _, err := b.NextDue(context.Background(), rt); err == nil { t.Fatal("NextDue reached something at the canonical path; the fixture has nothing there") } } // A unit-backed route has no generation sockets: SplitBackend starts it // with Start and hands back the canonical handle, whatever perGen says. func TestSplitBackendStartGenKeepsUnitTenantsCanonical(t *testing.T) { unitSide := &fakeBackend{execVerFn: func() string { return "v3" }} sb := &SplitBackend{Exec: &fakeBackend{}, Unit: unitSide} rt := registry.Route{Host: "u.example.com", Kind: "instance", Backing: registry.BackingUnit, Socket: "/run/carlos/u.example.com.sock"} _, g, err := sb.StartGen(context.Background(), rt, 4, true) if err != nil { t.Fatal(err) } if g != canonicalGen(rt, 4, "v3") { t.Fatalf("handle = %+v, want the canonical one", g) } if unitSide.starts != 1 { t.Fatalf("unit side Start calls = %d, want 1", unitSide.starts) } }