144 lines
4.7 KiB
Go
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")
|
|
}
|
|
}
|