Live apply now accepts only :5433/agent_system_dev plus a data/dev host root, maps stored work dirs fail-closed, and reports unique O_EXCL backups after a parent-directory fsync. Co-authored-by: Cursor <cursoragent@cursor.com>
172 lines
4.5 KiB
Go
172 lines
4.5 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
envDev = "dev"
|
|
confirmRepairSingleDev = "REPAIR_SINGLE_DEV_DOC"
|
|
devDatabaseName = "agent_system_dev"
|
|
devPort = "5433"
|
|
)
|
|
|
|
type cliConfig struct {
|
|
DSN string
|
|
RepoRoot string
|
|
DocID string
|
|
BackupDir string
|
|
Apply bool
|
|
Live bool
|
|
Environment string
|
|
Confirm string
|
|
StoredDataRoot string
|
|
HostDataRoot string
|
|
}
|
|
|
|
func validateCLI(cfg cliConfig) error {
|
|
if strings.TrimSpace(cfg.DSN) == "" {
|
|
return fmt.Errorf("dsn or TEST_DATABASE_URL required")
|
|
}
|
|
if cfg.Apply && strings.TrimSpace(cfg.BackupDir) == "" {
|
|
return fmt.Errorf("--backup-dir is required with --apply")
|
|
}
|
|
if (strings.TrimSpace(cfg.StoredDataRoot) == "") != (strings.TrimSpace(cfg.HostDataRoot) == "") {
|
|
return fmt.Errorf("--stored-data-root and --host-data-root must be set together")
|
|
}
|
|
|
|
if !cfg.Live {
|
|
if sharedDSNEnv(cfg.DSN) != "" || firstSharedPathEnv(cfg.RepoRoot, cfg.BackupDir, cfg.StoredDataRoot, cfg.HostDataRoot) != "" {
|
|
return fmt.Errorf("refusing shared/env data path or DSN; use isolated TEST_DATABASE_URL, or pass --live --environment dev --doc-id and confirm")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if strings.TrimSpace(cfg.Environment) != envDev {
|
|
return fmt.Errorf("live mode only allows --environment %s", envDev)
|
|
}
|
|
if strings.TrimSpace(cfg.DocID) == "" {
|
|
return fmt.Errorf("live mode forbids batch operations; --doc-id is required")
|
|
}
|
|
if err := requireLiveDevDSN(cfg.DSN); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(cfg.StoredDataRoot) == "" || strings.TrimSpace(cfg.HostDataRoot) == "" {
|
|
return fmt.Errorf("live mode requires --stored-data-root and --host-data-root")
|
|
}
|
|
if !isDevDataRoot(cfg.HostDataRoot) {
|
|
return fmt.Errorf("live --environment dev requires --host-data-root to be a data/dev directory")
|
|
}
|
|
if env := sharedPathEnv(cfg.HostDataRoot); env != envDev {
|
|
return fmt.Errorf("live mode refuses host-data-root that is not positively data/dev")
|
|
}
|
|
if cfg.Apply && strings.TrimSpace(cfg.Confirm) != confirmRepairSingleDev {
|
|
return fmt.Errorf("live apply requires --confirm %s", confirmRepairSingleDev)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requireLiveDevDSN(dsn string) error {
|
|
port, db, err := inspectPostgresDSN(dsn)
|
|
if err != nil {
|
|
return fmt.Errorf("live mode: %w", err)
|
|
}
|
|
if port != devPort || db != devDatabaseName {
|
|
return fmt.Errorf("live --environment dev requires DSN port :%s and database %s (got port=%q db=%q)", devPort, devDatabaseName, port, db)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func inspectPostgresDSN(dsn string) (port, db string, err error) {
|
|
dsn = strings.TrimSpace(dsn)
|
|
if dsn == "" {
|
|
return "", "", fmt.Errorf("empty dsn")
|
|
}
|
|
if strings.Contains(dsn, "://") {
|
|
u, perr := url.Parse(dsn)
|
|
if perr != nil {
|
|
return "", "", fmt.Errorf("invalid dsn: %w", perr)
|
|
}
|
|
return u.Port(), strings.Trim(u.Path, "/"), nil
|
|
}
|
|
for _, part := range strings.Fields(dsn) {
|
|
k, v, ok := strings.Cut(part, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch k {
|
|
case "port":
|
|
port = v
|
|
case "dbname":
|
|
db = v
|
|
}
|
|
}
|
|
if port == "" && strings.Contains(dsn, ":"+devPort) {
|
|
port = devPort
|
|
}
|
|
if db == "" {
|
|
return "", "", fmt.Errorf("dsn is not a recognizable postgres URL or keyword string")
|
|
}
|
|
return port, db, nil
|
|
}
|
|
|
|
func isDevDataRoot(p string) bool {
|
|
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(p)))
|
|
return clean == "data/dev" || strings.HasSuffix(clean, "/data/dev")
|
|
}
|
|
|
|
func sharedDSNEnv(dsn string) string {
|
|
port, _, err := inspectPostgresDSN(dsn)
|
|
if err != nil {
|
|
switch {
|
|
case strings.Contains(dsn, ":5433"):
|
|
return envDev
|
|
case strings.Contains(dsn, ":5434"):
|
|
return "test"
|
|
case strings.Contains(dsn, ":5435"):
|
|
return "main"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
switch port {
|
|
case "5433":
|
|
return envDev
|
|
case "5434":
|
|
return "test"
|
|
case "5435":
|
|
return "main"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func firstSharedPathEnv(paths ...string) string {
|
|
for _, p := range paths {
|
|
if env := sharedPathEnv(p); env != "" {
|
|
return env
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func sharedPathEnv(p string) string {
|
|
if strings.TrimSpace(p) == "" {
|
|
return ""
|
|
}
|
|
clean := filepath.ToSlash(filepath.Clean(p))
|
|
switch {
|
|
case clean == "data/dev" || strings.HasSuffix(clean, "/data/dev") || strings.Contains(clean, "/data/dev/"):
|
|
return envDev
|
|
case clean == "data/test" || strings.HasSuffix(clean, "/data/test") || strings.Contains(clean, "/data/test/"):
|
|
return "test"
|
|
case clean == "data/main" || strings.HasSuffix(clean, "/data/main") || strings.Contains(clean, "/data/main/"):
|
|
return "main"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|