Files
onyx/tools/ods/internal/devstack/stack.go

180 lines
5.0 KiB
Go

// Package devstack runs the local Onyx dev stack for one worktree: it leases
// host ports, starts the application services as managed process groups,
// keeps their state in the worktree's git metadata, and reconciles a partial
// start on the next run.
package devstack
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const (
stateFileName = "state.json"
lockFileName = "lock"
logsDirName = "logs"
// SchemaVersion is bumped when State changes shape.
SchemaVersion = 1
)
// Phases of a stack, as recorded in State.Phase.
const (
PhasePreparing = "preparing"
PhaseInfra = "infra"
PhaseMigrating = "migrating"
PhaseStarting = "starting"
PhaseReady = "ready"
PhaseDegraded = "degraded"
PhaseStopped = "stopped"
PhaseFailed = "failed"
)
// Stack identifies one worktree's dev stack.
type Stack struct {
// Root is the worktree root with symlinks resolved.
Root string
// StateDir holds the state file, lock and logs. It lives in the
// worktree's git metadata so every agent in the worktree shares it.
StateDir string
// Project is the Docker Compose project name.
Project string
// Registry holds the host-wide port leases.
Registry *Registry
}
// Open resolves the stack for the worktree that contains dir. project
// overrides the derived Compose project name when not empty.
func Open(dir, project string) (*Stack, error) {
root, err := gitOutput(dir, "rev-parse", "--show-toplevel")
if err != nil {
return nil, fmt.Errorf("%s is not inside a git worktree: %w", dir, err)
}
if root, err = filepath.EvalSymlinks(root); err != nil {
return nil, err
}
stateDir, err := gitOutput(root, "rev-parse", "--git-path", "ods-stack")
if err != nil {
return nil, err
}
if !filepath.IsAbs(stateDir) {
stateDir = filepath.Join(root, stateDir)
}
if project == "" {
project = ProjectName(root)
}
return &Stack{Root: root, StateDir: stateDir, Project: project}, nil
}
// ProjectName derives a Compose project name that is unique per worktree
// path: the normalized directory name plus a short hash of the full path.
func ProjectName(root string) string {
var b strings.Builder
// Compose accepts lowercase ASCII letters, digits, '-' and '_' only.
for _, r := range strings.ToLower(filepath.Base(root)) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
}
}
name := strings.Trim(b.String(), "-_")
if name == "" {
name = "onyx"
}
if len(name) > 40 {
name = name[:40]
}
sum := sha256.Sum256([]byte(root))
return name + "-" + hex.EncodeToString(sum[:])[:8]
}
func gitOutput(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// State is the persisted record of a stack.
type State struct {
SettleTime time.Duration `json:"settle_time,omitempty"`
SchemaVersion int `json:"schema_version"`
Root string `json:"root"`
Project string `json:"project"`
Profile string `json:"profile"`
Phase string `json:"phase"`
Ports map[string]int `json:"ports"`
Services []ServiceRecord `json:"services"`
Errors []string `json:"errors,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
Extra map[string]string `json:"extra,omitempty"`
}
// ReadState returns the persisted state, or nil when the stack has none.
func (s *Stack) ReadState() (*State, error) {
data, err := os.ReadFile(filepath.Join(s.StateDir, stateFileName))
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var st State
if err := json.Unmarshal(data, &st); err != nil {
return nil, fmt.Errorf("parse %s: %w", filepath.Join(s.StateDir, stateFileName), err)
}
return &st, nil
}
// WriteState persists st atomically.
func (s *Stack) WriteState(st *State) error {
st.SchemaVersion = SchemaVersion
st.Root = s.Root
st.Project = s.Project
st.UpdatedAt = time.Now().UTC()
if st.Ports == nil {
st.Ports = map[string]int{}
}
if err := os.MkdirAll(s.StateDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
return writeFileAtomic(filepath.Join(s.StateDir, stateFileName), append(data, '\n'), 0o644)
}
// LogDir is where service logs are written.
func (s *Stack) LogDir() string {
return filepath.Join(s.StateDir, logsDirName)
}
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
_, writeErr := tmp.Write(data)
closeErr := tmp.Close()
if err := errors.Join(writeErr, closeErr); err != nil {
return err
}
if err := os.Chmod(tmpName, perm); err != nil {
return err
}
return os.Rename(tmpName, path)
}