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

384 lines
11 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/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 string) []string {
args := []string{"compose", "-p", docker.ProjectName()}
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 {
gitRoot, err := paths.GitRoot()
if err != nil {
return nil
}
args := []string{"compose", "-p", docker.ProjectName(), "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
}
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()
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)
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 := docker.ProjectName()
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)
}
// 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) 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), "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
}