Files
dsh-web/internal/api/orphans.go
T

144 lines
4.7 KiB
Go

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")}
// Accept complete historical layouts, never arbitrary bind sources.
for _, m := range c.Mounts {
if m.Destination == "/data/dsh-home" {
expected = map[string]string{"/workspace": filepath.Join(host, "workspaces", owner, "files"), "/data/dsh-home": filepath.Join(host, "workspaces", owner, "instances", inst, "home")}
}
}
for _, m := range c.Mounts {
if m.Destination == "/shared" {
expected["/shared"] = filepath.Join(host, "workspaces", owner, "files")
}
}
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()
a.mu.Lock()
defer a.mu.Unlock()
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")
}
}