Files
onyx/tools/ods/cmd/stack.go

497 lines
16 KiB
Go

package cmd
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/onyx-dot-app/onyx/tools/ods/internal/alembic"
"github.com/onyx-dot-app/onyx/tools/ods/internal/devstack"
"github.com/onyx-dot-app/onyx/tools/ods/internal/docker"
"github.com/onyx-dot-app/onyx/tools/ods/internal/paths"
)
// exitHook ends the process with a code after the status was printed. It
// goes through logrus so its exit handlers run; tests replace it.
var exitHook = func(code int) { log.StandardLogger().Exit(code) }
// stackOptions holds the flags shared by the lifecycle subcommands.
type stackOptions struct {
devstack.Options
JSON bool
}
// NewStackCommand creates the "stack" command that runs the local dev stack
// of the current worktree.
func NewStackCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "stack",
Short: "Run the local dev stack of this worktree",
Long: `Run the local Onyx dev stack from a source checkout: Compose infrastructure,
migrations, and the application services from .vscode/launch.json as
background processes.
Every worktree gets its own Compose project, its own host ports, and its own
state under its git metadata (git rev-parse --git-path ods-stack). Port leases
are shared across worktrees in ` + filepath.Join(paths.DataDir(), "stack") + `
so a worktree keeps its ports across restarts. A lifecycle command takes the
worktree's lock, so two agents running "up" at once converge on one stack.
Profiles:
minimal Web Server and API Server
default minimal plus the primary, light, beat, docfetching, docprocessing
and user_file_processing Celery workers
full every service in the "Run All Onyx Services" compound
The model server comes from Compose; --local-model-server runs it from the
checkout instead, for model server changes.
Examples:
ods stack up
ods stack up --profile full --json
ods stack status --json
ods stack logs api --follow
ods stack stop
ods stack down`,
}
cmd.AddCommand(newStackLifecycleCommand("up", "Set up the worktree, start infrastructure, migrate and start the services"))
cmd.AddCommand(newStackLifecycleCommand("start", "Start or reconcile the services against running infrastructure"))
cmd.AddCommand(newStackLifecycleCommand("restart", "Stop the services and start them again"))
cmd.AddCommand(newStackStopCommand("stop", "Stop the services, keep the infrastructure"))
cmd.AddCommand(newStackStopCommand("down", "Stop the services and the infrastructure (volumes are kept)"))
cmd.AddCommand(newStackStatusCommand())
cmd.AddCommand(newStackLogsCommand())
cmd.AddCommand(newStackPlanCommand())
return cmd
}
func addStackPlanFlags(cmd *cobra.Command, opts *stackOptions) {
cmd.Flags().StringVar(&opts.Profile, "profile", devstack.ProfileDefault, "Services to run: "+strings.Join(devstack.Profiles, ", "))
cmd.Flags().BoolVar(&opts.LocalModelServer, "local-model-server", false, "Run the model server from the checkout instead of Compose")
}
func newStackLifecycleCommand(name, short string) *cobra.Command {
opts := &stackOptions{}
cmd := &cobra.Command{
Use: name,
Short: short,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
stack, provider := openStack()
ctx := context.Background()
var status *devstack.Status
var err error
switch name {
case "up":
status, err = stack.Up(ctx, provider, opts.Options)
case "start":
status, err = stack.Start(ctx, provider, opts.Options)
default:
status, err = stack.Restart(ctx, provider, opts.Options)
}
if err != nil && status == nil {
// Setup failed before the services were reached; report the
// recorded phase and errors so the caller still gets a status.
if recorded, serr := stack.Status(); serr == nil && recorded != nil {
status = recorded
}
}
if status != nil {
printStackStatus(status, opts.JSON)
}
if err != nil {
log.Error(err)
switch {
case errors.Is(err, devstack.ErrLocked):
// The printed status is older context, not this run's result.
exitHook(3)
case status != nil && status.Phase != devstack.PhaseFailed:
exitHook(2)
default:
exitHook(1)
}
}
},
}
addStackPlanFlags(cmd, opts)
cmd.Flags().DurationVar(&opts.ReadyTimeout, "timeout", 3*time.Minute, "How long to wait for the services to become healthy")
cmd.Flags().DurationVar(&opts.LockWait, "lock-wait", 10*time.Minute, "How long to wait for another lifecycle command on this worktree")
cmd.Flags().DurationVar(&opts.StopTimeout, "stop-timeout", 20*time.Second, "Grace period before a stale service is killed")
cmd.Flags().BoolVar(&opts.SkipPrepare, "skip-prepare", false, "Skip uv sync and bun install")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Print the status as JSON")
return cmd
}
func newStackStopCommand(name, short string) *cobra.Command {
opts := &stackOptions{}
cmd := &cobra.Command{
Use: name,
Short: short,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
stack, provider := openStack()
var err error
if name == "down" {
err = stack.Down(context.Background(), provider, opts.Options)
} else {
err = stack.Stop(context.Background(), opts.Options)
}
if err != nil {
log.Fatal(err)
}
log.Infof("Stack %s: services stopped", name)
},
}
cmd.Flags().DurationVar(&opts.StopTimeout, "timeout", 20*time.Second, "Grace period before a service is killed")
cmd.Flags().DurationVar(&opts.LockWait, "lock-wait", 10*time.Minute, "How long to wait for another lifecycle command on this worktree")
return cmd
}
func newStackStatusCommand() *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "status",
Short: "Show the services, ports, URLs and health (exit 0 ready, 2 not ready, 1 no stack)",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
stack, _ := openStack()
status, err := stack.Status()
if err != nil {
log.Fatal(err)
}
if status == nil {
if asJSON {
fmt.Println("null")
} else {
log.Infof("No dev stack has been started in %s", stack.Root)
}
os.Exit(1)
}
printStackStatus(status, asJSON)
if !status.Ready {
exitHook(2)
}
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Print the status as JSON")
return cmd
}
func newStackLogsCommand() *cobra.Command {
var lines int
var follow bool
cmd := &cobra.Command{
Use: "logs [service...]",
Short: "Show service logs (names as in `ods stack status`)",
Args: cobra.ArbitraryArgs,
Run: func(cmd *cobra.Command, args []string) {
stack, _ := openStack()
if err := runStackLogs(stack, args, lines, follow); err != nil {
log.Fatal(err)
}
},
}
cmd.Flags().IntVar(&lines, "lines", 80, "Lines to show from the end of each log")
cmd.Flags().BoolVar(&follow, "follow", false, "Follow the logs")
return cmd
}
func newStackPlanCommand() *cobra.Command {
opts := &stackOptions{}
cmd := &cobra.Command{
Use: "plan",
Short: "Print the services a profile would run, without starting them",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
stack, provider := openStack()
st, err := stack.ReadState()
if err != nil {
log.Fatal(err)
}
ports := map[string]int{}
if st != nil {
ports = st.Ports
}
specs, err := provider.Plan(stack.Root, ports, devstack.Env{}, opts.WithDefaults())
if err != nil {
log.Fatal(err)
}
for _, sp := range specs {
fmt.Printf("%s (%s): cd %s && %s\n", sp.Slug, sp.Name, sp.Dir, strings.Join(sp.Command, " "))
}
},
}
addStackPlanFlags(cmd, opts)
return cmd
}
// openStack resolves the stack of the current worktree and its provider.
// The Compose project is unique per worktree unless --project overrides it.
func openStack() (*devstack.Stack, *stackProvider) {
cwd, err := os.Getwd()
if err != nil {
log.Fatal(err)
}
stack, err := devstack.Open(cwd, flagProjectName())
if err != nil {
log.Fatal(err)
}
stack.Registry = &devstack.Registry{Dir: filepath.Join(paths.DataDir(), "stack")}
docker.SetProjectFlags(stack.Project)
return stack, &stackProvider{stack: stack}
}
func printStackStatus(status *devstack.Status, asJSON bool) {
if asJSON {
data, err := json.MarshalIndent(status, "", " ")
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
return
}
fmt.Printf("project: %s\nprofile: %s\nphase: %s\nready: %t\n", status.Project, status.Profile, status.Phase, status.Ready)
names := make([]string, 0, len(status.URLs))
for name := range status.URLs {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
fmt.Printf("%-8s %s\n", name+":", status.URLs[name])
}
for _, svc := range status.Services {
detail := ""
switch {
case svc.State == devstack.StateSkipped:
detail = svc.Reason
case svc.Healthy != nil && *svc.Healthy:
detail = "healthy"
case svc.Healthy != nil:
detail = "unhealthy"
}
fmt.Printf("%-8s %-28s pid %-7d %s\n", svc.State, svc.Slug, svc.PID, detail)
if svc.LogTail != "" {
fmt.Printf(" log: %s\n", svc.Log)
for _, line := range strings.Split(svc.LogTail, "\n") {
fmt.Printf(" | %s\n", line)
}
}
}
for _, e := range status.Errors {
fmt.Printf("error: %s\n", e)
}
}
func runStackLogs(stack *devstack.Stack, services []string, lines int, follow bool) error {
st, err := stack.ReadState()
if err != nil {
return err
}
if st == nil {
return errors.New("no dev stack has been started in this worktree")
}
var files []string
for _, rec := range st.Services {
if rec.Log == "" {
continue
}
if len(services) == 0 {
files = append(files, rec.Log)
continue
}
for _, want := range services {
if rec.Slug == want || rec.Name == want {
files = append(files, rec.Log)
}
}
}
if len(files) == 0 {
return fmt.Errorf("no logs for %s", strings.Join(services, ", "))
}
args := []string{"-n", fmt.Sprint(lines)}
if follow {
args = append(args, "-F")
}
tail := exec.Command("tail", append(args, files...)...)
tail.Stdout = os.Stdout
tail.Stderr = os.Stderr
return tail.Run()
}
// stackProvider runs the host-specific steps with the existing ods
// commands: compose, env and alembic.
type stackProvider struct {
stack *devstack.Stack
}
func (p *stackProvider) Prepare(ctx context.Context) error {
for _, tool := range []string{"uv", "bun", "docker"} {
if _, err := exec.LookPath(tool); err != nil {
return fmt.Errorf("%s is not installed", tool)
}
}
if err := runInDir(ctx, p.stack.Root, "docker", "info"); err != nil {
return fmt.Errorf("docker is not running: %w", err)
}
log.Info("Syncing Python dependencies (uv sync --frozen)...")
if err := runInDir(ctx, p.stack.Root, "uv", "sync", "--frozen"); err != nil {
return err
}
return prepareWebDirOutput(filepath.Join(p.stack.Root, "web"), os.Stderr)
}
func runInDir(ctx context.Context, dir, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
}
return nil
}
// InfraPorts requests one durable host port per exposed infrastructure
// port. A container that already runs keeps its mapped port.
func (p *stackProvider) InfraPorts() ([]devstack.PortRequest, error) {
var reqs []devstack.PortRequest
for _, svc := range docker.InfraServices {
container := docker.ContainerName(p.stack.Project, svc.Name)
for _, spec := range svc.Ports {
req := devstack.PortRequest{Name: spec.ComposeVar, Preferred: spec.DefaultHost}
if port, err := docker.GetHostPort(container, spec.ContainerPort); err == nil {
req.Fixed = port
}
reqs = append(reqs, req)
}
}
return reqs, nil
}
func (p *stackProvider) AppPorts(opts devstack.Options) []devstack.PortRequest {
return devstack.AppPortRequests(opts)
}
// ComposeUp starts the infrastructure on the leased ports. With a local
// model server the Compose inference server stays down.
func (p *stackProvider) ComposeUp(ctx context.Context, ports map[string]int, opts devstack.Options) error {
composePorts := map[string]string{}
for _, svc := range docker.InfraServices {
for _, spec := range svc.Ports {
if port, ok := ports[spec.ComposeVar]; ok {
composePorts[spec.ComposeVar] = fmt.Sprint(port)
}
}
}
composeOpts := &ComposeOptions{Wait: true, Infra: true, Ports: composePorts, Output: os.Stderr}
if opts.LocalModelServer {
composeOpts.ExcludeServices = []string{"inference_model_server"}
ids, err := exec.CommandContext(ctx, "docker", "ps",
"--filter", "label=com.docker.compose.project="+p.stack.Project,
"--filter", "label=com.docker.compose.service=inference_model_server",
"--format", "{{.ID}}").Output()
if err != nil {
return fmt.Errorf("find this stack's inference container: %w", err)
}
if containers := strings.Fields(string(ids)); len(containers) > 0 {
stop := exec.CommandContext(ctx, "docker", append([]string{"stop"}, containers...)...)
stop.Stdout, stop.Stderr = os.Stderr, os.Stderr
if err := stop.Run(); err != nil {
return fmt.Errorf("stop this stack's unused inference container: %w", err)
}
}
}
if err := runCompose("dev", composeOpts); err != nil {
return err
}
// The dev stack runs the object store only.
return removeEnvKey(filepath.Join(p.stack.Root, ".vscode", ".env"), legacyEndpointVar)
}
func (p *stackProvider) ComposeDown(context.Context) error {
return runCompose("dev", &ComposeOptions{Down: true})
}
// Migrate runs alembic with the stack's explicit database host and port.
func (p *stackProvider) Migrate(ctx context.Context, env []string) error {
bin, err := alembic.FindAlembicBinary()
if err != nil {
return err
}
log.Infof("Running migrations against %s:%s", devstack.EnvValue(env, "POSTGRES_HOST"), devstack.EnvValue(env, "POSTGRES_PORT"))
cmd := exec.CommandContext(ctx, bin, "upgrade", "head")
cmd.Dir = filepath.Join(p.stack.Root, "backend")
cmd.Env = env
var output commandOutputTail
writer := io.MultiWriter(os.Stderr, &output)
cmd.Stdout, cmd.Stderr = writer, writer
if err := cmd.Run(); err != nil {
return fmt.Errorf("alembic upgrade head: %w\n%s", err, lastLines(string(output.data), 20))
}
return nil
}
type commandOutputTail struct {
data []byte
}
func (b *commandOutputTail) Write(p []byte) (int, error) {
const limit = 64 * 1024
n := len(p)
if n >= limit {
b.data = append(b.data[:0], p[n-limit:]...)
return n, nil
}
if overflow := len(b.data) + n - limit; overflow > 0 {
copy(b.data, b.data[overflow:])
b.data = b.data[:len(b.data)-overflow]
}
b.data = append(b.data, p...)
return n, nil
}
// lastLines returns the last n lines of text.
func lastLines(text string, n int) string {
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
func (p *stackProvider) Plan(root string, ports map[string]int, env devstack.Env, opts devstack.Options) ([]devstack.Spec, error) {
launch, err := devstack.LoadLaunch(filepath.Join(root, ".vscode", "launch.json"))
if err != nil {
return nil, err
}
return devstack.LaunchPlan(root, launch, ports, env, opts)
}
// InfraEnv maps the leased infrastructure ports to the variables the app
// reads, the same way `ods env` does.
func (p *stackProvider) InfraEnv(ports map[string]int) map[string]string {
values := map[string]string{}
for _, svc := range docker.InfraServices {
for _, spec := range svc.Ports {
port, ok := ports[spec.ComposeVar]
if !ok || spec.AppVar == "" {
continue
}
values[spec.AppVar] = spec.AppValue(port)
}
}
return values
}