Initial commit: DSH Web Manager
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user