mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-22 11:22:49 -05:00
341 lines
13 KiB
Go
341 lines
13 KiB
Go
//go:build !windows
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/onyx-dot-app/onyx/tools/ods/internal/devstack"
|
|
)
|
|
|
|
// stackTestRepo builds a worktree with the directories the stack provider
|
|
// touches and a fake docker, uv, bun, tail and alembic. It returns the root
|
|
// and the call logs by tool name.
|
|
func stackTestRepo(t *testing.T) (root string, calls map[string]string) {
|
|
t.Helper()
|
|
binDir := devtoolBinDir(t)
|
|
// The process manager identifies services with ps.
|
|
ps, err := exec.LookPath("/bin/ps")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(ps, filepath.Join(binDir, "ps")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
root = devtoolRepo(t)
|
|
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
|
for _, dir := range []string{".vscode", "backend", "web", filepath.Join("web", "node_modules"), filepath.Join("deployment", "docker_compose"), filepath.Join(".venv", "bin")} {
|
|
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
writeFile(t, filepath.Join(root, ".vscode", "env_template.txt"), "GEN_AI_API_KEY=<REPLACE THIS>\n")
|
|
writeFile(t, filepath.Join(root, ".vscode", "env.web_template.txt"), "AUTH_TYPE=basic\n")
|
|
writeFile(t, filepath.Join(root, ".vscode", ".env"), "S3_LEGACY_ENDPOINT_URL=http://localhost:9005\nKEEP=1\n")
|
|
writeFile(t, filepath.Join(root, "web", "node_modules", "keep"), "")
|
|
writeFile(t, filepath.Join(root, "web", "bun.lock"), "{}")
|
|
|
|
calls = map[string]string{}
|
|
// docker port answers for the relational_db container only.
|
|
calls["docker"] = devtoolFakeTool(t, binDir, "docker", `
|
|
if [ "$1" = port ]; then
|
|
case "$2" in *relational_db*) echo "0.0.0.0:5555"; exit 0;; *) exit 1;; esac
|
|
fi
|
|
if [ "$1" = ps ]; then echo owned-inference-container; fi
|
|
exit 0
|
|
`)
|
|
for _, tool := range []string{"uv", "bun", "tail"} {
|
|
calls[tool] = devtoolFakeTool(t, binDir, tool, "exit 0\n")
|
|
}
|
|
alembic := filepath.Join(root, ".venv", "bin", "alembic")
|
|
writeFile(t, alembic, "#!/bin/sh\nprintf '%s\\0' \"$#\" \"$@\" >> \"$0.calls\"\nprintf 'HOST=%s PORT=%s\\n' \"$POSTGRES_HOST\" \"$POSTGRES_PORT\" > \"$0.env\"\n")
|
|
if err := os.Chmod(alembic, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
calls["alembic"] = alembic + ".env"
|
|
return root, calls
|
|
}
|
|
|
|
func TestStackProvider_infraPortsAndEnv(t *testing.T) {
|
|
root, _ := stackTestRepo(t)
|
|
stack, provider := openStack()
|
|
if stack.Root != root || stack.Project != devstack.ProjectName(root) {
|
|
t.Fatalf("stack %+v", stack)
|
|
}
|
|
|
|
reqs, err := provider.InfraPorts()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
byName := map[string]devstack.PortRequest{}
|
|
for _, r := range reqs {
|
|
byName[r.Name] = r
|
|
}
|
|
if pg := byName["POSTGRES_HOST_PORT"]; pg.Fixed != 5555 || pg.Preferred != 5432 {
|
|
t.Errorf("postgres request %+v", pg)
|
|
}
|
|
if redis := byName["REDIS_HOST_PORT"]; redis.Fixed != 0 || redis.Preferred != 6379 {
|
|
t.Errorf("redis request %+v", redis)
|
|
}
|
|
|
|
env := provider.InfraEnv(map[string]int{"POSTGRES_HOST_PORT": 5555, "OBJECT_STORE_HOST_PORT": 9010, "MODEL_SERVER_HOST_PORT": 9001, "web": 3000})
|
|
want := map[string]string{"POSTGRES_PORT": "5555", "S3_ENDPOINT_URL": "http://localhost:9010", "MODEL_SERVER_PORT": "9001"}
|
|
if !reflect.DeepEqual(env, want) {
|
|
t.Errorf("InfraEnv = %v, want %v", env, want)
|
|
}
|
|
|
|
app := provider.AppPorts(devstack.Options{Profile: devstack.ProfileFull, LocalModelServer: true})
|
|
var names []string
|
|
for _, r := range app {
|
|
names = append(names, r.Name)
|
|
}
|
|
if !reflect.DeepEqual(names, []string{"web", "api", "mcp", "model", "celery-primary-metrics", "celery-light-metrics", "celery-docfetching-metrics", "celery-docprocessing-metrics", "celery-heavy-metrics", "celery-scheduled_tasks-metrics"}) {
|
|
t.Errorf("app ports %v", names)
|
|
}
|
|
}
|
|
|
|
func TestStackProvider_composeUpWritesPortsAndExcludesLocalModel(t *testing.T) {
|
|
root, calls := stackTestRepo(t)
|
|
stack, provider := openStack()
|
|
|
|
ports := map[string]int{"POSTGRES_HOST_PORT": 5555, "REDIS_HOST_PORT": 6380, "web": 3000}
|
|
if err := provider.ComposeUp(context.Background(), ports, devstack.Options{LocalModelServer: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
composeEnv := devtoolReadFile(t, filepath.Join(root, "deployment", "docker_compose", ".env"))
|
|
for _, want := range []string{"POSTGRES_HOST_PORT=5555\n", "REDIS_HOST_PORT=6380\n", "MINIO_REPLICAS=0\n"} {
|
|
if !strings.Contains(composeEnv, want) {
|
|
t.Errorf("compose .env lacks %q:\n%s", want, composeEnv)
|
|
}
|
|
}
|
|
var up []string
|
|
for _, c := range devtoolCalls(t, calls["docker"]) {
|
|
if len(c.Args) > 1 && c.Args[0] == "compose" && slices.Contains(c.Args, "up") {
|
|
up = c.Args
|
|
}
|
|
}
|
|
if up == nil {
|
|
t.Fatal("docker compose up was not run")
|
|
}
|
|
for _, want := range []string{"-p", stack.Project, "--wait", "relational_db", "cache"} {
|
|
if !slices.Contains(up, want) {
|
|
t.Errorf("compose up args lack %q: %v", want, up)
|
|
}
|
|
}
|
|
if slices.Contains(up, "inference_model_server") {
|
|
t.Errorf("compose up started the inference server with a local model server: %v", up)
|
|
}
|
|
var found, stopped bool
|
|
for _, call := range devtoolCalls(t, calls["docker"]) {
|
|
if len(call.Args) > 0 && call.Args[0] == "ps" {
|
|
found = slices.Contains(call.Args, "label=com.docker.compose.project="+stack.Project) && slices.Contains(call.Args, "label=com.docker.compose.service=inference_model_server")
|
|
}
|
|
if slices.Equal(call.Args, []string{"stop", "owned-inference-container"}) {
|
|
stopped = true
|
|
}
|
|
}
|
|
if !found || !stopped {
|
|
t.Fatal("local model mode did not stop only the project-scoped inference container")
|
|
}
|
|
env := devtoolReadFile(t, filepath.Join(root, ".vscode", ".env"))
|
|
if strings.Contains(env, "S3_LEGACY_ENDPOINT_URL") || !strings.Contains(env, "KEEP=1") {
|
|
t.Errorf(".vscode/.env after compose up:\n%s", env)
|
|
}
|
|
|
|
if err := provider.ComposeDown(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
last := devtoolCalls(t, calls["docker"])
|
|
if args := last[len(last)-1].Args; !slices.Contains(args, "down") {
|
|
t.Errorf("compose down args %v", args)
|
|
}
|
|
}
|
|
|
|
func TestStackProvider_prepareAndMigrate(t *testing.T) {
|
|
_, calls := stackTestRepo(t)
|
|
_, provider := openStack()
|
|
|
|
if err := provider.Prepare(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if uv := devtoolCalls(t, calls["uv"]); len(uv) != 1 || !reflect.DeepEqual(uv[0].Args, []string{"sync", "--frozen"}) {
|
|
t.Errorf("uv calls %v", uv)
|
|
}
|
|
if docker := devtoolCalls(t, calls["docker"]); len(docker) == 0 || docker[0].Args[0] != "info" {
|
|
t.Errorf("docker calls %v", docker)
|
|
}
|
|
|
|
env := []string{"POSTGRES_HOST=localhost", "POSTGRES_PORT=5555"}
|
|
if err := provider.Migrate(context.Background(), env); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := devtoolReadFile(t, calls["alembic"]); got != "HOST=localhost PORT=5555\n" {
|
|
t.Errorf("alembic env %q", got)
|
|
}
|
|
}
|
|
|
|
func TestStackProvider_prepareReportsMissingTool(t *testing.T) {
|
|
root, _ := stackTestRepo(t)
|
|
_, provider := openStack()
|
|
if err := os.Remove(filepath.Join(filepath.Dir(os.Getenv("PATH")), filepath.Base(os.Getenv("PATH")), "bun")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := provider.Prepare(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "bun is not installed") {
|
|
t.Errorf("Prepare in %s: %v", root, err)
|
|
}
|
|
}
|
|
|
|
func TestStackPlanCommand_printsResolvedServices(t *testing.T) {
|
|
root, _ := stackTestRepo(t)
|
|
writeFile(t, filepath.Join(root, ".vscode", "launch.json"), `{
|
|
"compounds": [{"name": "Run All Onyx Services", "configurations": ["Web Server", "API Server", "Celery primary"]}],
|
|
"configurations": [
|
|
{"name": "Web Server", "type": "node", "request": "launch", "cwd": "${workspaceRoot}/web", "runtimeExecutable": "bun", "runtimeArgs": ["run", "dev"]},
|
|
{"name": "API Server", "type": "debugpy", "request": "launch", "module": "uvicorn", "cwd": "${workspaceFolder}/backend", "args": ["onyx.main:app", "--port", "8080"]},
|
|
{"name": "Celery primary", "type": "debugpy", "request": "launch", "program": "${workspaceFolder}/backend/scripts/dev_celery_reload.py", "cwd": "${workspaceFolder}/backend", "args": ["-A", "primary"]}
|
|
]
|
|
}`)
|
|
cmd := NewRootCommand()
|
|
cmd.SetArgs([]string{"stack", "plan", "--profile", "minimal"})
|
|
out := composeCapture(t, &os.Stdout, func() {
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
python := devstack.PythonExecutable(root)
|
|
want := "web (Web Server): cd " + filepath.Join(root, "web") + " && bun run dev\n" +
|
|
"api (API Server): cd " + filepath.Join(root, "backend") + " && " + python + " -m uvicorn onyx.main:app --port 8080\n"
|
|
if out != want {
|
|
t.Errorf("plan output:\n%s\nwant:\n%s", out, want)
|
|
}
|
|
}
|
|
|
|
func TestPrintStackStatus(t *testing.T) {
|
|
healthy := true
|
|
status := &devstack.Status{
|
|
Project: "onyx-abc", Profile: "default", Phase: devstack.PhaseReady, Ready: true,
|
|
Ports: map[string]int{"web": 3000, "api": 8080},
|
|
URLs: map[string]string{"web": "http://localhost:3000", "api": "http://localhost:8080"},
|
|
Services: []devstack.ServiceStatus{
|
|
{Name: "Web Server", Slug: "web", State: devstack.StateRunning, PID: 12, Healthy: &healthy},
|
|
{Name: "Slack Bot", Slug: "slack-bot", State: devstack.StateSkipped, Reason: "port 8000 busy"},
|
|
},
|
|
Errors: []string{"boom"},
|
|
}
|
|
text := composeCapture(t, &os.Stdout, func() { printStackStatus(status, false) })
|
|
for _, want := range []string{"project: onyx-abc", "ready: true", "api: http://localhost:8080", "running web", "healthy", "skipped slack-bot", "port 8000 busy", "error: boom"} {
|
|
if !strings.Contains(text, want) {
|
|
t.Errorf("text status lacks %q:\n%s", want, text)
|
|
}
|
|
}
|
|
js := composeCapture(t, &os.Stdout, func() { printStackStatus(status, true) })
|
|
if !strings.Contains(js, `"phase": "ready"`) || !strings.Contains(js, `"healthy": true`) {
|
|
t.Errorf("json status:\n%s", js)
|
|
}
|
|
}
|
|
|
|
func TestRunStackLogs(t *testing.T) {
|
|
root, calls := stackTestRepo(t)
|
|
stack, _ := openStack()
|
|
if err := runStackLogs(stack, nil, 10, false); err == nil || !strings.Contains(err.Error(), "no dev stack") {
|
|
t.Errorf("logs without state: %v", err)
|
|
}
|
|
webLog := filepath.Join(root, "web.log")
|
|
apiLog := filepath.Join(root, "api.log")
|
|
st := &devstack.State{Phase: devstack.PhaseReady, Services: []devstack.ServiceRecord{
|
|
{Name: "Web Server", Slug: "web", Log: webLog, StartedAt: time.Now()},
|
|
{Name: "API Server", Slug: "api", Log: apiLog, StartedAt: time.Now()},
|
|
{Name: "Slack Bot", Slug: "slack-bot", Skipped: "busy"},
|
|
}}
|
|
if err := stack.WriteState(st); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := runStackLogs(stack, nil, 10, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := runStackLogs(stack, []string{"api"}, 5, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := runStackLogs(stack, []string{"nope"}, 5, false); err == nil || !strings.Contains(err.Error(), "no logs for nope") {
|
|
t.Errorf("unknown service: %v", err)
|
|
}
|
|
tail := devtoolCalls(t, calls["tail"])
|
|
if len(tail) != 2 {
|
|
t.Fatalf("tail calls %v", tail)
|
|
}
|
|
if !reflect.DeepEqual(tail[0].Args, []string{"-n", "10", webLog, apiLog}) {
|
|
t.Errorf("all logs: %v", tail[0].Args)
|
|
}
|
|
if !reflect.DeepEqual(tail[1].Args, []string{"-n", "5", "-F", apiLog}) {
|
|
t.Errorf("one log followed: %v", tail[1].Args)
|
|
}
|
|
}
|
|
|
|
// runStackCommand runs "ods stack <args>" and returns its stdout.
|
|
func runStackCommand(t *testing.T, args ...string) string {
|
|
t.Helper()
|
|
cmd := NewRootCommand()
|
|
cmd.SetArgs(append([]string{"stack"}, args...))
|
|
return composeCapture(t, &os.Stdout, func() {
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestStackCommands_lifecycleWithFakeService(t *testing.T) {
|
|
root, _ := stackTestRepo(t)
|
|
// The only service is a worker with no port, run by a fake interpreter
|
|
// that just stays alive.
|
|
writeFile(t, filepath.Join(root, ".venv", "bin", "python"), "#!/bin/sh\nexec /bin/sleep 300\n")
|
|
if err := os.Chmod(filepath.Join(root, ".venv", "bin", "python"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFile(t, filepath.Join(root, ".vscode", "launch.json"), `{
|
|
"compounds": [{"name": "Run All Onyx Services", "configurations": ["Celery primary"]}],
|
|
"configurations": [
|
|
{"name": "Celery primary", "type": "debugpy", "request": "launch", "program": "${workspaceFolder}/backend/scripts/dev_celery_reload.py", "cwd": "${workspaceFolder}/backend", "args": ["-A", "primary"]}
|
|
]
|
|
}`)
|
|
stack, _ := openStack()
|
|
t.Cleanup(func() {
|
|
_ = stack.Stop(context.Background(), devstack.Options{StopTimeout: 2 * time.Second, LockWait: 10 * time.Second})
|
|
})
|
|
|
|
runStackCommand(t, "stop")
|
|
runStackCommand(t, "down")
|
|
|
|
out := runStackCommand(t, "up", "--profile", "full", "--skip-prepare", "--timeout", "20s", "--json")
|
|
if !strings.Contains(out, `"phase": "ready"`) || !strings.Contains(out, `"slug": "celery-primary"`) {
|
|
t.Fatalf("up output:\n%s", out)
|
|
}
|
|
if !strings.Contains(devtoolReadFile(t, filepath.Join(root, ".vscode", ".env")), "POSTGRES_PORT=5555") {
|
|
t.Error("infra port was not written to .vscode/.env")
|
|
}
|
|
out = runStackCommand(t, "status")
|
|
if !strings.Contains(out, "ready: true") || !strings.Contains(out, "running celery-primary") {
|
|
t.Errorf("status output:\n%s", out)
|
|
}
|
|
runStackCommand(t, "logs", "celery-primary")
|
|
out = runStackCommand(t, "restart", "--profile", "full", "--skip-prepare", "--timeout", "20s")
|
|
if !strings.Contains(out, "phase: ready") {
|
|
t.Errorf("restart output:\n%s", out)
|
|
}
|
|
runStackCommand(t, "stop")
|
|
st, err := stack.ReadState()
|
|
if err != nil || st == nil || st.Phase != devstack.PhaseStopped || len(st.Services) != 0 {
|
|
t.Errorf("state after stop: %+v %v", st, err)
|
|
}
|
|
runStackCommand(t, "down")
|
|
}
|