package api import ( "context" "encoding/json" "errors" "net/http" "net/url" "os" "path/filepath" "regexp" "strings" "time" ) var orphanID = regexp.MustCompile(`^[a-f0-9]{64}$`) var orphanLabelID = regexp.MustCompile(`^[a-f0-9]{32}$`) func (a *API) checkOrphan(ctx context.Context, id string) error { var c struct { ID, Name string Config struct{ Labels map[string]string } State struct { Status string Running, Paused, Restarting bool } Mounts []struct{ Type, Source, Destination string } } if !orphanID.MatchString(id) { return errors.New("invalid container ID") } if err := a.docker.Do(ctx, "GET", "/containers/"+id+"/json", nil, &c); err != nil { return errors.New("container unavailable") } inst, owner := c.Config.Labels["dsh-mgr.instance"], c.Config.Labels["dsh-mgr.owner"] if c.ID != id || c.Config.Labels["dsh-mgr.role"] != "dsh" || !orphanLabelID.MatchString(inst) || !orphanLabelID.MatchString(owner) || strings.TrimPrefix(c.Name, "/") != runtimeNetwork(inst) { return errors.New("container identity not eligible") } if c.State.Running || c.State.Paused || c.State.Restarting || (c.State.Status != "exited" && c.State.Status != "created") { return errors.New("only stopped containers can be reclaimed") } var n int if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM instances WHERE id=? OR container_id=?", inst, id).Scan(&n); err != nil || n != 0 { return errors.New("instance reference or database unavailable") } if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM users WHERE id=?", owner).Scan(&n); err != nil || n != 1 { return errors.New("unknown owner") } if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='upgrade_journal'").Scan(&n); err != nil { return err } if n != 0 { if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM upgrade_journal WHERE instance_id=? OR old_container=? OR new_container=?", inst, id, id).Scan(&n); err != nil || n != 0 { return errors.New("upgrade reference or database unavailable") } } host := os.Getenv("HOST_DATA_ROOT") if host == "" { host = a.root } if !filepath.IsAbs(host) { return errors.New("host root unavailable") } expected := map[string]string{"/workspace": filepath.Join(host, "workspaces", owner, "instances", inst, "home")} for _, m := range c.Mounts { if m.Type == "tmpfs" && m.Destination == "/tmp" { continue } want, ok := expected[m.Destination] if !ok || m.Type != "bind" || filepath.Clean(m.Source) != want { return errors.New("mount identity not eligible") } delete(expected, m.Destination) } if len(expected) != 0 { return errors.New("required mounts missing") } return nil } func (a *API) orphans(w http.ResponseWriter, r *http.Request) { u, _, err := a.user(r) if err != nil { fail(w, 401, "authentication required") return } if !u.Admin { fail(w, 403, "administrator required") return } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() // checkOrphan re-verifies instance rows, upgrade journal entries and mount // identities before removal, so no lifecycle lock is needed here: a // container is only reclaimed when its instance row is already gone. switch r.Method { case "GET": filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.role=dsh"}}) var list []struct { ID string `json:"Id"` } if a.docker.Do(ctx, "GET", "/containers/json?all=1&filters="+url.QueryEscape(string(filters)), nil, &list) != nil { fail(w, 502, "container inventory unavailable") return } eligible := []string{} for _, c := range list { if a.checkOrphan(ctx, c.ID) == nil { eligible = append(eligible, c.ID) } } writeJSON(w, eligible) case "DELETE": id := r.URL.Query().Get("id") if r.URL.Query().Get("confirm") != id || id == "" { fail(w, 400, "exact container ID confirmation required") return } if err := a.checkOrphan(ctx, id); err != nil { fail(w, 409, err.Error()) return } // Never force removal or remove volumes. Docker rejects a concurrent start. if a.docker.Do(ctx, "DELETE", "/containers/"+id+"?force=false&v=false", nil, nil) != nil { fail(w, 409, "container changed or removal rejected") return } a.s.AddAudit(u.ID, "reconcile.reclaim", id, "stopped orphan removed; bind data retained") writeJSON(w, map[string]bool{"ok": true}) default: fail(w, 405, "method not allowed") } }