package instance import ( "context" "fmt" "net/http" "os" "os/exec" "strings" "sync" "time" "instance-manager/internal/config" "instance-manager/internal/db" "instance-manager/internal/models" ) type Manager struct { db *db.Database cfg config.InstanceConfig mu sync.Mutex procs map[int]*exec.Cmd } func New(db *db.Database, cfg config.InstanceConfig) *Manager { return &Manager{ db: db, cfg: cfg, procs: make(map[int]*exec.Cmd), } } func (m *Manager) Start(user *models.User) error { m.mu.Lock() defer m.mu.Unlock() existing, err := m.db.GetInstanceByUserID(user.ID) if err != nil { return fmt.Errorf("querying instance: %w", err) } if existing != nil && existing.Status == "running" { // Verify the process is actually alive (may be stale after restart) if _, ok := m.procs[existing.Port]; ok { return nil } // Stale "running" status — treat as stopped and restart port := existing.Port instID := existing.ID if err := m.db.UpdateInstanceStatus(instID, "starting"); err != nil { return err } delete(m.procs, port) url := m.buildURL(port, user.Username) if err := m.db.UpdateInstanceURL(instID, url); err != nil { return err } if err := m.db.UpdateInstancePort(instID, port); err != nil { return err } ctx, cancel := context.WithCancel(context.Background()) args := m.buildArgs(m.cfg.StartCommand, port, user.Username, user.ID) cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr env := os.Environ() env = append(env, fmt.Sprintf("APP_PORT=%d", port)) env = append(env, fmt.Sprintf("APP_USER_ID=%d", user.ID)) env = append(env, fmt.Sprintf("APP_USER_NAME=%s", user.Username)) cmd.Env = env if err := cmd.Start(); err != nil { cancel() if err := m.db.UpdateInstanceStatus(instID, "error"); err != nil { fmt.Printf("Failed to update instance status: %v\n", err) } return fmt.Errorf("starting instance: %w", err) } pid := int64(cmd.Process.Pid) m.procs[port] = cmd if err := m.db.UpdateInstanceRunning(instID, pid); err != nil { fmt.Printf("Failed to update instance running status: %v\n", err) } go func(p int) { if !m.waitForReady(p, 10*time.Second) { fmt.Printf("Instance on port %d did not become ready in time\n", p) } else { fmt.Printf("Instance on port %d is ready\n", p) } }(port) return nil } var port int var instID int64 if existing != nil { port = existing.Port instID = existing.ID if err := m.db.UpdateInstanceStatus(instID, "starting"); err != nil { return err } if old, ok := m.procs[port]; ok && old.Process != nil { old.Process.Kill() old.Wait() delete(m.procs, port) } } else { allocatedPort, err := m.allocatePort() if err != nil { return fmt.Errorf("allocating port: %w", err) } port = allocatedPort url := m.buildURL(port, user.Username) inst, err := m.db.CreateInstance(user.ID, port, url) if err != nil { return fmt.Errorf("creating instance record: %w", err) } instID = inst.ID } url := m.buildURL(port, user.Username) if err := m.db.UpdateInstanceURL(instID, url); err != nil { return err } if err := m.db.UpdateInstancePort(instID, port); err != nil { return err } ctx, cancel := context.WithCancel(context.Background()) args := m.buildArgs(m.cfg.StartCommand, port, user.Username, user.ID) cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr env := os.Environ() env = append(env, fmt.Sprintf("APP_PORT=%d", port)) env = append(env, fmt.Sprintf("APP_USER_ID=%d", user.ID)) env = append(env, fmt.Sprintf("APP_USER_NAME=%s", user.Username)) cmd.Env = env if err := cmd.Start(); err != nil { cancel() if err := m.db.UpdateInstanceStatus(instID, "error"); err != nil { fmt.Printf("Failed to update instance status: %v\n", err) } return fmt.Errorf("starting instance: %w", err) } pid := int64(cmd.Process.Pid) m.procs[port] = cmd if err := m.db.UpdateInstanceRunning(instID, pid); err != nil { fmt.Printf("Failed to update instance running status: %v\n", err) } // Release lock before waiting for instance to be ready go func(p int) { if !m.waitForReady(p, 10*time.Second) { fmt.Printf("Instance on port %d did not become ready in time\n", p) } else { fmt.Printf("Instance on port %d is ready\n", p) } }(port) return nil } func (m *Manager) Stop(user *models.User) error { m.mu.Lock() defer m.mu.Unlock() inst, err := m.db.GetInstanceByUserID(user.ID) if err != nil { return fmt.Errorf("querying instance: %w", err) } if inst == nil { return nil } if inst.Status == "stopped" { return nil } if err := m.db.UpdateInstanceStatus(inst.ID, "stopping"); err != nil { return err } if m.cfg.StopCommand != "" { stopCmd := m.buildCommand(m.cfg.StopCommand, inst.Port, user.Username, user.ID) output, err := stopCmd.CombinedOutput() if err != nil { fmt.Printf("Stop command error: %v, output: %s\n", err, output) } } if cmd, ok := m.procs[inst.Port]; ok { cmd.Process.Kill() cmd.Wait() delete(m.procs, inst.Port) } return m.db.UpdateInstanceStatus(inst.ID, "stopped") } func (m *Manager) StartAll() { users, err := m.db.GetActiveUsers() if err != nil { fmt.Printf("Failed to get active users: %v\n", err) return } var wg sync.WaitGroup for _, user := range users { wg.Add(1) go func(u models.User) { defer wg.Done() if err := m.Start(&u); err != nil { fmt.Printf("Failed to start instance for user %s: %v\n", u.Username, err) } else { fmt.Printf("Started instance for user %s\n", u.Username) } }(user) } wg.Wait() } func (m *Manager) GetInstance(user *models.User) (*models.Instance, error) { return m.db.GetInstanceByUserID(user.ID) } func (m *Manager) GetInstanceByPort(port int) (*models.Instance, error) { return m.db.GetInstanceByPort(port) } func (m *Manager) GetInstanceURL(user *models.User) (string, error) { inst, err := m.GetInstance(user) if err != nil { return "", err } if inst == nil { return "", fmt.Errorf("no instance found") } if inst.Status != "running" { return "", fmt.Errorf("instance is not running") } return inst.URL, nil } func (m *Manager) waitForReady(port int, timeout time.Duration) bool { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() ticker := time.NewTicker(250 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return false case <-ticker.C: client := &http.Client{Timeout: 200 * time.Millisecond} resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/", port)) if err == nil { resp.Body.Close() return true } } } } func (m *Manager) allocatePort() (int, error) { for port := m.cfg.PortRange.Start; port <= m.cfg.PortRange.End; port++ { inst, err := m.db.GetInstanceByPort(port) if err != nil { return 0, err } if inst == nil { return port, nil } } return 0, fmt.Errorf("no available ports in range") } func (m *Manager) buildCommand(template string, port int, username string, userID int64) *exec.Cmd { cmdStr := template cmdStr = strings.ReplaceAll(cmdStr, "{port}", fmt.Sprintf("%d", port)) cmdStr = strings.ReplaceAll(cmdStr, "{username}", username) cmdStr = strings.ReplaceAll(cmdStr, "{userid}", fmt.Sprintf("%d", userID)) parts := strings.Fields(cmdStr) if len(parts) == 0 { return exec.Command("true") } cmd := exec.Command(parts[0], parts[1:]...) env := os.Environ() env = append(env, fmt.Sprintf("APP_PORT=%d", port)) env = append(env, fmt.Sprintf("APP_USER_ID=%d", userID)) env = append(env, fmt.Sprintf("APP_USER_NAME=%s", username)) cmd.Env = env return cmd } func (m *Manager) buildURL(port int, username string) string { url := m.cfg.URLTemplate url = strings.ReplaceAll(url, "{port}", fmt.Sprintf("%d", port)) url = strings.ReplaceAll(url, "{username}", username) return url } func (m *Manager) buildArgs(template string, port int, username string, userID int64) []string { args := strings.Fields(template) for i, arg := range args { args[i] = strings.ReplaceAll(arg, "{port}", fmt.Sprintf("%d", port)) args[i] = strings.ReplaceAll(args[i], "{username}", username) args[i] = strings.ReplaceAll(args[i], "{userid}", fmt.Sprintf("%d", userID)) } return args }