mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-23 03:42:59 -05:00
## 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 -->
414 lines
12 KiB
Go
414 lines
12 KiB
Go
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
|
||
}
|