Files
onyx/tools/ods/cmd/compose.go
roshan a8033533d1 refactor(ods): unify launchers and protect shared infrastructure (#16246)
## Description

Make ODS reuse shared development infrastructure by default, with
`--isolated` for separate datasets.

- Use the same service specifications and launchers for `stack`, `web
dev`, and `backend` commands. Remove the duplicate `stack
--frontend-only` option.
- Enforce project ownership across checkouts and resolve database
commands to the actual managed database.
- Stop services before pending migrations, preserve them when already at
head, and reject manual migrations beneath managed services.
- Harden startup cancellation, shutdown grace, descendant cleanup,
terminal input, and frontend API validation. Preserve Windows foreground
commands.
- Consolidate environment helpers, protect symlinked environment files,
propagate restore errors, and update help and documentation.

This PR contains only ODS and its development-stack skill. It does not
include the plugins implementation.

## How Has This Been Tested?

- Targeted command and regression tests using fake tools and disposable
Git repositories.
- Race tests for runtime targeting, ownership, migration ordering,
process cleanup, and terminal input.
- `go vet ./...`, pre-commit checks, native builds, and Windows
cross-builds.
- The full Go suite was run. Its remaining failures are the existing
macOS devcontainer SSH-mount fixture expectations in
`TestDevcontainerCommands` and `TestRunDevRebuild`.

No real database migrations, restores, or development-stack restarts
ran. Native Windows execution remains untested.

## Additional Options

- [ ] [Optional] Please cherry-pick this PR to the latest release
version.
- [x] [Optional] Override Linear Check

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
ODS now shares development infrastructure across worktrees by default,
with `--isolated` for separate datasets. `stack`, `web dev`, and
`backend` use the same service specs and launchers, and the duplicate
`stack --frontend-only` option is removed.

- Direct launches run the same dependency-prepare step as stack startup,
so `backend` and `web dev` work with fresh or stale Python environments.
- A stopped preview discards its former project state before retargeting
to another API owner.
- Enforces project ownership across checkouts and resolves database
commands to the managed database. New tests reject conflicting targets
before any mutation.
- Stops services before pending migrations, preserves them when already
at head, and rejects manual migrations beneath managed services.
Migration probe failures fail closed.
- Hardens startup cancellation, shutdown grace, and descendant cleanup;
ODS keeps the terminal foreground group so Ctrl-C still honors shutdown
timeouts, and tmux pane identifiers no longer change service identity.
- Database passwords reach tools through the child environment only,
never in process arguments; Windows foreground commands are preserved.
- Consolidates environment helpers, protects symlinked env files,
propagates restore errors, and refreshes help and documentation. Test
commits exercise the new migration-target and preview-launch paths.

<sup>Written for commit 14500cb051.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/onyx-dot-app/onyx/pull/16246?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="View guided diff"
src="https://www.cubic.dev/buttons/review-in-cubic-light.svg"></picture></a>
<a
href="https://www.cubic.dev/action/auto-fix/pr/onyx-dot-app/onyx/16246?returnTo=https%3A%2F%2Fgithub.com%2Fonyx-dot-app%2Fonyx%2Fpull%2F16246&source=description"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/turn-on-auto-fix-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/turn-on-auto-fix-light.svg"><img
alt="Turn on auto-fix"
src="https://www.cubic.dev/buttons/turn-on-auto-fix-light.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added the global `--isolated` option for separate development stacks;
shared infrastructure is the default, with clearer project ownership and
status information.
* Added `ods web dev` previews that can use a healthy managed API on
macOS and Linux, plus foreground startup on Windows.
* Expanded command guidance for development workflows, database
targeting, and dump formats.
* **Bug Fixes**
* Pending migrations now stop managed application services first;
healthy services remain running when no migrations are needed.
* Database operations verify project ownership and target selection
before making changes. Restore errors now fail the command, SQL restores
stop on errors, and temporary files are cleaned up after failures.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-10-19 20:07:02 +00:00

414 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cmd
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"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"
)
var validProfiles = []string{"dev", "multitenant"}
type ComposeOptions struct {
Tag string
Down bool
Wait bool
ForceRecreate bool
Infra bool
// Ports, when set, are the compose host port variables to write instead
// of scanning for free ports.
Ports map[string]string
// ExcludeServices are infra services left out of an --infra start.
ExcludeServices []string
Output io.Writer
}
// NewComposeCommand creates a new compose command for launching docker
// containers.
func NewComposeCommand() *cobra.Command {
opts := &ComposeOptions{}
cmd := &cobra.Command{
Use: "compose [profile]",
Short: "Launch Onyx docker containers",
Long: `Launch Onyx docker containers using docker compose.
By default, this runs docker compose up -d with the standard docker-compose.yml.
Paid features need a license in the database. When ONYX_DEV_LICENSE is set in
the shell, it is seeded into the api_server once the stack is healthy. The seed
is skipped with --infra, with --wait=false, and for the multitenant profile.
Available profiles:
dev Use dev configuration (exposes service ports for development)
multitenant Dev configuration plus multi-tenant (Onyx Cloud) mode
Examples:
# Start containers with default configuration
ods compose
# Start containers with dev configuration (exposes service ports)
ods compose dev
# Start containers in multi-tenant mode (dev configuration plus the
# docker-compose.multitenant.yml overlay)
ods compose multitenant
# Stop running containers
ods compose --down
ods compose dev --down
# Start without waiting for services to be healthy
ods compose --wait=false
# Force recreate containers
ods compose --force-recreate
# Start only infrastructure containers (no api_server, background, etc.)
ods compose dev --infra
# Use a specific image tag
ods compose --tag edge`,
Args: cobra.MaximumNArgs(1),
ValidArgs: validProfiles,
Run: func(cmd *cobra.Command, args []string) {
profile := ""
if len(args) > 0 {
profile = args[0]
}
if err := runCompose(profile, opts); err != nil {
log.Fatal(err)
}
},
}
cmd.Flags().BoolVar(&opts.Down, "down", false, "Stop running containers instead of starting them")
cmd.Flags().BoolVar(&opts.Wait, "wait", true, "Wait for services to be healthy before returning")
cmd.Flags().BoolVar(&opts.ForceRecreate, "force-recreate", false, "Force recreate containers even if unchanged")
cmd.Flags().StringVar(&opts.Tag, "tag", "", "Set the IMAGE_TAG for docker compose (e.g. edge, v2.10.4)")
cmd.Flags().BoolVar(&opts.Infra, "infra", false, "Start only infrastructure containers (db, cache, search, model servers)")
return cmd
}
// validateProfile checks that the given profile is valid.
func validateProfile(profile string) error {
if profile != "" && profile != "dev" && profile != "multitenant" {
return fatalErrorf("Invalid profile %q. Valid profiles: dev, multitenant", profile)
}
return nil
}
// composeFiles returns the list of docker compose files for the given profile.
// "multitenant" stacks a small overlay on the dev configuration.
func composeFiles(profile string) []string {
switch profile {
case "multitenant":
return []string{"docker-compose.yml", "docker-compose.dev.yml", "docker-compose.multitenant.yml"}
case "dev":
return []string{"docker-compose.yml", "docker-compose.dev.yml"}
default:
return []string{"docker-compose.yml"}
}
}
// composeProfiles returns Docker Compose profile names to activate. The object
// store and MinIO are defined with profiles: ["s3-filestore"] in
// docker-compose.yml, so it must be activated explicitly for commands like
// "down" that don't name services.
func composeProfiles(profile string) []string {
switch profile {
case "dev", "multitenant":
return []string{"s3-filestore"}
default:
return nil
}
}
// baseArgs builds the common "docker compose -p <project> -f ... -f ...
// --profile ..." argument prefix.
func baseArgs(profile, project string) []string {
args := []string{"compose", "-p", project}
for _, f := range composeFiles(profile) {
args = append(args, "-f", f)
}
for _, p := range composeProfiles(profile) {
args = append(args, "--profile", p)
}
return args
}
// profileLabel returns a display label for the profile.
func profileLabel(profile string) string {
if profile == "" {
return "default"
}
return profile
}
// execDockerCompose runs a docker compose command in the correct directory with
// optional extra environment variables.
func execDockerCompose(args []string, extraEnv []string) error {
return execDockerComposeOutput(args, extraEnv, os.Stdout)
}
func execDockerComposeOutput(args []string, extraEnv []string, output io.Writer) error {
log.Debugf("Running: docker %v", args)
dir, err := composeDir()
if err != nil {
return err
}
dockerCmd := exec.Command("docker", args...)
dockerCmd.Dir = dir
dockerCmd.Stdout = output
dockerCmd.Stderr = os.Stderr
dockerCmd.Stdin = os.Stdin
if len(extraEnv) > 0 {
dockerCmd.Env = append(os.Environ(), extraEnv...)
}
if err := dockerCmd.Run(); err != nil {
return fatalErrorf("Docker compose failed: %w", err)
}
return nil
}
// runningServiceNames returns the names of currently running services in the
// compose project by running "docker compose -p onyx ps --services".
// On any error it returns nil (completions will just be empty).
func runningServiceNames() []string {
target, err := resolveRuntimeTarget()
if err != nil {
return nil
}
gitRoot, err := paths.GitRoot()
if err != nil {
return nil
}
args := []string{"compose", "-p", target.Project, "ps", "--services"}
cmd := exec.Command("docker", args...)
cmd.Dir = filepath.Join(gitRoot, "deployment", "docker_compose")
out, err := cmd.Output()
if err != nil {
return nil
}
var services []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
services = append(services, line)
}
}
return services
}
// envForTag returns the environment slice needed to set IMAGE_TAG, or nil.
func envForTag(tag string) []string {
if tag == "" {
return nil
}
return []string{fmt.Sprintf("IMAGE_TAG=%s", tag)}
}
// composeDir returns the path to the docker compose directory.
func composeDir() (string, error) {
gitRoot, err := paths.GitRoot()
if err != nil {
return "", fatalErrorf("Failed to find git root: %w", err)
}
return filepath.Join(gitRoot, "deployment", "docker_compose"), nil
}
// setEnvValue sets a key=value pair in the .env file within the compose
// directory. If the key already exists its value is updated in place; otherwise
// the entry is appended. The file is created if it does not exist.
func setEnvValue(key, value string) error {
dir, err := composeDir()
if err != nil {
return err
}
envPath := filepath.Join(dir, ".env")
data, err := os.ReadFile(envPath)
if err != nil && !os.IsNotExist(err) {
return fatalErrorf("Failed to read %s: %w", envPath, err)
}
entry := fmt.Sprintf("%s=%s", key, value)
prefix := key + "="
if len(data) == 0 {
// File missing or empty – create with just this entry.
if err := os.WriteFile(envPath, []byte(entry+"\n"), 0644); err != nil {
return fatalErrorf("Failed to write %s: %w", envPath, err)
}
return nil
}
lines := strings.Split(string(data), "\n")
found := false
for i, line := range lines {
if strings.HasPrefix(line, prefix) {
lines[i] = entry
found = true
break
}
}
if !found {
// Insert before the trailing empty line (if the file ended with \n) so
// we don't accumulate blank lines.
if lines[len(lines)-1] == "" {
lines = append(lines[:len(lines)-1], entry, "")
} else {
lines = append(lines, entry)
}
}
if err := os.WriteFile(envPath, []byte(strings.Join(lines, "\n")), 0644); err != nil {
return fatalErrorf("Failed to write %s: %w", envPath, err)
}
return nil
}
// runCompose starts or stops Docker Compose containers for the current docker.
// For profiles that expose host ports ("dev", "multitenant"), it scans for
// available ports and writes them to the compose .env file before starting
// containers.
func runCompose(profile string, opts *ComposeOptions) error {
if err := validateProfile(profile); err != nil {
return err
}
target, err := resolveRuntimeTarget()
if err != nil {
return err
}
release, err := lockRuntimeTarget(target, true)
if err != nil {
return err
}
defer release()
if target.Managed {
st, err := stackForTarget(target).ReadState()
if err != nil {
return err
}
if st != nil && len(st.Services) > 0 {
return fmt.Errorf("project %s has managed application services; use 'ods stack stop' before changing containers", target.Project)
}
}
return runComposeTarget(profile, opts, target)
}
func runComposeTarget(profile string, opts *ComposeOptions, target devstack.Target) error {
if err := validateProfile(profile); err != nil {
return err
}
if !opts.Down {
if profile == "dev" || profile == "multitenant" {
// Dev runs the object store only; `ods object-store migrate` is
// the one command that starts MinIO. The compose files still
// default the app to MinIO for installs that predate the store.
for k, v := range map[string]string{"MINIO_REPLICAS": "0", "S3_ENDPOINT_URL": "http://object-store:8333"} {
if err := setEnvValue(k, v); err != nil {
return err
}
}
composeEnv := opts.Ports
if composeEnv == nil {
ports, err := docker.FindAvailablePorts(target.Project)
if err != nil {
return fatalErrorf("Failed to find available ports: %w", err)
}
composeEnv = ports.ComposeEnv()
}
for k, v := range composeEnv {
if err := setEnvValue(k, v); err != nil {
return err
}
}
}
}
args := baseArgs(profile, target.Project)
if opts.Down {
args = append(args, "down")
if opts.Infra {
// MinIO is not infra, but a migrate may have left it running.
args = append(append(args, docker.InfraServiceNames()...), "minio")
}
} else {
args = append(args, "up", "-d")
if opts.Wait {
args = append(args, "--wait")
}
if opts.ForceRecreate {
args = append(args, "--force-recreate")
}
if opts.Infra {
for _, name := range docker.InfraServiceNames() {
if !slices.Contains(opts.ExcludeServices, name) {
args = append(args, name)
}
}
}
}
projName := target.Project
action := "Starting"
if opts.Down {
action = "Stopping"
}
log.Infof("%s containers for project %q with %s configuration...", action, projName, profileLabel(profile))
output := opts.Output
if output == nil {
output = os.Stdout
}
if err := execDockerComposeOutput(args, envForTag(opts.Tag), output); err != nil {
return err
}
if opts.Down {
log.Info("Containers stopped successfully")
return nil
}
log.Info("Containers started successfully")
return seedComposeDevLicense(profile, opts, target.Project)
}
// seedComposeDevLicense installs the dev license in the running api_server so
// paid features unlock. The seed needs the migrations a healthy api_server has
// run, and multi-tenant mode takes its tier from the control plane instead.
func seedComposeDevLicense(profile string, opts *ComposeOptions, project string) error {
if os.Getenv(devLicenseEnv) == "" {
return nil
}
if opts.Infra || !opts.Wait || profile == "multitenant" {
log.Infof("Not seeding the dev license from %s for this configuration", devLicenseEnv)
return nil
}
log.Infof("Seeding the dev license from %s...", devLicenseEnv)
// A bare -e passes the value through from this process, off the argv.
args := append(baseArgs(profile, project), "exec", "-T", "-e", devLicenseEnv, "api_server",
"python", "-m", "scripts.seed_dev_license")
if err := execDockerCompose(args, nil); err != nil {
return fatalErrorf("Failed to seed the dev license: %v", err)
}
return nil
}