Initial push
This commit is contained in:
@@ -0,0 +1,678 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/arescom/docker-migrate/internal/dkr"
|
||||
"github.com/arescom/docker-migrate/internal/job"
|
||||
"github.com/arescom/docker-migrate/internal/migrate"
|
||||
"github.com/arescom/docker-migrate/internal/spec"
|
||||
"github.com/arescom/docker-migrate/internal/sshx"
|
||||
"github.com/arescom/docker-migrate/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body := map[string]any{
|
||||
"ok": true,
|
||||
"dockerHost": s.docker.Endpoint,
|
||||
"packageDir": s.cfg.PackageDir,
|
||||
"dataDir": s.cfg.DataDir,
|
||||
"knownHosts": s.hosts.Path(),
|
||||
"authRequired": s.cfg.Token != "",
|
||||
}
|
||||
if v, err := s.docker.Ping(ctx); err != nil {
|
||||
body["ok"] = false
|
||||
body["dockerError"] = err.Error()
|
||||
} else {
|
||||
body["dockerVersion"] = v
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// handleSource returns the full inventory of the source daemon, plus the
|
||||
// default selection for every container so the UI can render immediately.
|
||||
func (s *Server) handleSource(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
inv, err := s.docker.Inventory(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "%v", err)
|
||||
return
|
||||
}
|
||||
defaults := make(map[string]spec.ItemSelection, len(inv.Containers))
|
||||
for i := range inv.Containers {
|
||||
defaults[inv.Containers[i].ID] = spec.DefaultSelection(&inv.Containers[i])
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"inventory": inv,
|
||||
"defaults": defaults,
|
||||
"options": spec.DefaultOptions(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleSourceSizes measures volume sizes, which is slow enough that the UI
|
||||
// asks for it separately.
|
||||
func (s *Server) handleSourceSizes(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
sizes, err := s.docker.VolumeSizes(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "%v", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"volumes": sizes})
|
||||
}
|
||||
|
||||
func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.conns.List())
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveConnection(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg sshx.Config
|
||||
if err := decode(r, &cfg); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "%v", err)
|
||||
return
|
||||
}
|
||||
saved, err := s.conns.Save(cfg)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "%v", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, saved)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteConnection(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.conns.Delete(r.PathValue("id")); err != nil {
|
||||
writeError(w, http.StatusNotFound, "%v", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleProbe reads the target's host key so the operator can compare the
|
||||
// fingerprint before trusting it.
|
||||
func (s *Server) handleProbe(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.conns.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "%v", err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
info, err := sshx.Probe(ctx, cfg, s.hosts)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "%v", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
// handleTrust records the host key. The fingerprint the operator approved is
|
||||
// echoed back and re-checked, so approving one key cannot trust another.
|
||||
func (s *Server) handleTrust(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.conns.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "%v", err)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
if err := decode(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "%v", err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := sshx.TrustFromProbe(ctx, cfg, s.hosts, body.Fingerprint); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "%v", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"trusted": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleTestConnection(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rd, closeFn, err := s.dialTarget(ctx, r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.writeDialError(w, err)
|
||||
return
|
||||
}
|
||||
defer closeFn()
|
||||
|
||||
pre, err := rd.Preflight(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "%v", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, pre)
|
||||
}
|
||||
|
||||
func (s *Server) handleTargetInventory(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
rd, closeFn, err := s.dialTarget(ctx, r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.writeDialError(w, err)
|
||||
return
|
||||
}
|
||||
defer closeFn()
|
||||
|
||||
inv, err := rd.Inventory(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "%v", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, inv)
|
||||
}
|
||||
|
||||
// PreviewItem is what the UI shows when the operator asks "what will this do?".
|
||||
type PreviewItem struct {
|
||||
ContainerID string `json:"containerId"`
|
||||
Name string `json:"name"`
|
||||
TargetName string `json:"targetName"`
|
||||
Image string `json:"image"`
|
||||
Commands []string `json:"commands"`
|
||||
Transfers []string `json:"transfers"`
|
||||
Notes []string `json:"notes"`
|
||||
Warnings []string `json:"warnings"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
}
|
||||
|
||||
// handlePreview renders the exact docker commands a plan would run, without
|
||||
// touching either host.
|
||||
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||
var plan spec.Plan
|
||||
if err := decode(r, &plan); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "%v", err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
inv, err := s.docker.Inventory(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "%v", err)
|
||||
return
|
||||
}
|
||||
byID := map[string]*spec.Container{}
|
||||
for i := range inv.Containers {
|
||||
byID[inv.Containers[i].ID] = &inv.Containers[i]
|
||||
}
|
||||
|
||||
out := []PreviewItem{}
|
||||
netSeen := map[string]bool{}
|
||||
var netCmds []string
|
||||
|
||||
for _, sel := range plan.Items {
|
||||
if !sel.Include {
|
||||
continue
|
||||
}
|
||||
p, err := migrate.Prepare(byID[sel.ContainerID], sel, inv.Volumes, inv.Networks)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "container %s: %v", sel.ContainerID, err)
|
||||
return
|
||||
}
|
||||
item := PreviewItem{
|
||||
ContainerID: p.Source.ID,
|
||||
Name: p.Source.Name,
|
||||
TargetName: p.ContainerName(),
|
||||
Image: p.Target.Image,
|
||||
Notes: p.Notes,
|
||||
Warnings: p.Source.Warnings,
|
||||
}
|
||||
for _, n := range p.Networks {
|
||||
if !netSeen[n.Name] {
|
||||
netSeen[n.Name] = true
|
||||
netCmds = append(netCmds, "docker "+spec.ShellQuoteAll(n.CreateArgs()))
|
||||
}
|
||||
}
|
||||
for _, v := range p.Volumes {
|
||||
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(v.CreateArgs()))
|
||||
}
|
||||
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(p.Target.CreateArgs(p.Render)))
|
||||
for _, args := range p.Target.NetworkConnectArgs(p.Render) {
|
||||
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(args))
|
||||
}
|
||||
for _, t := range p.Transfers {
|
||||
item.Transfers = append(item.Transfers, t.Label)
|
||||
item.Commands = append(item.Commands,
|
||||
fmt.Sprintf("docker cp -a - %s:%s # contents of %s", p.ContainerName(), t.RestoreInto, t.SourcePath))
|
||||
if t.SizeBytes > 0 {
|
||||
item.TotalBytes += t.SizeBytes
|
||||
}
|
||||
}
|
||||
if p.Selection.StartAfter {
|
||||
item.Commands = append(item.Commands, "docker start "+spec.ShellQuote(p.ContainerName()))
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"networkCommands": netCmds,
|
||||
"items": out,
|
||||
})
|
||||
}
|
||||
|
||||
type sshMigrateRequest struct {
|
||||
ConnectionID string `json:"connectionId"`
|
||||
Plan spec.Plan `json:"plan"`
|
||||
}
|
||||
|
||||
func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
|
||||
var req sshMigrateRequest
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "%v", err)
|
||||
return
|
||||
}
|
||||
if req.ConnectionID == "" {
|
||||
writeError(w, http.StatusBadRequest, "connectionId is required")
|
||||
return
|
||||
}
|
||||
if countIncluded(req.Plan) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "no containers selected")
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := s.conns.Get(req.ConnectionID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The inventory is re-read now so the plan is applied to current state
|
||||
// rather than to whatever the browser last loaded.
|
||||
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||
inv, err := s.docker.Inventory(invCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Dial before the job starts so a bad credential is an immediate error in
|
||||
// the UI instead of a failed job.
|
||||
dialCtx, dialCancel := context.WithTimeout(r.Context(), 45*time.Second)
|
||||
client, err := sshx.Dial(dialCtx, cfg, s.hosts)
|
||||
dialCancel()
|
||||
if err != nil {
|
||||
s.writeDialError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("%d container(s) to %s", countIncluded(req.Plan), cfg.Name)
|
||||
runner := &migrate.SSHRunner{
|
||||
Src: s.docker,
|
||||
Dst: sshx.NewRemoteDocker(client),
|
||||
Containers: inv.Containers,
|
||||
Volumes: inv.Volumes,
|
||||
Networks: inv.Networks,
|
||||
Plan: req.Plan,
|
||||
}
|
||||
|
||||
j := s.jobs.Run(context.Background(), job.KindSSH, title, req.Plan.Options.DryRun,
|
||||
func(ctx context.Context, j *job.Job) error {
|
||||
defer client.Close()
|
||||
j.Logf(job.LevelInfo, "", "migrating to %s@%s over ssh", cfg.User, cfg.Host)
|
||||
return runner.Run(ctx, j)
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusAccepted, j.Snapshot())
|
||||
}
|
||||
|
||||
type packageRequest struct {
|
||||
Plan spec.Plan `json:"plan"`
|
||||
Format migrate.PackageFormat `json:"format"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBuildPackage(w http.ResponseWriter, r *http.Request) {
|
||||
var req packageRequest
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "%v", err)
|
||||
return
|
||||
}
|
||||
if countIncluded(req.Plan) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "no containers selected")
|
||||
return
|
||||
}
|
||||
if req.Format == "" {
|
||||
req.Format = migrate.FormatTar
|
||||
}
|
||||
if req.Format != migrate.FormatTar && req.Format != migrate.FormatDir {
|
||||
writeError(w, http.StatusBadRequest, "format must be %q or %q", migrate.FormatTar, migrate.FormatDir)
|
||||
return
|
||||
}
|
||||
|
||||
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||
inv, err := s.docker.Inventory(invCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
packager := &migrate.Packager{
|
||||
Src: s.docker,
|
||||
Containers: inv.Containers,
|
||||
Volumes: inv.Volumes,
|
||||
Networks: inv.Networks,
|
||||
Plan: req.Plan,
|
||||
OutputDir: s.cfg.PackageDir,
|
||||
Format: req.Format,
|
||||
SourceHost: inv.Host,
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("package of %d container(s)", countIncluded(req.Plan))
|
||||
j := s.jobs.Run(context.Background(), job.KindPackage, title, req.Plan.Options.DryRun,
|
||||
func(ctx context.Context, j *job.Job) error {
|
||||
res, err := packager.Run(ctx, j)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
j.Logf(job.LevelInfo, "", "package ready: %s (%s)", res.Name, humanBytes(res.Bytes))
|
||||
return nil
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusAccepted, j.Snapshot())
|
||||
}
|
||||
|
||||
func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.jobs.List())
|
||||
}
|
||||
|
||||
func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
|
||||
j, err := s.jobs.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "%v", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, j.Snapshot())
|
||||
}
|
||||
|
||||
// handleJobEvents streams job snapshots over server-sent events, coalescing
|
||||
// rapid updates so a fast transfer does not saturate the browser.
|
||||
func (s *Server) handleJobEvents(w http.ResponseWriter, r *http.Request) {
|
||||
j, err := s.jobs.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "%v", err)
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "streaming unsupported")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
changes, unsubscribe := j.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
send := func() bool {
|
||||
b, err := json.Marshal(j.Snapshot())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
|
||||
return false
|
||||
}
|
||||
flusher.Flush()
|
||||
return true
|
||||
}
|
||||
if !send() {
|
||||
return
|
||||
}
|
||||
|
||||
// Updates are batched: at most one frame every 250ms while work is busy.
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
keepalive := time.NewTicker(20 * time.Second)
|
||||
defer keepalive.Stop()
|
||||
|
||||
dirty := false
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case _, open := <-changes:
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
dirty = true
|
||||
case <-ticker.C:
|
||||
if !dirty {
|
||||
continue
|
||||
}
|
||||
dirty = false
|
||||
if !send() {
|
||||
return
|
||||
}
|
||||
if j.Snapshot().State.Terminal() {
|
||||
fmt.Fprint(w, "event: done\ndata: {}\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
case <-keepalive.C:
|
||||
fmt.Fprint(w, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
case <-j.Done():
|
||||
// Drain one last snapshot so the client sees the final state.
|
||||
send()
|
||||
fmt.Fprint(w, "event: done\ndata: {}\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
j, err := s.jobs.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "%v", err)
|
||||
return
|
||||
}
|
||||
j.Cancel()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"canceled": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteJob(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.jobs.Delete(r.PathValue("id")); err != nil {
|
||||
writeError(w, http.StatusNotFound, "%v", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// PackageInfo describes one built package on disk.
|
||||
type PackageInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
IsDir bool `json:"isDir"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListPackages(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := os.ReadDir(s.cfg.PackageDir)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "read package directory: %v", err)
|
||||
return
|
||||
}
|
||||
out := []PackageInfo{}
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p := PackageInfo{
|
||||
Name: e.Name(),
|
||||
Path: filepath.Join(s.cfg.PackageDir, e.Name()),
|
||||
IsDir: e.IsDir(),
|
||||
CreatedAt: info.ModTime(),
|
||||
}
|
||||
if e.IsDir() {
|
||||
p.Bytes, _ = dirBytes(p.Path)
|
||||
} else {
|
||||
p.Bytes = info.Size()
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleDownloadPackage streams a package file. Directory packages are not
|
||||
// downloadable as-is; the operator picks the tar format for that.
|
||||
func (s *Server) handleDownloadPackage(w http.ResponseWriter, r *http.Request) {
|
||||
path, err := s.packagePath(r.PathValue("name"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "%v", err)
|
||||
return
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "package not found")
|
||||
return
|
||||
}
|
||||
if info.IsDir() {
|
||||
writeError(w, http.StatusBadRequest,
|
||||
"this package is a directory; copy it from %s, or rebuild with the tar format", path)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
w.Header().Set("Content-Type", "application/x-tar")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(path)))
|
||||
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeletePackage(w http.ResponseWriter, r *http.Request) {
|
||||
path, err := s.packagePath(r.PathValue("name"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "%v", err)
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// packagePath resolves a package name against the package directory, refusing
|
||||
// anything that would escape it.
|
||||
func (s *Server) packagePath(name string) (string, error) {
|
||||
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
|
||||
return "", errors.New("invalid package name")
|
||||
}
|
||||
base, err := filepath.Abs(s.cfg.PackageDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
full := filepath.Join(base, name)
|
||||
if !strings.HasPrefix(full, base+string(os.PathSeparator)) {
|
||||
return "", errors.New("invalid package name")
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// dialTarget opens a short-lived connection for an interactive request.
|
||||
func (s *Server) dialTarget(ctx context.Context, id string) (*sshx.RemoteDocker, func(), error) {
|
||||
cfg, err := s.conns.Get(id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
client, err := sshx.Dial(ctx, cfg, s.hosts)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return sshx.NewRemoteDocker(client), func() { client.Close() }, nil
|
||||
}
|
||||
|
||||
// writeDialError turns an untrusted host key into a structured response the UI
|
||||
// can turn into a trust prompt.
|
||||
func (s *Server) writeDialError(w http.ResponseWriter, err error) {
|
||||
var hk *sshx.HostKeyError
|
||||
if errors.As(err, &hk) {
|
||||
writeJSON(w, http.StatusPreconditionRequired, map[string]any{
|
||||
"error": err.Error(),
|
||||
"hostKey": hk.Fingerprint,
|
||||
"keyType": hk.KeyType,
|
||||
"changed": hk.Changed,
|
||||
"needsTrust": true,
|
||||
"host": hk.Host,
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "%v", err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadGateway, "%v", err)
|
||||
}
|
||||
|
||||
func countIncluded(p spec.Plan) int {
|
||||
n := 0
|
||||
for _, i := range p.Items {
|
||||
if i.Include {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func dirBytes(dir string) (int64, error) {
|
||||
var total int64
|
||||
err := filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
total += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return total, err
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for v := n / unit; v >= unit; v /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
var _ = dkr.RestorePath
|
||||
@@ -0,0 +1,246 @@
|
||||
// Package api exposes the migration tool over HTTP and serves the web UI.
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/arescom/docker-migrate/internal/dkr"
|
||||
"github.com/arescom/docker-migrate/internal/job"
|
||||
"github.com/arescom/docker-migrate/internal/sshx"
|
||||
"github.com/arescom/docker-migrate/internal/store"
|
||||
)
|
||||
|
||||
// Config configures the HTTP server.
|
||||
type Config struct {
|
||||
// Addr is the listen address, e.g. 127.0.0.1:8080.
|
||||
Addr string
|
||||
// Token, when set, must be presented on every API request.
|
||||
Token string
|
||||
// DataDir holds connections and the known-hosts file.
|
||||
DataDir string
|
||||
// PackageDir is where migration packages are written.
|
||||
PackageDir string
|
||||
// DockerHost overrides the source daemon address.
|
||||
DockerHost string
|
||||
// UI is the embedded web app; nil disables the UI.
|
||||
UI fs.FS
|
||||
// Logger receives request and error logs.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Server ties the Docker client, connection store and job manager to HTTP.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
docker *dkr.Client
|
||||
conns *store.Connections
|
||||
hosts *sshx.KnownHosts
|
||||
jobs *job.Manager
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// New builds the server and everything it owns.
|
||||
func New(cfg Config) (*Server, error) {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
if cfg.PackageDir == "" {
|
||||
cfg.PackageDir = filepath.Join(cfg.DataDir, "packages")
|
||||
}
|
||||
if err := os.MkdirAll(cfg.PackageDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create package directory: %w", err)
|
||||
}
|
||||
|
||||
docker, err := dkr.New(cfg.DockerHost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conns, err := store.NewConnections(filepath.Join(cfg.DataDir, "connections.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hosts, err := sshx.NewKnownHosts(filepath.Join(cfg.DataDir, "known_hosts"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
cfg: cfg, log: cfg.Logger, docker: docker,
|
||||
conns: conns, hosts: hosts, jobs: job.NewManager(), mux: http.NewServeMux(),
|
||||
}
|
||||
s.routes()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close releases the Docker connection.
|
||||
func (s *Server) Close() error { return s.docker.Close() }
|
||||
|
||||
// Handler returns the root HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.recoverer(s.logging(s.auth(s.mux)))
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
m := s.mux
|
||||
|
||||
m.HandleFunc("GET /api/health", s.handleHealth)
|
||||
m.HandleFunc("GET /api/source", s.handleSource)
|
||||
m.HandleFunc("GET /api/source/sizes", s.handleSourceSizes)
|
||||
|
||||
m.HandleFunc("GET /api/connections", s.handleListConnections)
|
||||
m.HandleFunc("POST /api/connections", s.handleSaveConnection)
|
||||
m.HandleFunc("DELETE /api/connections/{id}", s.handleDeleteConnection)
|
||||
m.HandleFunc("POST /api/connections/{id}/probe", s.handleProbe)
|
||||
m.HandleFunc("POST /api/connections/{id}/trust", s.handleTrust)
|
||||
m.HandleFunc("POST /api/connections/{id}/test", s.handleTestConnection)
|
||||
m.HandleFunc("GET /api/connections/{id}/inventory", s.handleTargetInventory)
|
||||
|
||||
m.HandleFunc("POST /api/plan/preview", s.handlePreview)
|
||||
m.HandleFunc("POST /api/migrate/ssh", s.handleMigrateSSH)
|
||||
m.HandleFunc("POST /api/migrate/package", s.handleBuildPackage)
|
||||
|
||||
m.HandleFunc("GET /api/jobs", s.handleListJobs)
|
||||
m.HandleFunc("GET /api/jobs/{id}", s.handleGetJob)
|
||||
m.HandleFunc("GET /api/jobs/{id}/events", s.handleJobEvents)
|
||||
m.HandleFunc("POST /api/jobs/{id}/cancel", s.handleCancelJob)
|
||||
m.HandleFunc("DELETE /api/jobs/{id}", s.handleDeleteJob)
|
||||
|
||||
m.HandleFunc("GET /api/packages", s.handleListPackages)
|
||||
m.HandleFunc("GET /api/packages/{name}/download", s.handleDownloadPackage)
|
||||
m.HandleFunc("DELETE /api/packages/{name}", s.handleDeletePackage)
|
||||
|
||||
if s.cfg.UI != nil {
|
||||
m.Handle("/", s.spaHandler())
|
||||
}
|
||||
}
|
||||
|
||||
// auth enforces the shared token on the API. The token may also be passed as a
|
||||
// query parameter, because EventSource cannot set headers and neither can a
|
||||
// download link.
|
||||
//
|
||||
// Static assets are deliberately served without it. A browser opening
|
||||
// /?token=… does not carry the query string over to /assets/app.js, so gating
|
||||
// the shell would leave the UI unable to boot. Nothing sensitive lives in the
|
||||
// bundle; every piece of data is behind /api.
|
||||
func (s *Server) auth(next http.Handler) http.Handler {
|
||||
if s.cfg.Token == "" {
|
||||
return next
|
||||
}
|
||||
want := []byte(s.cfg.Token)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
got := r.Header.Get("X-Auth-Token")
|
||||
if got == "" {
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
got = strings.TrimPrefix(h, "Bearer ")
|
||||
}
|
||||
}
|
||||
if got == "" {
|
||||
got = r.URL.Query().Get("token")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(got), want) != 1 {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or missing token")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(sw, r)
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
s.log.Debug("request", "method", r.Method, "path", r.URL.Path,
|
||||
"status", sw.status, "duration", time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
s.log.Error("panic serving request", "path", r.URL.Path, "panic", rec)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Flush forwards to the wrapped writer so server-sent events keep streaming.
|
||||
func (w *statusWriter) Flush() {
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// spaHandler serves the built web app, falling back to index.html so client
|
||||
// side routing works on a hard refresh.
|
||||
func (s *Server) spaHandler() http.Handler {
|
||||
files := http.FileServer(http.FS(s.cfg.UI))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if p == "" {
|
||||
p = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(s.cfg.UI, p); err != nil {
|
||||
r = r.Clone(r.Context())
|
||||
r.URL.Path = "/"
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
} else if strings.HasPrefix(p, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
// The response is already partially written; nothing useful is left to do.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, format string, args ...any) {
|
||||
writeJSON(w, code, errorBody{Error: fmt.Sprintf(format, args...)})
|
||||
}
|
||||
|
||||
func decode(r *http.Request, v any) error {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 8<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return fmt.Errorf("invalid request body: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user