Files
onyx/cli/internal/deploy/install/install.go
T

1574 lines
57 KiB
Go

package install
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/onyx-dot-app/onyx/cli/internal/deploy/deployfiles"
"github.com/onyx-dot-app/onyx/cli/internal/deploy/dockercmd"
"github.com/onyx-dot-app/onyx/cli/internal/deploy/paths"
"github.com/onyx-dot-app/onyx/cli/internal/deploy/release"
"github.com/onyx-dot-app/onyx/cli/internal/deploy/resources"
"github.com/onyx-dot-app/onyx/cli/internal/deploy/state"
"github.com/onyx-dot-app/onyx/cli/internal/deploy/ui"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/version"
)
// Resource expectations (halved thresholds in lite mode), from install.sh.
const (
expectedRAMGB = 10
expectedDiskGB = 32
expectedRAMGBLite = 4
expectedDiskGBLite = 16
waitTimeoutSeconds = 600
minComposeVersion = "2.24.0"
failureLogTail = 30
// dockerDesktopWait bounds the wait for the macOS daemon to answer, and so
// also bounds how long its spinner can turn with nothing else on screen.
dockerDesktopWait = 120 * time.Second
// s3FilestoreProfile is the COMPOSE_PROFILES entry that runs MinIO: on in
// standard mode, off in lite. It is the only entry the CLI owns.
s3FilestoreProfile = "s3-filestore"
)
// preflight carries the environment facts gathered concurrently while the
// user answers the setup questions. Healthy values stay quiet; only problems
// surface as warnings or provisioning offers.
// tagLookup carries the background release lookup's outcome. The error is
// reported by the receiving goroutine, not the sender: the output helpers
// drive the wizard, which only the main goroutine may touch.
type tagLookup struct {
tag string
err error
}
type preflight struct {
dockerVersion string
composeVersion string
composeType string
memoryMB int
diskGB int
rootless bool
}
// RunInstall implements `deploy install` (and the install-onyx alias): fresh
// installs, and restart/update runs against an existing deployment.
func RunInstall(ctx context.Context, deps Deps, opts Options) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
in := newInstaller(deps, opts)
in.cancel = cancel
in.lite = opts.Lite
in.craft = opts.IncludeCraft
in.prod = opts.Prod
in.dev = opts.Dev
err := in.runInstall(ctx)
if errors.Is(err, ui.ErrAborted) || (err != nil && ctx.Err() != nil) {
return exitcodes.New(exitcodes.General, "installation cancelled")
}
return err
}
func (in *installer) runInstall(ctx context.Context) error {
if in.opts.Lite && in.opts.IncludeCraft {
return exitcodes.New(exitcodes.BadRequest,
"--lite and --include-craft cannot be used together: Craft requires services (OpenSearch, Redis, background workers) that lite mode disables")
}
if in.opts.Prod && in.opts.Lite {
return exitcodes.New(exitcodes.BadRequest,
"--prod and --lite cannot be used together: they select conflicting compose files")
}
if in.opts.Prod && in.opts.Dev {
return exitcodes.New(exitcodes.BadRequest,
"--prod and --dev cannot be used together: the dev overlay publishes Postgres, Redis, OpenSearch and the API on the host, which a production deployment keeps behind nginx")
}
in.root = paths.Resolve(in.opts.Dir)
envPath := filepath.Join(in.deploymentDir(), ".env")
_, envErr := os.Stat(envPath)
rerun := envErr == nil
// Prod deployments are adopted or restarted, never created here: a fresh
// one needs a filled-in .env, a .env.nginx naming the domain, and TLS
// material from init-letsencrypt.sh before compose can as much as render.
if in.opts.Prod && !rerun {
return exitcodes.Newf(exitcodes.BadRequest,
"no deployment/.env at %s — prod mode manages an existing production deployment; set one up per deployment/docker_compose/docker-compose.prod.yml first, then adopt it with `onyx-cli deploy upgrade --prod --dir %s`",
in.root.Dir, in.root.Dir)
}
// --tag is checked before anything else runs: a typo must fail here, not
// minutes later as a pull error with the bad version already on disk.
// pinnedTag is assigned once, before the goroutines below read it, and
// never again — opts stays immutable for the rest of the run.
pinnedTag := in.opts.Tag
if pinnedTag != "" {
validated, verr := in.validateTag(ctx, pinnedTag)
if verr != nil {
return verr
}
pinnedTag = validated
}
// The latest-release lookup and the environment checks run in the
// background while the user answers the questions below.
tagCh := make(chan tagLookup, 1)
go func() {
switch {
case in.opts.Offline && pinnedTag == "":
// Deliberately not "edge": offline, the only deployable versions
// are the ones whose images are already here. errOffline routes
// this through the same fallback an unreachable GitHub takes.
tagCh <- tagLookup{err: errOffline}
case in.opts.Local:
tagCh <- tagLookup{tag: "edge"}
case pinnedTag != "":
tagCh <- tagLookup{tag: pinnedTag}
default:
tag, err := in.deps.Release.LatestAppTag(ctx)
tagCh <- tagLookup{tag: tag, err: err}
}
}()
// tagCh delivers exactly one value; latestTag memoizes it so every
// consumer (question defaults, initialTag) can read it safely. The
// fallback is reported here rather than in the goroutine: output helpers
// touch the wizard, which only the main goroutine may drive.
var latestTag string
getLatestTag := func() string {
if latestTag == "" {
got := <-tagCh
if got.err != nil {
got.tag = in.unreachableTagFallback(ctx)
}
latestTag = got.tag
}
return latestTag
}
if in.opts.DryRun {
in.printPlan(getLatestTag())
return nil
}
// Started only past the dry-run exit: a dry run inspects nothing about
// the host, and leaving docker probes running after the command returns
// makes its "no side effects" promise depend on timing.
preCh := make(chan preflight, 1)
go func() { preCh <- in.gatherPreflight(ctx) }()
manifest, err := state.Load(in.root.Dir)
if err != nil {
return err
}
hadManifest := manifest != nil
if manifest == nil {
manifest = &state.Manifest{}
}
in.resolveProject(manifest)
if in.fancy() {
in.wiz = ui.StartWizard(in.deps.IOS.Out, "Onyx Installer", in.deps.CLIVersion, in.cancel)
defer in.wiz.Abort()
}
// ---- Questions: every decision is made up front ----
chosenTag := pinnedTag // the tag this run deploys; "" until answered
var updateTag string // rerun: non-empty means update to this tag
pre, preSeen := preflight{}, false
if rerun {
// The running-services guard needs docker handles, so resolve the
// environment before asking anything.
pre, preSeen = <-preCh, true
if err := in.resolveDockerProblems(ctx, pre); err != nil {
return err
}
// Read the published port off the running deployment: installs
// predating HOST_PORT recording have it nowhere else.
in.observedPort = in.runningHostPort(ctx)
running, err := in.guardRunningServices(ctx)
if err != nil {
return err
}
// The mode never changes implicitly on a rerun: the recorded mode
// (or the overlays on disk) wins unless --lite/--include-craft say
// otherwise. (install.sh re-asked every run, so a --no-prompt rerun
// silently flipped standard installs to lite.)
in.prod = in.opts.Prod || manifest.Mode == state.ModeProd ||
in.overlayOnDisk(filepath.Base(deployfiles.ProdCompose.DestRel))
if in.prod && in.opts.Lite {
return exitcodes.New(exitcodes.BadRequest,
"this is a prod deployment — --lite cannot be applied to it")
}
if in.prod && in.opts.Dev {
return exitcodes.New(exitcodes.BadRequest,
"this is a prod deployment — --dev cannot be applied to it")
}
in.wasLite = manifest.Mode == state.ModeLite ||
in.overlayOnDisk(filepath.Base(deployfiles.LiteOverlay.DestRel))
in.lite = !in.prod && (in.opts.Lite || (!in.opts.IncludeCraft && in.wasLite))
in.craft = in.opts.IncludeCraft || manifest.IncludeCraft ||
in.overlayOnDisk(filepath.Base(deployfiles.CraftOverlay.DestRel))
in.dev = !in.prod && (in.opts.Dev || manifest.Dev ||
in.overlayOnDisk(filepath.Base(deployfiles.DevOverlay.DestRel)))
if pinnedTag != "" {
// --tag already answers the question below.
updateTag = pinnedTag
} else if !in.prompt.AssumeDefaults {
// This is also where a live deployment is signed off on: the
// choices restart it either way, so a separate "you know this
// restarts things" confirmation would only ask it twice.
title, restartHint, cancelHint :=
"This deployment already exists. What would you like to do?",
"keep the current configuration",
"leave the deployment as it is"
if running {
title, cancelHint = "Onyx is already running here. What would you like to do?",
"leave everything running"
}
choice, err := in.selectOne(title,
[]ui.Option{
{Label: "Restart", Hint: restartHint},
{Label: "Upgrade", Hint: "move to a newer Onyx version"},
{Label: "Cancel", Hint: cancelHint},
}, 0)
if err != nil {
return err
}
switch choice {
case 1:
updateTag, err = in.askVersion(ctx, "Version to deploy", getLatestTag())
if err != nil {
return err
}
case 2:
if running {
return exitcodes.New(exitcodes.General,
"cancelled — services left running (stop them with `onyx-cli deploy stop`)")
}
return exitcodes.New(exitcodes.General, "cancelled")
}
if in.wiz != nil {
if updateTag != "" {
in.wiz.Answer("Action", "Upgrade")
in.wiz.Answer("Version", updateTag)
} else {
in.wiz.Answer("Action", "Restart")
}
}
}
if running {
// Said once, whichever way the decision was reached: the run is
// about to sit on `pull` for a while, and this is what stops that
// looking like downtime.
in.infof("Onyx keeps serving while the images download; each service is replaced once, at the end")
}
} else {
if err := in.askModeQuestion(); err != nil {
return err
}
if chosenTag == "" {
tag, err := in.askVersion(ctx, "Version to deploy", getLatestTag())
if err != nil {
return err
}
chosenTag = tag
}
if in.wiz != nil {
in.wiz.Answer("Version", chosenTag)
}
}
// ---- Join the checks; only problems surface ----
if !preSeen {
pre = <-preCh
if err := in.resolveDockerProblems(ctx, pre); err != nil {
return err
}
}
if err := in.resourceWarnings(pre); err != nil {
return err
}
if err := in.checkComposeVersion(ctx); err != nil {
return err
}
// ---- Phase 1: prepare configuration ----
in.phase("Preparing configuration")
if in.root.Source == paths.SourceLegacyCwd {
in.infof("Managing existing install at %s (created by install.sh)", in.root.Dir)
}
for _, alt := range in.root.Ambiguous {
in.warnf("Another Onyx install exists at %s — pass --dir to target it instead", alt)
}
if !hadManifest && paths.IsInstall(in.root.Dir) {
in.infof("No %s found — adopting this install; files not written by the CLI are treated as potentially customized", state.FileName)
}
for _, dir := range []string{
filepath.Join(in.root.Dir, "deployment"),
filepath.Join(in.root.Dir, "data", "nginx", "local"),
} {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create %s: %w", dir, err)
}
}
gitkeep := filepath.Join(in.root.Dir, "data", "nginx", "local", ".gitkeep")
if err := os.WriteFile(gitkeep, nil, 0644); err != nil {
return fmt.Errorf("failed to create %s: %w", gitkeep, err)
}
initialTag := chosenTag
if initialTag == "" {
if updateTag != "" {
initialTag = updateTag
} else {
initialTag = getLatestTag()
}
}
initialRef := ""
if !in.localFiles() {
initialRef = release.ConfigRef(initialTag)
}
fetcher := &fileFetcher{in: in}
if err := in.materializeFiles(ctx, initialRef, managedFiles(in.prod, in.lite, in.craft, in.dev), manifest, fetcher); err != nil {
return err
}
if !in.lite {
if err := in.removeOverlayIfPresent(deployfiles.LiteOverlay, manifest, "switching to standard mode"); err != nil {
return err
}
}
if !in.craft {
if err := in.removeOverlayIfPresent(deployfiles.CraftOverlay, manifest, "Craft disabled this run"); err != nil {
return err
}
}
if !in.dev {
if err := in.removeOverlayIfPresent(deployfiles.DevOverlay, manifest, "dev ports not requested this run"); err != nil {
return err
}
}
if in.dev {
in.warnf("Dev overlay: Postgres, Redis, OpenSearch, MinIO, the model server and the API are published on this host, not just nginx. Only run it on a machine you trust the network of.")
}
// Captured for rollback: a failed pull must not leave .env pointing at a
// version that never ran (nil means there was no .env before this run).
prevEnv, prevErr := os.ReadFile(envPath)
if prevErr != nil {
prevEnv = nil
}
var effectiveTag string
var hostPort int
if rerun {
effectiveTag, hostPort, err = in.reconfigureExistingEnv(envPath, updateTag)
} else {
effectiveTag, hostPort, err = in.createFreshEnv(envPath, initialTag)
}
if err != nil {
return err
}
// Pinned tags want config files from their own ref so compose matches
// the images; re-materialize when the effective tag needs another ref.
configRef := release.ConfigRef(effectiveTag)
if !in.localFiles() && configRef != initialRef {
in.infof("Fetching config files matching %s...", configRef)
if err := in.materializeFiles(ctx, configRef, managedFiles(in.prod, in.lite, in.craft, in.dev), manifest, fetcher); err != nil {
// .env already names this version and nothing has been deployed
// yet, so it is put back for the same reason a failed pull does:
// a version that never ran must not be left recorded as the
// deployment's current one.
in.rollbackEnv(envPath, prevEnv)
return err
}
}
if in.craft {
in.ensureCraftResources(ctx)
}
in.successf("Configuration ready at %s (version %s)", in.root.Dir, effectiveTag)
// Persist the manifest's file records now: if the pull fails, the next
// run must still know the freshly materialized files were CLI-written,
// not hand-edited. InstalledTag advances only after a successful start.
if err := manifest.Save(in.root.Dir); err != nil {
in.rollbackEnv(envPath, prevEnv)
return err
}
// ---- Phases 2 + 3: pull and start ----
if err := in.pullImages(ctx, effectiveTag, hostPort); err != nil {
in.rollbackEnv(envPath, prevEnv)
return err
}
if err := in.startServices(ctx, effectiveTag, Var(string(prevEnv), "IMAGE_TAG"), hostPort); err != nil {
return err
}
now := time.Now().UTC()
manifest.InstalledTag = effectiveTag
manifest.CLIVersion = in.deps.CLIVersion
manifest.Mode = state.ModeStandard
switch {
case in.lite:
manifest.Mode = state.ModeLite
case in.prod:
manifest.Mode = state.ModeProd
}
in.recordProject(manifest)
manifest.IncludeCraft = in.craft
manifest.Dev = in.dev
if !hadManifest || manifest.InstalledAt.IsZero() {
manifest.InstalledAt = now
}
manifest.UpdatedAt = now
if err := manifest.Save(in.root.Dir); err != nil {
// The deployment is already up on this version: say what did happen,
// or the error reads as an install that never took place.
return fmt.Errorf("deployed %s, but recording it in %s failed: %w", effectiveTag, state.FileName, err)
}
in.printSuccess(ctx, hostPort)
return nil
}
// validateTag normalizes a requested version and, when it can, confirms it
// exists BEFORE anything is written, so a typo fails here instead of
// surfacing minutes later as a pull error with the bad version already in
// .env. Only release versions are looked up: floating and hand-built image
// tags are pullable without a matching git ref. Verification is best-effort
// by design — an unreachable (or lying) GitHub must never be able to block
// an install that would otherwise work.
func (in *installer) validateTag(ctx context.Context, tag string) (string, error) {
tag = strings.TrimSpace(tag)
if tag == "" {
return "", exitcodes.New(exitcodes.BadRequest, "no version given")
}
normalized, checkable := release.NormalizeVersionTag(tag)
if normalized != tag {
in.infof("Using %s (Onyx versions are v-prefixed)", normalized)
}
// A dry run writes nothing and pulls nothing, so it stays offline.
if !checkable || in.localFiles() || in.opts.DryRun {
return normalized, nil
}
exists, err := in.deps.Release.RefExists(ctx, normalized)
if err != nil {
in.warnf("Could not verify that version %s exists (%v) — continuing", normalized, err)
return normalized, nil
}
if exists {
return normalized, nil
}
// A 404 is only worth acting on if the same endpoint answers for a ref
// that certainly exists: proxies and captive portals 404 everything, and
// rejecting a real version would leave no way to install at all.
if ok, cerr := in.deps.Release.RefExists(ctx, "main"); cerr != nil || !ok {
in.warnf("Could not reach GitHub to verify version %s — continuing", normalized)
return normalized, nil
}
return "", exitcodes.Newf(exitcodes.BadRequest,
"version %s not found — Onyx releases look like v4.4.6 (https://github.com/onyx-dot-app/onyx/releases)", normalized)
}
// appImage is the one image every deployment runs whatever its mode, so the
// tags present for it are the versions this host could deploy right now.
const appImage = "onyxdotapp/onyx-backend"
// errOffline stands in for the release lookup --offline never makes, so the
// one place that turns a failed lookup into an offered version handles both.
var errOffline = errors.New("offline")
// latestAppTag is the release lookup, skipped outright when --offline says
// the network is not there to be asked.
func (in *installer) latestAppTag(ctx context.Context) (string, error) {
if in.opts.Offline {
return "", errOffline
}
return in.deps.Release.LatestAppTag(ctx)
}
// unreachableTagFallback picks the version to offer when the latest release
// could not be looked up. A host that already has released images can still
// install from them, and offering one is what makes that possible: edge is a
// floating tag, so choosing it commits the run to another trip to the
// registry — the one thing an absent network guarantees will fail.
func (in *installer) unreachableTagFallback(ctx context.Context) string {
why := "Could not reach GitHub"
if in.opts.Offline {
why = "Offline"
}
if local := in.localAppTag(ctx); local != "" {
in.warnf("%s — offering %s, whose images are already on this host", why, local)
return local
}
if in.opts.Offline {
// edge may well be here too; it just isn't a version, so it can't be
// compared with anything or verified as the one that was meant.
in.warnf("%s — no released Onyx images on this host, offering edge", why)
} else {
in.warnf("Could not determine latest Onyx release — falling back to main / edge")
}
return "edge"
}
// localAppTag returns the newest released version already pulled on this
// host, or "" when there is none. Only released versions count: a floating
// tag on disk is a copy of whatever main was that day, and deploying it pulls
// again regardless.
func (in *installer) localAppTag(ctx context.Context) string {
if !dockercmd.Installed() {
return ""
}
in.docker.RefreshSudo(ctx)
res, err := in.deps.Runner.Run(ctx, in.docker.Command(nil, "images", "--format", "{{.Tag}}", appImage))
if err != nil {
return ""
}
best, bestVer := "", version.Semver{}
for _, line := range strings.Split(strings.TrimSpace(res.Stdout), "\n") {
tag := strings.TrimSpace(line)
if !release.IsImmutableTag(tag) {
continue
}
ver, ok := version.Parse(tag)
if !ok {
continue
}
if best == "" || bestVer.LessThan(ver) {
best, bestVer = tag, ver
}
}
return best
}
// askVersion asks for a deployment version and validates the answer;
// interactive runs re-ask, seeded with the rejected text so a typo can be
// corrected rather than retyped.
func (in *installer) askVersion(ctx context.Context, title, def string) (string, error) {
for {
tag, err := in.askString(title, def)
if err != nil {
return "", err
}
resolved, verr := in.validateTag(ctx, tag)
if verr == nil {
return resolved, nil
}
if in.prompt.AssumeDefaults {
return "", verr
}
in.errorf("%v", verr)
def = tag
}
}
// askModeQuestion is the deployment-mode select. Lite stays the default for
// new installs, matching install.sh's interactive and --no-prompt behavior.
// Craft is deliberately not offered here: it is reachable with
// --include-craft, and installs it the same way, but it binds the docker
// socket and is not yet something to put in front of someone who has not
// gone looking for it.
func (in *installer) askModeQuestion() error {
if in.opts.Lite {
in.infof("Deployment mode: Lite (set via --lite flag)")
return nil
}
if in.opts.IncludeCraft {
return nil // craft implies standard; the conflict was rejected earlier
}
choice, err := in.selectOne("Deployment mode",
[]ui.Option{
{Label: "Lite", Hint: "chat, tools, uploads, projects — no vector search (recommended)"},
{Label: "Standard", Hint: "full search, connectors, and RAG"},
}, 0)
if err != nil {
return err
}
in.lite = choice == 0
if in.wiz != nil {
in.wiz.Answer("Mode", []string{"Lite", "Standard"}[choice])
}
return nil
}
// gatherPreflight collects environment facts without side effects or output.
func (in *installer) gatherPreflight(ctx context.Context) preflight {
p := preflight{diskGB: resources.DiskAvailableGB(".")}
if !dockercmd.Installed() {
return p
}
p.dockerVersion = in.docker.Version(ctx)
if c := dockercmd.DetectCompose(ctx, in.docker); c != nil {
p.composeVersion = c.Version(ctx)
p.composeType = c.TypeName()
}
dockerInfo := ""
if res, err := in.deps.Runner.Run(ctx, dockercmd.Command{Name: "docker", Args: []string{"system", "info"}}); err == nil {
dockerInfo = res.Stdout
}
p.memoryMB = resources.MemoryMB(dockerInfo)
p.rootless = strings.Contains(strings.ToLower(dockerInfo), "rootless")
return p
}
// resolveDockerProblems provisions or errors for anything missing, then
// prints one compact summary line. Mirrors install.sh's behaviors: Docker
// Engine and compose plugin auto-install on Linux/WSL, Docker Desktop
// start-and-wait on macOS, instructions elsewhere.
func (in *installer) resolveDockerProblems(ctx context.Context, pre preflight) error {
linuxLike := runtime.GOOS == "linux" || dockercmd.IsWSL()
if !dockercmd.Installed() {
switch {
case linuxLike:
in.infof("Docker is required but not installed.")
ok, err := in.confirmYN("Install Docker Engine?", true)
if err != nil {
return err
}
if !ok {
return exitcodes.New(exitcodes.General, "Docker is required to run Onyx")
}
if err := in.suspend(func() error {
return dockercmd.InstallDockerLinux(ctx, in.deps.Runner, in.deps.IOS.Out)
}); err != nil {
return exitcodes.Newf(exitcodes.General, "Docker installation failed: %v\n Visit: https://docs.docker.com/get-docker/", err)
}
if !dockercmd.Installed() {
return exitcodes.New(exitcodes.General, "Docker installation failed.\n Visit: https://docs.docker.com/get-docker/")
}
in.successf("Docker installed successfully")
case runtime.GOOS == "windows":
in.plainf("%s", dockercmd.DockerDesktopInstructionsWindows)
return exitcodes.New(exitcodes.General, "Docker Desktop is required to run Onyx")
default:
return exitcodes.New(exitcodes.General,
"Docker is not installed. Please install Docker Desktop first.\n Visit: https://docs.docker.com/get-docker/")
}
}
in.compose = dockercmd.DetectCompose(ctx, in.docker)
if in.compose != nil {
in.compose.Project = in.projectName()
}
if in.compose == nil && linuxLike {
in.infof("Docker Compose is required but not installed.")
ok, err := in.confirmYN("Install the Docker Compose plugin?", true)
if err != nil {
return err
}
if !ok {
return exitcodes.New(exitcodes.General, "Docker Compose is required to run Onyx")
}
if err := in.suspend(func() error {
return dockercmd.InstallComposePluginLinux(ctx, in.deps.Runner, in.deps.IOS.Out)
}); err != nil {
return exitcodes.Newf(exitcodes.General, "Failed to install the Docker Compose plugin: %v\n Visit: https://docs.docker.com/compose/install/", err)
}
in.compose = dockercmd.DetectCompose(ctx, in.docker)
if in.compose == nil {
return exitcodes.New(exitcodes.General, "Docker Compose plugin installed but not detected.\n Visit: https://docs.docker.com/compose/install/")
}
in.compose.Project = in.projectName()
in.successf("Docker Compose plugin installed")
}
if in.compose == nil {
return exitcodes.New(exitcodes.General,
"Docker Compose is not installed. Please install Docker Compose first.\n Visit: https://docs.docker.com/compose/install/")
}
in.docker.RefreshSudo(ctx)
if in.docker.UsingSudo() {
if err := in.suspend(func() error {
return dockercmd.EnsureDockerGroup(ctx, in.deps.Runner, in.deps.IOS.Out)
}); err != nil {
in.warnf("Could not add you to the docker group: %v", err)
}
in.infof("Using sudo for docker commands in this run.")
}
if !in.docker.DaemonRunning(ctx) {
if runtime.GOOS == "darwin" {
in.infof("Docker daemon is not running.")
if err := in.runTask("Starting Docker Desktop", func(progress io.Writer) error {
return dockercmd.StartDockerDesktopDarwin(ctx, in.docker, progress, dockerDesktopWait)
}); err != nil {
return exitcodes.Newf(exitcodes.General, "%v", err)
}
} else {
return exitcodes.New(exitcodes.General, "Docker daemon is not running. Please start Docker.")
}
}
summary := []string{"Docker " + orDetect(pre.dockerVersion, in.docker.Version(ctx))}
if pre.composeVersion != "" {
summary = append(summary, fmt.Sprintf("Compose %s (%s)", pre.composeVersion, pre.composeType))
} else {
summary = append(summary, fmt.Sprintf("Compose %s (%s)", in.compose.Version(ctx), in.compose.TypeName()))
}
summary = append(summary, "daemon running")
if pre.rootless {
in.rootless = true
summary = append(summary, "rootless")
}
if pre.memoryMB > 0 {
summary = append(summary, resources.FormatMemory(pre.memoryMB)+" RAM")
}
if pre.diskGB >= 0 {
summary = append(summary, fmt.Sprintf("%dGB free", pre.diskGB))
}
in.successf("%s", strings.Join(summary, " · "))
return nil
}
func orDetect(v, fallback string) string {
if v != "" {
return v
}
return fallback
}
// resourceWarnings surfaces low-resource conditions only (healthy machines
// see nothing) and asks whether to continue, like install.sh's warning path.
func (in *installer) resourceWarnings(pre preflight) error {
ramWant, diskWant := expectedRAMGB, expectedDiskGB
mode := "standard"
if in.lite {
ramWant, diskWant = expectedRAMGBLite, expectedDiskGBLite
mode = "lite"
}
warning := false
if pre.memoryMB > 0 && pre.memoryMB < ramWant*1024 {
in.warnf("Less than %dGB RAM available (found: %s)", ramWant, resources.FormatMemory(pre.memoryMB))
warning = true
}
if pre.diskGB >= 0 && pre.diskGB < diskWant {
in.warnf("Less than %dGB disk space available (found: %dGB)", diskWant, pre.diskGB)
warning = true
}
if in.rootless && !in.lite {
in.warnf("Rootless Docker: the standard deployment's OpenSearch index requests unlimited memlock, which a rootless daemon usually can't grant.")
in.infof("Either raise the daemon's limits (systemctl --user edit docker: LimitMEMLOCK=infinity, LimitNOFILE=1048576, then systemctl --user restart docker) or use Lite mode, which has no OpenSearch.")
}
if !warning {
return nil
}
in.warnf("Onyx recommends at least %dGB RAM and %dGB disk space in %s mode.", ramWant, diskWant, mode)
cont, err := in.confirmYN("Continue anyway?", true)
if err != nil {
return err
}
if !cont {
return exitcodes.New(exitcodes.General, "Installation cancelled. Please allocate more resources and try again.")
}
return nil
}
func (in *installer) checkComposeVersion(ctx context.Context) error {
if in.compose == nil {
return nil
}
composeVersion := in.compose.Version(ctx)
if composeVersion == "dev" {
return nil
}
have, ok := version.Parse(composeVersion)
minimum, _ := version.Parse(minComposeVersion)
if !ok || !have.LessThan(minimum) {
return nil
}
in.warnf("Docker Compose %s is older than %s; docker-compose.yml uses the newer env_file format.", composeVersion, minComposeVersion)
in.infof("Upgrade Docker Compose: https://docs.docker.com/compose/install/")
cont, err := in.confirmYN("Continue anyway?", true)
if err != nil {
return err
}
if !cont {
return exitcodes.New(exitcodes.General, "Installation cancelled. Please upgrade Docker Compose and re-run.")
}
return nil
}
// createFreshEnv writes deployment/.env from env.template with the chosen
// tag, generated secrets, port, and mode adjustments.
func (in *installer) createFreshEnv(envPath, tag string) (string, int, error) {
template, err := os.ReadFile(filepath.Join(in.root.Dir, "deployment", "env.template"))
if err != nil {
return "", 0, fmt.Errorf("failed to read env.template: %w", err)
}
env := string(template)
env = SetVar(env, "IMAGE_TAG", tag)
// Recorded in .env so reruns and upgrades reuse the same port instead of
// re-scanning (which would collide with the deployment's own binding).
hostPort := resources.FindAvailablePort(3000)
if hostPort != 3000 {
in.infof("Port 3000 is in use — using %d", hostPort)
}
env = SetVar(env, "HOST_PORT", strconv.Itoa(hostPort))
if in.lite {
// MinIO never starts in lite mode and the overlay forces the
// postgres file store at runtime; align .env so it isn't misleading.
env = SetVar(env, "COMPOSE_PROFILES", withoutProfile(Var(env, "COMPOSE_PROFILES"), s3FilestoreProfile))
env = SetVar(env, "FILE_STORE_BACKEND", "postgres")
}
env = SetVar(env, "USER_AUTH_SECRET", `"`+randomHex(32)+`"`)
minioAccessKey, minioSecretKey := randomHex(16), randomHex(32)
env = SetVar(env, "MINIO_ROOT_USER", minioAccessKey)
env = SetVar(env, "MINIO_ROOT_PASSWORD", minioSecretKey)
env = SetVar(env, "S3_AWS_ACCESS_KEY_ID", minioAccessKey)
env = SetVar(env, "S3_AWS_SECRET_ACCESS_KEY", minioSecretKey)
if in.craft {
env = SetVarUncomment(env, "ENABLE_CRAFT", "true")
backend := sandboxBackendForTag(tag)
env = SetVarUncomment(env, "SANDBOX_BACKEND", backend)
in.successf("Onyx Craft enabled (ENABLE_CRAFT=true, SANDBOX_BACKEND=%s)", backend)
if backend == "docker" {
in.plainf("%s", craftSecurityWarning)
} else {
in.infof("Image tag %s predates the docker sandbox backend (v4.0.6+); using SANDBOX_BACKEND=%s.", tag, backend)
}
}
if err := os.WriteFile(envPath, []byte(env), 0600); err != nil {
return "", 0, fmt.Errorf("failed to write .env: %w", err)
}
in.successf(".env created (auth secret and MinIO credentials generated) — customize it any time")
return tag, hostPort, nil
}
// reconfigureExistingEnv applies the rerun decision: restart keeps .env
// untouched except craft/lite alignment; a non-empty updateTag rewrites
// IMAGE_TAG (and nothing else).
func (in *installer) reconfigureExistingEnv(envPath, updateTag string) (string, int, error) {
existing, err := os.ReadFile(envPath)
if err != nil {
return "", 0, fmt.Errorf("failed to read %s: %w", envPath, err)
}
env := string(existing)
// Keep the port the deployment already uses. Installs that predate
// HOST_PORT recording fall back to the port they were observed
// publishing, and only a deployment that was never up gets a fresh scan
// (services are stopped on this path, so the scan can't collide with our
// own binding — but it would silently move a running deployment).
// Prod ignores HOST_PORT entirely: docker-compose.prod.yml publishes
// 80/443 outright.
hostPort := 0
if !in.prod {
var perr error
hostPort, perr = strconv.Atoi(Var(env, "HOST_PORT"))
if perr != nil {
switch {
case in.observedPort != 0:
hostPort = in.observedPort
in.infof("Recording the port this deployment already uses (%d) in .env", hostPort)
default:
hostPort = resources.FindAvailablePort(3000)
if hostPort != 3000 {
in.infof("Port 3000 is in use — using %d", hostPort)
}
}
env = SetVar(env, "HOST_PORT", strconv.Itoa(hostPort))
}
}
if updateTag != "" && updateTag != Var(env, "IMAGE_TAG") {
env = SetVar(env, "IMAGE_TAG", updateTag)
in.successf("Updated IMAGE_TAG to %s (all other settings preserved)", updateTag)
} else {
in.infof("Restarting with the current configuration")
}
effectiveTag := Var(env, "IMAGE_TAG")
if effectiveTag == "" {
effectiveTag = "edge"
}
// Honor --include-craft on existing installs regardless of branch,
// aligning SANDBOX_BACKEND with the effective tag.
if in.craft {
env = SetVarUncomment(env, "ENABLE_CRAFT", "true")
backend := sandboxBackendForTag(effectiveTag)
env = SetVarUncomment(env, "SANDBOX_BACKEND", backend)
in.successf("Onyx Craft enabled (ENABLE_CRAFT=true, SANDBOX_BACKEND=%s, image tag: %s)", backend, effectiveTag)
}
// Lite mode on an existing .env: the template ships with s3-filestore
// enabled; drop that entry so MinIO doesn't start. Only that entry — any
// other profile in the list was turned on by the user, and clearing the
// whole value would switch their services off with no way to name them
// again on the way back.
if in.lite && hasProfile(Var(env, "COMPOSE_PROFILES"), s3FilestoreProfile) {
env = SetVar(env, "COMPOSE_PROFILES", withoutProfile(Var(env, "COMPOSE_PROFILES"), s3FilestoreProfile))
in.successf("Dropped %s from COMPOSE_PROFILES for lite mode", s3FilestoreProfile)
}
// Leaving lite behind: undo those same adjustments, or the "standard"
// deployment keeps lite's storage behaviour with MinIO never starting.
// The rest of the profile list, and a file store the user has since
// pointed somewhere else, are left as they are.
if !in.lite && in.wasLite {
env = SetVar(env, "COMPOSE_PROFILES", withProfile(Var(env, "COMPOSE_PROFILES"), s3FilestoreProfile))
if Var(env, "FILE_STORE_BACKEND") == "postgres" {
env = SetVar(env, "FILE_STORE_BACKEND", "s3")
in.warnf("Files stored while in lite mode live in Postgres; standard mode reads the MinIO store, so they won't be listed until you switch back.")
}
in.successf("Restored the standard file store (COMPOSE_PROFILES=%s, FILE_STORE_BACKEND=%s)",
Var(env, "COMPOSE_PROFILES"), Var(env, "FILE_STORE_BACKEND"))
}
if err := os.WriteFile(envPath, []byte(env), 0600); err != nil {
return "", 0, fmt.Errorf("failed to write .env: %w", err)
}
return effectiveTag, hostPort, nil
}
// guardRunningServices reports whether the deployment's services are up, and
// settles what that means for a scripted run: reconfiguring a live deployment
// needs --force, and the refusal carries the remedy (install.sh printed
// "./install.sh --shutdown", which doesn't exist after curl|bash). Interactive
// runs are asked instead, by the rerun question — that is where Cancel lives.
//
// Nothing is stopped either way: running services are handed to `up` as
// --force-recreate, so the current version keeps serving while the images
// download and each container is replaced only once its replacement is ready.
func (in *installer) guardRunningServices(ctx context.Context) (bool, error) {
files := in.composeFileNames(true)
cmd := in.compose.Command(in.deploymentDir(), stopFallbackEnv(), files, "ps", "-q")
res, err := in.deps.Runner.Run(ctx, cmd)
if err != nil || strings.TrimSpace(res.Stdout) == "" {
return false, nil
}
if in.prompt.AssumeDefaults && !in.opts.Force {
return true, exitcodes.New(exitcodes.General,
"Onyx services are running — pass --force to recreate them with the new configuration, or stop them first with `onyx-cli deploy stop`")
}
in.forceRecreate = true
return true, nil
}
func (in *installer) ensureCraftResources(ctx context.Context) {
network := sandboxNetworkName()
if created, err := in.docker.EnsureNetwork(ctx, network); err != nil {
in.warnf("Could not create sandbox network %s — create it manually:", network)
in.cmdf("docker network create %s", network)
} else if created {
in.successf("Created sandbox bridge network: %s", network)
}
if created, err := in.docker.EnsureVolume(ctx, sandboxProxyCAVolume); err != nil {
in.warnf("Could not create sandbox proxy CA volume %s — create it manually:", sandboxProxyCAVolume)
in.cmdf("docker volume create %s", sandboxProxyCAVolume)
} else if created {
in.successf("Created sandbox proxy CA volume: %s", sandboxProxyCAVolume)
}
}
func (in *installer) composeRunEnv(tag string, hostPort int) map[string]string {
env := map[string]string{"IMAGE_TAG": tag}
// Prod has no HOST_PORT: docker-compose.prod.yml publishes 80/443
// outright, so passing one would only suggest it does something.
if !in.prod {
env["HOST_PORT"] = fmt.Sprintf("%d", hostPort)
}
return env
}
func (in *installer) pullImages(ctx context.Context, tag string, hostPort int) error {
if in.opts.Offline {
return in.checkLocalImages(ctx, tag, hostPort)
}
phase := composePhase{
stage: ui.StagePull,
title: "Pulling images",
dir: in.deploymentDir(),
env: in.composeRunEnv(tag, hostPort),
files: in.composeFileNames(false),
args: []string{"pull"},
}
if release.IsImmutableTag(tag) && in.composeAtLeast(ctx, minPullPolicyVersion) {
// A released tag names an image that never changes, so a copy already
// on the host is the copy this deployment wants. Without this compose
// asks the registry about every service on every run, which is most of
// the wait on a re-run that has nothing to fetch.
phase.args = append(phase.args, "--policy", "missing")
}
mode := in.progressMode(ctx)
switch {
case mode != "":
// The pull drives the wizard's own checklist, so the full-screen view
// survives the phase instead of being released to compose's renderer.
phase.args = append([]string{"--progress", mode}, phase.args...)
phase.onLine = newPullProgress(in.wiz.Services, in.wiz.TaskExtra).line
defer in.wiz.Services(nil)
case !in.opts.Verbose:
phase.args = append(phase.args, "--quiet")
}
if err := in.runComposePhase(ctx, phase); err != nil {
if ctx.Err() != nil {
return err
}
in.infof("Check your internet connection and re-run. If the issue persists: founders@onyx.app")
return exitcodes.Newf(exitcodes.General, "docker compose pull failed: %v", err)
}
return nil
}
// checkLocalImages replaces the pull when --offline forbids one. It asks
// compose what the deployment resolves to and names every image this host
// doesn't have, because `up` would otherwise stop at the first one it misses
// and report it as a registry error rather than a missing copy. Note that not
// every image follows IMAGE_TAG: code-interpreter carries its own version
// line, so it is pinned separately and is the one most often absent.
func (in *installer) checkLocalImages(ctx context.Context, tag string, hostPort int) error {
in.phase("Checking images")
cmd := in.compose.Command(in.deploymentDir(), in.composeRunEnv(tag, hostPort),
in.composeFileNames(false), "config", "--images")
res, err := in.deps.Runner.Run(ctx, cmd)
if err != nil {
// No list to check against. `up --pull never` still refuses to reach a
// registry, so let it be the one to object.
return nil
}
var missing []string
for _, line := range strings.Split(strings.TrimSpace(res.Stdout), "\n") {
image := strings.TrimSpace(line)
if image == "" {
continue
}
if _, ierr := in.deps.Runner.Run(ctx, in.docker.Command(nil, "image", "inspect", image)); ierr != nil {
missing = append(missing, image)
}
}
if len(missing) == 0 {
in.successf("Every image this deployment needs is already on this host")
return nil
}
for _, image := range missing {
in.errorf("Not on this host: %s", image)
}
return exitcodes.Newf(exitcodes.NotAvailable,
"%d image(s) missing — load them with `docker load`, or re-run without --offline to fetch them",
len(missing))
}
// startServices brings the stack up. prevTag is the version .env carried
// before this run ("" on a fresh install), used only to explain what a failed
// start left behind.
func (in *installer) startServices(ctx context.Context, tag, prevTag string, hostPort int) error {
env := in.composeRunEnv(tag, hostPort)
files := in.composeFileNames(false)
dir := in.deploymentDir()
upArgs := []string{"up", "-d"}
recreate := true
switch {
case in.opts.Offline:
// Compose consults the registry for any service whose own policy says
// to, whatever the tag; --pull never is what makes --offline a promise
// rather than a preference. No image can have moved under a name while
// the host was offline, so nothing needs recreating for its own sake.
upArgs = append(upArgs, "--pull", "never")
recreate = in.forceRecreate
if recreate {
upArgs = append(upArgs, "--force-recreate")
}
case release.IsFloatingTag(tag):
// A floating tag names a moving image: the digest changes under the
// same name, so neither the pull nor the recreate can be skipped.
upArgs = append(upArgs, "--pull", "always", "--force-recreate")
case in.forceRecreate:
// Services were up when the run started and the operator signed off
// on replacing them; compose would otherwise leave containers whose
// config it considers unchanged running the old image.
upArgs = append(upArgs, "--force-recreate")
default:
// Compose decides per container, so a container still on the one it
// started on may be one it means to leave alone.
recreate = false
}
wait := !in.opts.NoWait
if wait {
upArgs = append(upArgs, "--wait", "--wait-timeout", fmt.Sprintf("%d", waitTimeoutSeconds))
}
phase := composePhase{
stage: ui.StageStart,
title: "Starting services",
dir: dir,
env: env,
files: files,
args: upArgs,
}
if wait && in.wiz != nil && !in.opts.Verbose {
// Watch container states so the wizard shows something while
// `up --wait` blocks (it is silent for as long as that takes).
phase.watch = &healthWatch{before: in.runningContainers(ctx), recreate: recreate, project: in.projectName()}
}
if mode := in.progressMode(ctx); mode != "" {
// Compose's own event stream names every transition, which is more than
// a container list can show. The watch stays on behind it though: a
// compose that prints no events (a format we can't read, output
// swallowed by a wrapper) would otherwise leave the longest phase of
// the run with nothing on screen at all.
phase.args = append([]string{"--progress", mode}, upArgs...)
start := newStartProgress(in.wiz.Services, in.wiz.TaskExtra, wait, in.projectName())
phase.onLine = start.line
if phase.watch != nil {
phase.watch.quiet = func() bool { return !start.reporting() }
}
defer in.wiz.Services(nil)
}
if err := in.runComposePhase(ctx, phase); err != nil {
if ctx.Err() != nil {
// Cancelled, not broken. Quitting stops compose, not the containers
// it had already brought up, which is worth saying before the run
// reports itself cancelled.
in.infof("Services that already started are still running:")
in.cmdf("onyx-cli deploy status%s", in.dirArg())
in.cmdf("onyx-cli deploy stop%s", in.dirArg())
return err
}
in.infof("Current container status:")
ps := in.compose.Command(dir, env, files, "ps")
ps.Stdout, ps.Stderr = in.deps.IOS.Out, in.deps.IOS.ErrOut
_, _ = in.deps.Runner.Run(ctx, ps)
in.infof("Check the logs of any unhealthy service:")
in.cmdf("onyx-cli deploy status%s", in.dirArg())
in.cmdf("onyx-cli deploy logs%s <service>", in.dirArg())
in.explainIncompleteStart(tag, prevTag)
in.infof("If the issue persists, please contact: founders@onyx.app")
return exitcodes.Newf(exitcodes.General, "docker compose up failed: %v", err)
}
return nil
}
// explainIncompleteStart says what a half-finished `up` left behind. Unlike a
// failed pull, .env is deliberately NOT reverted here: containers that did
// come up are running the new images, so putting the old version back would
// deploy older code over data the new one may already have migrated, and on a
// fresh install .env holds the secrets the volumes were just initialized with.
// The manifest still records the previously deployed version, so `deploy
// status` reports the split rather than claiming the move succeeded.
func (in *installer) explainIncompleteStart(tag, prevTag string) {
switch {
case prevTag == "":
in.warnf("Keep %s: it holds the secrets any volumes created just now were initialized with.",
filepath.Join("deployment", ".env"))
in.infof("Fix the problem above and re-run %s, or start clean with %s.",
in.paint.Accent("onyx-cli deploy install"), in.paint.Accent("onyx-cli deploy uninstall"))
case prevTag != tag:
in.warnf("Partially deployed: services that started are on %s, the rest are still on %s.", tag, prevTag)
in.infof("`.env` stays on %s so a re-run finishes the move; to go back, run %s.",
tag, in.paint.Accent(fmt.Sprintf("onyx-cli deploy upgrade --tag %s", prevTag)))
}
}
// rollbackEnv restores .env to its pre-run content after a failed pull, so a
// version that never ran isn't left recorded as the deployment's current one.
// Only .env is reverted: the managed config files have already been refreshed
// to the target ref, and re-running completes the move.
func (in *installer) rollbackEnv(envPath string, prev []byte) {
if prev == nil {
if err := os.Remove(envPath); err != nil {
in.warnf("Could not remove the incomplete .env: %v", err)
return
}
in.infof("Removed the incomplete .env — nothing was deployed; re-run to start over")
return
}
if err := os.WriteFile(envPath, prev, 0600); err != nil {
in.warnf("Could not restore .env: %v", err)
return
}
in.infof("Restored .env to the previously deployed version — re-run to retry")
}
// runningHostPort reports the host port the deployment currently publishes,
// or 0 when nothing is running. It is the only record of the port for
// installs created by install.sh, which never wrote HOST_PORT to .env.
func (in *installer) runningHostPort(ctx context.Context) int {
if !dockercmd.Installed() {
return 0
}
res, err := in.deps.Runner.Run(ctx, in.psCommand("{{.Ports}}"))
if err != nil {
return 0
}
for _, line := range strings.Split(strings.TrimSpace(res.Stdout), "\n") {
if port, perr := strconv.Atoi(publishedHostPort(line)); perr == nil {
return port
}
}
return 0
}
// composePhase describes one long compose invocation shown as a single phase.
type composePhase struct {
stage int
title string
dir string
env map[string]string
files []string
args []string
// watch reads container states off `docker ps` while the command runs;
// nil for phases with no containers to watch.
watch *healthWatch
// onLine receives each output line while the wizard drives the run, for
// phases whose progress can be read off the command's own output.
onLine func(string)
}
// runComposePhase runs one long compose command as a visible phase: a rail
// stage with a live task (and per-service checklist) in the wizard, streamed
// output otherwise. On failure only the log tail is shown.
func (in *installer) runComposePhase(ctx context.Context, p composePhase) error {
cmd := in.compose.Command(p.dir, p.env, p.files, p.args...)
if in.wiz == nil || in.opts.Verbose {
in.phase(p.title)
var seen bytes.Buffer
cmd.Stdout = io.MultiWriter(in.deps.IOS.Out, &seen)
cmd.Stderr = io.MultiWriter(in.deps.IOS.ErrOut, &seen)
_, err := in.deps.Runner.Run(ctx, cmd)
switch {
case err == nil:
in.successf("%s complete", p.title)
case ctx.Err() == nil:
in.errorf("%s failed", p.title)
in.printFailureDiagnosis(seen.String())
}
return err
}
in.wiz.Stage(p.stage)
in.wiz.TaskStart(p.title)
var captured bytes.Buffer
// One writer for both streams: os/exec then serializes the copies, which
// is what lets onLine run without a lock.
var sink io.Writer = &captured
if p.onLine != nil {
sink = io.MultiWriter(&captured, &lineWriter{emit: p.onLine})
}
cmd.Stdout, cmd.Stderr = sink, sink
watchCtx, stopWatch := context.WithCancel(ctx)
watching := make(chan struct{})
if p.watch == nil {
close(watching)
} else {
go in.pollServiceHealth(watchCtx, in.wiz, *p.watch, watching)
}
_, err := in.deps.Runner.Run(ctx, cmd)
// The watch has to be off the screen before the phase ends: a repaint
// landing after TaskDone would redraw a finished phase as running, and
// after a failed one it would race the teardown below.
stopWatch()
<-watching
in.wiz.TaskDone(err == nil)
if err != nil {
// Tear the wizard down so the tail prints as plain scrollback.
in.wiz.Abort()
in.wiz = nil
if ctx.Err() != nil {
// Quitting killed the command mid-sentence. What it had written by
// then describes nothing that went wrong, so it is not shown.
return err
}
for _, l := range failureTail(captured.String(), failureLogTail) {
in.plainf(" %s", l)
}
in.printFailureDiagnosis(captured.String())
}
return err
}
// printFailureDiagnosis translates known failure signatures in compose
// output into targeted remedies instead of leaving raw runtime errors.
func (in *installer) printFailureDiagnosis(output string) {
lower := strings.ToLower(output)
if strings.Contains(lower, "setting rlimit") || strings.Contains(lower, "memlock") {
in.plainf("")
in.warnf("This looks like a ulimit failure: the OpenSearch index container requests unlimited memlock, which rootless Docker (and some hosts) can't grant.")
in.infof("Fix one of these ways, then re-run:")
in.plainf(" • Raise the daemon limits: %s → [Service] LimitMEMLOCK=infinity, LimitNOFILE=1048576 → %s",
in.paint.Accent("systemctl --user edit docker"), in.paint.Accent("systemctl --user restart docker"))
in.plainf(" (OpenSearch may also need: %s)", in.paint.Accent("sudo sysctl -w vm.max_map_count=262144"))
in.plainf(" • Or deploy Lite mode (no OpenSearch): %s", in.paint.Accent("onyx-cli deploy install --lite"))
return
}
if strings.Contains(lower, "address already in use") || strings.Contains(lower, "port is already allocated") {
in.plainf("")
in.warnf("A required port is taken by another process. Stop it, or set HOST_PORT before re-running.")
}
}
// pollServiceHealth feeds the wizard a live per-service checklist while
// `up --wait` blocks (otherwise silent for up to ten minutes). It runs until
// ctx is cancelled and closes done on the way out, so the phase can be sure
// nothing else paints before it reports its own result.
func (in *installer) pollServiceHealth(ctx context.Context, wiz *ui.Wizard, w healthWatch, done chan struct{}) {
defer close(done)
tick := time.NewTicker(healthPollInterval)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
}
if w.quiet != nil && !w.quiet() {
continue
}
res, err := in.deps.Runner.Run(ctx, in.psCommand("{{.Names}}\t{{.ID}}\t{{.Status}}"))
if err != nil {
continue
}
rows, ready := watchRows(res.Stdout, w)
if len(rows) > 0 {
wiz.Services(rows)
wiz.TaskExtra(fmt.Sprintf("%d/%d ready", ready, len(rows)))
}
}
}
// psCommand lists the deployment's running containers in the given format.
func (in *installer) psCommand(format string) dockercmd.Command {
return in.docker.Command(nil, "ps",
"--filter", "label=com.docker.compose.project="+in.projectName(),
"--format", format)
}
// runningContainers maps each running service to the container serving it, so
// a phase that replaces containers can tell which ones it has got to.
func (in *installer) runningContainers(ctx context.Context) map[string]string {
if !dockercmd.Installed() {
return nil
}
res, err := in.deps.Runner.Run(ctx, in.psCommand("{{.Names}}\t{{.ID}}"))
if err != nil {
return nil
}
before := map[string]string{}
for _, line := range strings.Split(strings.TrimSpace(res.Stdout), "\n") {
if name, id, ok := strings.Cut(line, "\t"); ok {
before[shortContainerName(name, in.projectName())] = id
}
}
return before
}
func (in *installer) printSuccess(ctx context.Context, hostPort int) {
url := fmt.Sprintf("http://localhost:%d", hostPort)
if in.prod {
url = in.prodAccessURL()
}
headline := "Onyx is ready"
if url != "" {
headline += " → " + ui.Accent(url)
}
if in.opts.NoWait {
headline = "Onyx containers started (still initializing — check: " + ui.Accent("onyx-cli deploy status") + ")"
}
lines := []string{headline}
if !in.prod {
// Prod deployments have their auth configured already; the signup
// pointer belongs to first-run installs.
lines = append(lines, "",
"First signup becomes the admin account: "+url+"/auth/signup")
}
if in.lite {
lines = append(lines, "",
"Lite mode: no OpenSearch/Redis/model servers or background workers.",
"Connectors and RAG search are off; chat, tools, uploads, projects work.")
}
if in.dev {
lines = append(lines, "",
"Dev overlay: the API (8080) and the data services (Postgres, Redis,",
"OpenSearch, MinIO, model server) are published on this host.")
}
lines = append(lines, "")
lines = append(lines, manageLines()...)
if in.wiz != nil {
// The install is over by the time the star question is on screen, so
// the rail says so rather than leaving a stage mid-flight behind it.
in.wiz.Stage(ui.StageComplete)
star := in.askStarQuestion()
in.wiz.Finish(append([]string{"🎉 " + lines[0]}, lines[1:]...)...)
in.wiz = nil
if star {
in.starRepo(ctx)
}
return
}
in.plainf("")
for _, l := range lines {
in.plainf("%s", l)
}
in.plainf("")
in.infof("For help or issues, contact: founders@onyx.app")
in.starPrompt(ctx)
}
// prodAccessURL reads the deployment's public URL off .env.nginx, or "" when
// no DOMAIN is recorded there. Prod publishes 80/443 under a real domain, so
// "http://localhost:<port>" would name a URL that serves nothing.
func (in *installer) prodAccessURL() string {
data, err := os.ReadFile(filepath.Join(in.deploymentDir(), ".env.nginx"))
if err != nil {
return ""
}
if domain := Var(string(data), "DOMAIN"); domain != "" {
return "https://" + domain
}
return ""
}
// recordProject stores the compose project in the manifest when it differs
// from the default; the default stays implicit so existing manifests don't
// all gain a field that repeats a constant.
func (in *installer) recordProject(manifest *state.Manifest) {
if in.project != dockercmd.DefaultProjectName {
manifest.Project = in.project
} else {
manifest.Project = ""
}
}
// manageLines closes a summary card with the verbs that come after the
// install. The installer is typically run once and then forgotten, so the
// commands that keep the deployment going get a spelled-out block rather than
// a single line of "status · stop · upgrade" — the point is that the reader
// recognizes them months later, when something needs doing.
func manageLines() []string {
cmds := []struct{ cmd, what string }{
{"onyx-cli deploy status", "health, version, and URL"},
{"onyx-cli deploy upgrade", "move to a newer version"},
{"onyx-cli deploy stop", "shut the services down"},
{"onyx-cli deploy uninstall", "remove it and its data"},
}
width := 0
for _, c := range cmds {
width = max(width, len(c.cmd))
}
lines := []string{"Manage this deployment any time with:"}
for _, c := range cmds {
lines = append(lines, " "+ui.Accent(fmt.Sprintf("%-*s", width, c.cmd))+" "+c.what)
}
return lines
}
// starPrompt ports install.sh's GitHub star prompt: only when interactive
// and the gh CLI is available.
func (in *installer) starPrompt(ctx context.Context) {
if in.askStarQuestion() {
in.starRepo(ctx)
}
}
// askStarQuestion asks while the UI is still live (the wizard closes on
// Finish, so callers ask first and run the API call after).
func (in *installer) askStarQuestion() bool {
if in.prompt.AssumeDefaults || in.opts.Offline {
return false
}
if _, err := exec.LookPath("gh"); err != nil {
return false
}
ok, err := in.confirmYN("Enjoying Onyx? ⭐ Star the repo on GitHub?", true)
return err == nil && ok
}
func (in *installer) starRepo(ctx context.Context) {
cmd := dockercmd.Command{
Name: "gh",
Args: []string{"api", "-X", "PUT", "/user/starred/onyx-dot-app/onyx"},
Env: map[string]string{"GH_PAGER": ""},
}
if _, err := in.deps.Runner.Run(ctx, cmd); err == nil {
in.successf("Thanks for the star!")
} else {
in.infof("Star us at: https://github.com/onyx-dot-app/onyx")
}
}
// composeFileNames returns the -f list. Prod deployments run the standalone
// docker-compose.prod.yml on its own; every other mode stacks overlays on
// docker-compose.yml. With autoDetect, previously downloaded files are
// picked up from disk so users don't have to repeat
// --lite/--prod/--include-craft/--dev for lifecycle commands (install.sh's
// build_compose_file_args true).
func (in *installer) composeFileNames(autoDetect bool) []string {
prodName := filepath.Base(deployfiles.ProdCompose.DestRel)
craftName := filepath.Base(deployfiles.CraftOverlay.DestRel)
if in.prod || (autoDetect && in.overlayOnDisk(prodName)) {
files := []string{prodName}
if in.craft || (autoDetect && in.overlayOnDisk(craftName)) {
files = append(files, craftName)
}
return files
}
files := []string{"docker-compose.yml"}
liteName := filepath.Base(deployfiles.LiteOverlay.DestRel)
if in.lite || (autoDetect && in.overlayOnDisk(liteName)) {
files = append(files, liteName)
}
if in.craft || (autoDetect && in.overlayOnDisk(craftName)) {
files = append(files, craftName)
}
// Last, so the ports and build targets it publishes are the ones that
// survive the merge — that is the whole point of the overlay.
devName := filepath.Base(deployfiles.DevOverlay.DestRel)
if in.dev || (autoDetect && in.overlayOnDisk(devName)) {
files = append(files, devName)
}
return files
}
func (in *installer) overlayOnDisk(name string) bool {
_, err := os.Stat(filepath.Join(in.deploymentDir(), name))
return err == nil
}
func (in *installer) deploymentDir() string {
return filepath.Join(in.root.Dir, "deployment")
}
// hasComposeFile reports whether the deployment dir carries a compose file
// the lifecycle verbs can drive: the base file, or the standalone prod file
// (prod roots managed by the CLI have no docker-compose.yml).
func (in *installer) hasComposeFile() bool {
return in.overlayOnDisk("docker-compose.yml") ||
in.overlayOnDisk(filepath.Base(deployfiles.ProdCompose.DestRel))
}
// stopFallbackEnv supplies safe defaults for compose invocations that may run
// before .env is known-good (install.sh uses the same pair for shutdown).
func stopFallbackEnv() map[string]string {
return map[string]string{"HOST_PORT": "3000", "IMAGE_TAG": "edge"}
}