Files
onyx/tools/ods/internal/devstack/inventory.go
1720ffc203 feat(ods): list dev stacks machine-wide, stop orphans by project, and release unused port leases (#16285)
## Description

Adds `ods stack ls` to list every Onyx dev stack on the machine from any
directory, and lets `stop`/`down --project NAME` act on a stack whose
worktree is gone. The PR also releases port leases when a stack stops
owning them.

**`ods stack ls [--json] [--all] [--orphaned]`**
(`internal/devstack/inventory.go`, `cmd/stack_ls.go`)
- It merges four sources, keyed by Compose project: `docker ps -a` +
`docker inspect` (labels, state, health), `projects/*/owner.json`, every
reachable `state.json` (owner state dirs, plus
`<git-common-dir>/{ods-stack,ods-stack-shared,ods-stack-frontend,worktrees/*/ods-stack,worktrees/*/ods-stack-frontend}`
from owner, lease, Docker and state roots), and `Registry.Leases()`. It
does not call `devstack.Open`, and it takes no locks.
- An Onyx Compose project is one with `relational_db` + `cache`
services, or one that a state or owner record claims.
- Statuses: `active` (an app process is alive, checked with
`groupIsOurs`, so a reused PID does not count), `app-stopped` (infra
runs, phase `stopped`), `infra-only` (infra runs, no app process: a
Compose-only project or dead processes), `stopped`, `orphaned`, and
`stale-lease` (`--all` only).
- Orphan rules: the Compose working dir is gone and no state claims the
project; a state's root is gone; or the root exists but is no longer a
git worktree (state rows, or Docker rows with an owner record whose
state is gone). A row counts as orphaned only when something is left to
clean up: processes, containers, leases or an owner record.
- A missing or corrupt state degrades its row (`errors`). When Docker is
down, `ls` prints a warning and shows rows from saved state only. Exit
codes: 0; 3 when `--orphaned` finds an orphan; 1 when no source can be
read.

**`down`/`stop --project NAME` and `prune`**
(`internal/devstack/project_down.go`)
- The command resolves the stack through `owner.json` → `state_dir`,
then any state that claims the project, then Docker labels. It runs from
any directory.
- It takes the state lock (only if the state dir still exists, so git
metadata is never recreated) and the project lock. It stops recorded
groups with `stopServices`/`stopRecords` and runs `docker compose -p
NAME down` from a neutral dir. Volumes are kept.
- After a successful down it releases the stack's leases and removes
`owner.json` for an orphan.
- It refuses a stack with a live service under an existing git worktree
unless you pass `--force`. If `--project` already matches the current
checkout's stack, the existing checkout path runs.
- `ods stack prune` runs this for every orphaned row after a
confirmation (`--yes` skips it). `--volumes` has its own confirmation,
and `--yes` does not skip it.

**Port lease fixes** (`ports.go`, `lifecycle.go`, `legacy.go`)
- `Stack.Stop` releases app leases (everything except `*_HOST_PORT`).
`Stack.Down` releases app leases after `stopServices`, and every lease
after Compose down. New `Registry.ReleaseNames`.
- Shared adoption (`releaseLegacyLeases`, runs after the legacy guard
and before `leasePorts`): each stopped legacy candidate without live
process groups loses all its leases, or only its app leases while its
containers run.
- Reclaim: `Lease.StateDir` is now recorded (`LeaseFor`). Another
owner's lease is reclaimable when its state is `stopped` or missing,
none of its recorded groups is alive, and, for `*_HOST_PORT` only, its
Compose project has no running container. That last check is lazy, so
app-port leasing stays fast. Leases without `StateDir` are reclaimable
only for shared/frontend keys, which are their own state dirs.
- Drift down: `choosePort` moves a lease to the lowest free port from
`Preferred` when the service is not running. I added
`PortRequest.Running` because `Fixed` alone is not set for a running
service that does not hold its port yet, and moving such a service
changed `web dev`'s full-stack owner
(`TestWebRuntimeUsesTheFullStackWebOwner` caught this).

Docs: `ods stack` help, `tools/ods/README.md`,
`tools/ods/integration/README.md`, and the onyx-dev-stack skill (a new
"Find and stop old stacks" step, and the updated port bullet).

Known limit: `git worktree remove` deletes
`<common>/worktrees/<name>/ods-stack`, so the recorded PIDs are lost.
`down --project` still removes the containers, leases and owner record,
but it cannot find app processes that outlived that removal.

**Discovery-first CLI.** Bare `ods stack` prints a short intro, then
groups its subcommands with the same `AddGroup` pattern as the root
command: Find stacks (`status`, `ls`, `logs`, `plan`), Run this
worktree's stack (`up`, `start`, `restart`, `stop`, `down`), and Clean
up (`prune`). Each `ls` row carries `next`, the exact command that stops
it, and the text output logs it with `log.Info`. Empty results and
refusals name the command to run.

Example on the VM (an isolated stack, then its worktree deleted with `rm
-rf`):
```
$ ods stack ls
PROJECT           STATUS       APP  INFRA  WEB  AGE  ROOT
demo-wt-fd863404  app-stopped  -    7/7    -    1m   ~/demo-wt
$ rm -rf ~/demo-wt && ods stack ls --orphaned   # exit 3
PROJECT           STATUS    APP  INFRA  WEB  AGE  ROOT
demo-wt-fd863404  orphaned  -    7/7    -    1m   ~/demo-wt (missing)
level=info msg="To stop demo-wt-fd863404 (orphaned), run: ods stack down --project demo-wt-fd863404"
```
After `stack stop`, only the `*_HOST_PORT` leases remained. After `down
--project`, no containers or leases were left.

## How Has This Been Tested?

- Unit tests for the inventory merge (fake containers, owners, states,
leases, liveness): live, app-stopped, infra-only, Compose-only, orphan
with a missing root, a removed worktree whose directory remains, a stale
lease, Docker unavailable, unreadable sources, and sort order.
- Real-process tests for `DownProject`/`StopProject`: an orphan in a
deleted linked worktree is stopped, its leases are released and its
owner record is removed; a live worktree is refused without `--force`.
- Lease tests: reclaim of a stopped owner, live process group skipped,
non-stopped owner skipped, `*_HOST_PORT` with and without running
containers, drift down vs. running, `ReleaseNames`, stop/down releases,
adoption with and without a live legacy group, and the frontend handoff
releasing preview leases.
- Coverage gates: `cmd` is at 90.6% against a 90.4% floor, and
`internal/docker` at 99.3% against 99.2%.
- `go test ./internal/... ./cmd/` passes, except `TestRunCI_failures`,
which also fails on a clean main in this environment. golangci-lint
passes.
- New integration test `TestStackLiveOrphanedWorktree` (`-tags
integration`) passed on the VM (246 s). It starts an isolated stack in a
disposable worktree, deletes the worktree, asserts that `ls --orphaned`
lists the stack with exit 3, then that `down --project` stops its
processes, removes its containers and releases its leases.

## Additional Options

- [ ] [Optional] Please cherry-pick this PR to the latest release
version.


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

* **New Features**
* Added `ods stack ls` to view active, stopped, orphaned, and
stale-lease stacks with service, health, port, age, and location
details.
* Added JSON output, filtering, orphan detection, and project-based
shutdown support.
* Added `ods stack prune` to safely clean up orphaned stacks, with
optional volume removal.
* **Bug Fixes**
  * Improved port lease reclamation and reassignment for stopped stacks.
* `stop` now releases application ports while preserving infrastructure
ports; `down` releases all ports.
* **Documentation**
  * Expanded lifecycle, recovery, listing, and cleanup guidance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: roshan <rohod04@gmail.com>
Co-authored-by: roshan <38771624+rohoswagger@users.noreply.github.com>
2026-10-20 00:22:08 +00:00

648 lines
19 KiB
Go

package devstack
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/onyx-dot-app/onyx/tools/ods/internal/docker"
)
// Inventory statuses, in the order `ods stack ls` sorts them.
const (
StatusOrphaned = "orphaned"
StatusActive = "active"
StatusAppStopped = "app-stopped"
StatusInfraOnly = "infra-only"
StatusStopped = "stopped"
StatusStaleLease = "stale-lease"
)
var statusOrder = map[string]int{StatusOrphaned: 0, StatusActive: 1, StatusAppStopped: 2, StatusInfraOnly: 3, StatusStopped: 4, StatusStaleLease: 5}
const (
sharedStateDirName = "ods-stack-shared"
isolatedStateDirName = "ods-stack"
frontendStateDirName = "ods-stack-frontend"
composeWorkingSuffix = "deployment/docker_compose"
ownerFileName = "owner.json"
projectsDirName = "projects"
)
// AppCount is the number of recorded application services that are alive.
type AppCount struct {
Running int `json:"running"`
Total int `json:"total"`
}
// InfraCount is the number of Compose containers of a project that run.
type InfraCount struct {
Running int `json:"running"`
Total int `json:"total"`
Health string `json:"health,omitempty"`
}
// StackRow is one dev stack as found on the machine.
type StackRow struct {
Project string `json:"project"`
Status string `json:"status"`
Root string `json:"root"`
// RootExists and Worktree describe Root now: a removed checkout has
// neither, a directory left behind by a removed worktree has no Worktree.
RootExists bool `json:"root_exists"`
Worktree bool `json:"worktree"`
Branch string `json:"branch,omitempty"`
Mode Mode `json:"mode,omitempty"`
Profile string `json:"profile,omitempty"`
Phase string `json:"phase,omitempty"`
App *AppCount `json:"app"`
Infra *InfraCount `json:"infra"`
Web string `json:"web,omitempty"`
// Since is when the oldest running container started, or else when the
// state was last written.
Since time.Time `json:"since,omitzero"`
StateDir string `json:"state_dir,omitempty"`
WorkingDir string `json:"compose_working_dir,omitempty"`
OwnerRecord string `json:"owner_record,omitempty"`
LeaseRoot string `json:"lease_root,omitempty"`
Leases map[string]int `json:"leases,omitempty"`
OrphanReason string `json:"orphan_reason,omitempty"`
// Next is the command that stops the stack, or "" when nothing runs.
Next string `json:"next,omitempty"`
Errors []string `json:"errors"`
state *State
containers int
}
// Running reports whether the row has a live application process or a
// running container.
func (r StackRow) Running() bool {
return (r.App != nil && r.App.Running > 0) || (r.Infra != nil && r.Infra.Running > 0)
}
// Inventory is every stack found on the machine.
type Inventory struct {
Rows []StackRow
Warnings []string
// Unreadable is set when neither Docker nor the port registry could be read.
Unreadable bool
}
type ownerRecord struct {
Path string
// Key is the owner directory name, ProjectName(project).
Key string
StateDir string
Err error
}
type stateRecord struct {
Dir string
State *State
Err error
ModTime time.Time
}
type checkoutInfo struct {
Worktree bool
Branch string
}
type inventoryInput struct {
Containers []docker.ComposeContainer
DockerErr error
Owners []ownerRecord
OwnersErr error
States []stateRecord
Leases []Lease
LeasesErr error
Alive func(ServiceRecord) bool
Exists func(string) bool
Checkout func(string) checkoutInfo
}
// DiscoverOptions select the sources Discover reads.
type DiscoverOptions struct {
// ListContainers lists the Compose containers; nil uses Docker.
ListContainers func() ([]docker.ComposeContainer, error)
// Dirs are extra directories, such as the working directory, whose
// repositories are searched for stack state.
Dirs []string
}
// Discover lists every dev stack on the machine from Docker, the project
// owner records, the stack state files, and the port leases. It takes no
// locks and changes nothing.
func Discover(registry *Registry, opts DiscoverOptions) Inventory {
return buildInventory(readInventoryInput(registry, opts))
}
func readInventoryInput(registry *Registry, opts DiscoverOptions) inventoryInput {
in := inventoryInput{Alive: groupIsOurs, Exists: pathExists, Checkout: inspectCheckout}
list := opts.ListContainers
if list == nil {
list = docker.ListComposeContainers
}
in.Containers, in.DockerErr = list()
in.Owners, in.OwnersErr = readOwners(registry)
in.Leases, in.LeasesErr = registry.Leases()
// Roots lead to Git common directories, whose state directories are
// read; each state's root can lead to another repository.
var roots, dirs []string
for _, owner := range in.Owners {
roots = append(roots, owner.StateDir)
dirs = append(dirs, owner.StateDir)
}
for _, lease := range in.Leases {
roots = append(roots, lease.Root)
}
for _, c := range in.Containers {
roots = append(roots, composeRoot(c.WorkingDir))
}
roots = append(roots, opts.Dirs...)
commons, read := map[string]bool{}, map[string]bool{}
for len(roots) > 0 || len(dirs) > 0 {
if len(dirs) == 0 {
root := roots[0]
roots = roots[1:]
if common := commonDirOf(root); root != "" && common != "" && !commons[common] {
commons[common] = true
dirs = append(dirs, stateDirCandidates(common)...)
}
continue
}
dir := dirs[0]
dirs = dirs[1:]
if dir == "" || read[dir] {
continue
}
read[dir] = true
info, err := os.Stat(filepath.Join(dir, stateFileName))
if errors.Is(err, os.ErrNotExist) {
continue
}
record := stateRecord{Dir: dir, Err: err}
if err == nil {
record.ModTime = info.ModTime()
record.State, record.Err = (&Stack{StateDir: dir}).ReadState()
}
in.States = append(in.States, record)
if record.State != nil {
roots = append(roots, record.State.Root)
}
}
return in
}
func readOwners(registry *Registry) ([]ownerRecord, error) {
if registry == nil {
return nil, nil
}
paths, err := filepath.Glob(filepath.Join(registry.Dir, projectsDirName, "*", ownerFileName))
if err != nil {
return nil, err
}
owners := make([]ownerRecord, 0, len(paths))
for _, path := range paths {
owners = append(owners, readOwner(path))
}
return owners, nil
}
func readOwner(path string) ownerRecord {
record := ownerRecord{Path: path, Key: filepath.Base(filepath.Dir(path))}
data, err := os.ReadFile(path)
if err != nil {
record.Err = err
return record
}
var owner projectOwner
if err := json.Unmarshal(data, &owner); err != nil {
record.Err = fmt.Errorf("parse project owner %s: %w", path, err)
return record
}
if owner.StateDir == "" || !filepath.IsAbs(owner.StateDir) {
record.Err = fmt.Errorf("invalid project owner %s", path)
return record
}
record.StateDir = owner.StateDir
return record
}
// commonDirOf returns the Git common directory a stack root or state
// directory belongs to, or "" when it cannot be reached.
func commonDirOf(path string) string {
switch filepath.Base(path) {
case sharedStateDirName, isolatedStateDirName, frontendStateDirName:
parent := filepath.Dir(path)
if filepath.Base(filepath.Dir(parent)) == "worktrees" {
parent = filepath.Dir(filepath.Dir(parent))
}
if pathExists(parent) {
return parent
}
return ""
}
if !pathExists(path) {
return ""
}
common, err := gitOutput(path, "rev-parse", "--path-format=absolute", "--git-common-dir")
if err != nil {
return ""
}
if resolved, err := filepath.EvalSymlinks(common); err == nil {
common = resolved
}
return common
}
func stateDirCandidates(common string) []string {
dirs := []string{
filepath.Join(common, isolatedStateDirName),
filepath.Join(common, sharedStateDirName),
filepath.Join(common, frontendStateDirName),
}
for _, name := range []string{isolatedStateDirName, frontendStateDirName} {
matches, err := filepath.Glob(filepath.Join(common, "worktrees", "*", name))
if err == nil {
dirs = append(dirs, matches...)
}
}
return dirs
}
// composeRoot is the checkout of a Compose working directory.
func composeRoot(workingDir string) string {
clean := filepath.Clean(workingDir)
suffix := filepath.FromSlash(composeWorkingSuffix)
if strings.HasSuffix(clean, string(filepath.Separator)+suffix) {
return strings.TrimSuffix(clean, string(filepath.Separator)+suffix)
}
return clean
}
func modeOfStateDir(dir string) Mode {
switch filepath.Base(dir) {
case sharedStateDirName:
return Shared
case frontendStateDirName:
return Frontend
}
return Isolated
}
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func inspectCheckout(root string) checkoutInfo {
top, err := gitOutput(root, "rev-parse", "--show-toplevel")
if err != nil {
return checkoutInfo{}
}
resolvedTop, err1 := filepath.EvalSymlinks(top)
resolvedRoot, err2 := filepath.EvalSymlinks(root)
if err1 != nil || err2 != nil || resolvedTop != resolvedRoot {
return checkoutInfo{}
}
branch, _ := gitOutput(root, "branch", "--show-current")
return checkoutInfo{Worktree: true, Branch: branch}
}
// isOnyxProject recognizes an Onyx Compose project by its services.
func isOnyxProject(containers []docker.ComposeContainer) bool {
services := map[string]bool{}
for _, c := range containers {
services[c.Service] = true
}
return services[docker.RelationalDB.Name] && services["cache"]
}
func buildInventory(in inventoryInput) Inventory {
var inv Inventory
if in.DockerErr != nil {
inv.Warnings = append(inv.Warnings, fmt.Sprintf("Docker is unavailable, so containers are not shown: %v", in.DockerErr))
}
if in.OwnersErr != nil {
inv.Warnings = append(inv.Warnings, fmt.Sprintf("cannot read project owner records: %v", in.OwnersErr))
}
if in.LeasesErr != nil {
inv.Warnings = append(inv.Warnings, fmt.Sprintf("cannot read port leases: %v", in.LeasesErr))
}
inv.Unreadable = in.DockerErr != nil && (in.OwnersErr != nil || in.LeasesErr != nil) && len(in.States) == 0
ownersByKey := map[string][]ownerRecord{}
ownersByStateDir := map[string]ownerRecord{}
for _, owner := range in.Owners {
if owner.Err != nil {
inv.Warnings = append(inv.Warnings, owner.Err.Error())
continue
}
ownersByKey[owner.Key] = append(ownersByKey[owner.Key], owner)
ownersByStateDir[owner.StateDir] = owner
}
statesByDir := map[string]stateRecord{}
claims := map[string][]stateRecord{}
for _, record := range in.States {
statesByDir[record.Dir] = record
if record.State != nil && record.State.Project != "" && modeOfStateDir(record.Dir) != Frontend {
claims[record.State.Project] = append(claims[record.State.Project], record)
}
}
groups := map[string][]docker.ComposeContainer{}
for _, c := range in.Containers {
groups[c.Project] = append(groups[c.Project], c)
}
for project, containers := range groups {
if len(claims[project]) == 0 && len(ownersByKey[ProjectName(project)]) == 0 && !isOnyxProject(containers) {
delete(groups, project)
}
}
primary := map[string]string{}
for project, records := range claims {
primary[project] = choosePrimary(project, records, ownersByKey)
}
var rows []*StackRow
for _, record := range in.States {
row := &StackRow{StateDir: record.Dir, Mode: modeOfStateDir(record.Dir), Since: record.ModTime, Errors: []string{}}
if owner, ok := ownersByStateDir[record.Dir]; ok {
row.OwnerRecord = owner.Path
}
if record.Err != nil {
row.Errors = append(row.Errors, record.Err.Error())
}
if st := record.State; st != nil {
row.state = st
row.Project, row.Root, row.Profile, row.Phase = st.Project, st.Root, st.Profile, st.Phase
row.Errors = append(row.Errors, st.Errors...)
row.App, row.Web = appCount(st, in.Alive)
if row.Mode != Frontend && primary[st.Project] == record.Dir {
if containers, ok := groups[st.Project]; ok {
row.attachContainers(containers)
delete(groups, st.Project)
}
}
}
rows = append(rows, row)
}
projects := make([]string, 0, len(groups))
for project := range groups {
projects = append(projects, project)
}
sort.Strings(projects)
for _, project := range projects {
row := &StackRow{Project: project, Errors: []string{}}
row.attachContainers(groups[project])
row.Root = composeRoot(row.WorkingDir)
for _, owner := range ownersByKey[ProjectName(project)] {
row.OwnerRecord = owner.Path
record, ok := statesByDir[owner.StateDir]
switch {
case !ok:
row.Errors = append(row.Errors, fmt.Sprintf("owner record %s points to %s, which has no state", owner.Path, owner.StateDir))
case record.Err != nil:
row.Errors = append(row.Errors, record.Err.Error())
}
}
rows = append(rows, row)
}
attached := map[string]bool{}
for _, row := range rows {
attached[row.OwnerRecord] = true
}
for _, owner := range in.Owners {
if owner.Err == nil && !attached[owner.Path] {
rows = append(rows, &StackRow{OwnerRecord: owner.Path, StateDir: owner.StateDir, Mode: modeOfStateDir(owner.StateDir),
Status: StatusOrphaned, OrphanReason: "the owner record points to missing state", Errors: []string{}})
}
}
for _, row := range rows {
if row.Root != "" {
row.RootExists = in.Exists(row.Root)
if row.RootExists {
info := in.Checkout(row.Root)
row.Worktree, row.Branch = info.Worktree, info.Branch
}
}
}
leasesByRoot := map[string]map[string]int{}
var leaseRoots []string
for _, lease := range in.Leases {
if leasesByRoot[lease.Root] == nil {
leasesByRoot[lease.Root] = map[string]int{}
leaseRoots = append(leaseRoots, lease.Root)
}
leasesByRoot[lease.Root][lease.Name] = lease.Port
}
for _, root := range leaseRoots {
if row := leaseOwner(rows, root); row != nil {
row.LeaseRoot, row.Leases = root, leasesByRoot[root]
continue
}
rows = append(rows, &StackRow{Root: root, RootExists: in.Exists(root), Status: StatusStaleLease, LeaseRoot: root, Leases: leasesByRoot[root], Errors: []string{}})
}
for _, row := range rows {
if row.Status == "" {
row.Status, row.OrphanReason = rowStatus(row, len(claims[row.Project]) > 0)
}
row.Next = nextCommand(row)
inv.Rows = append(inv.Rows, *row)
}
sort.SliceStable(inv.Rows, func(i, j int) bool {
a, b := inv.Rows[i], inv.Rows[j]
if statusOrder[a.Status] != statusOrder[b.Status] {
return statusOrder[a.Status] < statusOrder[b.Status]
}
if !a.Since.Equal(b.Since) {
return a.Since.Before(b.Since)
}
return a.Project < b.Project
})
return inv
}
// choosePrimary picks the state that owns a project's infrastructure: the
// owner record's state, else the newest.
func choosePrimary(project string, records []stateRecord, owners map[string][]ownerRecord) string {
best := records[0]
for _, record := range records {
for _, owner := range owners[ProjectName(project)] {
if record.Dir == owner.StateDir {
return record.Dir
}
}
if record.State.UpdatedAt.After(best.State.UpdatedAt) {
best = record
}
}
return best.Dir
}
func appCount(st *State, alive func(ServiceRecord) bool) (*AppCount, string) {
count := &AppCount{}
web := ""
for _, rec := range st.Services {
if rec.Skipped != "" {
continue
}
count.Total++
if !alive(rec) {
continue
}
count.Running++
if rec.Slug == "web" {
port := rec.Port
if port == 0 {
port = st.Ports["web"]
}
if port > 0 {
web = fmt.Sprintf("http://localhost:%d", port)
}
}
}
return count, web
}
func (r *StackRow) attachContainers(containers []docker.ComposeContainer) {
infra := &InfraCount{Total: len(containers)}
var oldest time.Time
health := map[string]bool{}
for _, c := range containers {
if r.WorkingDir == "" {
r.WorkingDir = c.WorkingDir
}
if !c.Running() {
continue
}
infra.Running++
if c.Health != "" {
health[c.Health] = true
}
if !c.StartedAt.IsZero() && (oldest.IsZero() || c.StartedAt.Before(oldest)) {
oldest = c.StartedAt
}
}
switch {
case health["unhealthy"]:
infra.Health = "unhealthy"
case health["starting"]:
infra.Health = "starting"
case health["healthy"]:
infra.Health = "healthy"
}
if oldest.IsZero() && r.Since.IsZero() {
for _, c := range containers {
if !c.CreatedAt.IsZero() && (oldest.IsZero() || c.CreatedAt.Before(oldest)) {
oldest = c.CreatedAt
}
}
}
if !oldest.IsZero() {
r.Since = oldest
}
r.Infra = infra
r.containers = len(containers)
}
// leaseOwner finds the stack whose leases are keyed by root: an isolated
// stack leases under its checkout, a shared or frontend stack under its
// state directory.
func leaseOwner(rows []*StackRow, root string) *StackRow {
for _, row := range rows {
if row.StateDir == root && row.Mode != Isolated {
return row
}
}
for _, row := range rows {
if row.state != nil && row.Mode == Isolated && row.Root == root {
return row
}
}
for _, row := range rows {
if row.state == nil && row.Root == root {
return row
}
}
return nil
}
// nextCommand is the command that stops row's stack.
func nextCommand(row *StackRow) string {
inRoot := func(args string) string {
return "cd " + shellQuote(row.Root) + " && ods " + args
}
switch row.Status {
case StatusOrphaned:
if row.Mode == Frontend || row.Project == "" {
return "ods stack prune"
}
return "ods stack down --project " + row.Project
case StatusActive, StatusAppStopped, StatusInfraOnly:
switch {
case row.state == nil:
return "ods stack down --project " + row.Project
case row.Mode == Frontend:
return "stop the 'ods web dev' process in " + row.Root
case row.Mode == Isolated:
return inRoot("--isolated stack down")
}
return inRoot("stack down")
case StatusStopped:
if row.containers > 0 && row.Project != "" {
return "ods stack down --project " + row.Project
}
}
return ""
}
func shellQuote(path string) string {
if path != "" && !strings.ContainsAny(path, " \t'\"$`\\;&|<>()*?[]{}~!#") {
return path
}
return "'" + strings.ReplaceAll(path, "'", `'\''`) + "'"
}
func rowStatus(row *StackRow, claimed bool) (string, string) {
reason := ""
switch {
case row.state != nil && row.Root != "" && !row.RootExists:
reason = "the checkout no longer exists"
case row.state != nil && row.Root != "" && !row.Worktree:
reason = "the checkout is no longer a git worktree"
case row.state == nil && row.WorkingDir != "" && !claimed && !row.RootExists:
reason = "the Compose working directory no longer exists"
case row.state == nil && row.OwnerRecord != "" && !claimed && !row.Worktree:
reason = "the owning worktree was removed"
}
appRunning := row.App != nil && row.App.Running > 0
infraRunning := row.Infra != nil && row.Infra.Running > 0
if reason != "" && (appRunning || row.containers > 0 || len(row.Leases) > 0 || row.OwnerRecord != "") {
return StatusOrphaned, reason
}
switch {
case appRunning:
return StatusActive, ""
case infraRunning && row.state != nil && row.Phase == PhaseStopped:
return StatusAppStopped, ""
case infraRunning:
return StatusInfraOnly, ""
}
return StatusStopped, ""
}