Add SSH source support with dialstdio and UI components

- Implement SSH dial via stdio for remote connections
- Add sources API and storage layer for managing connection sources
- Add SourcePanel and SshFields web components for SSH configuration
- Update app structure to support source-based connections
- Update handlers and server for new sources endpoint

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 10:19:47 +02:00
co-authored by Claude Haiku 4.5
parent 05db8bfeb9
commit 9b354636bb
19 changed files with 1857 additions and 174 deletions
+76 -14
View File
@@ -21,23 +21,39 @@ import (
)
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
ctx, cancel := context.WithTimeout(r.Context(), 45*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 {
// Health is also what connects to the selected source on a fresh start, so
// the UI learns straight away when the remembered source is unreachable.
conn, release, err := s.source(ctx)
if err != nil {
selected, _ := s.sources.Get(s.sources.Selected())
body["ok"] = false
body["dockerError"] = err.Error()
} else {
body["dockerVersion"] = v
endpoint := sourceEndpoint(selected)
body["dockerHost"] = endpoint
body["source"] = sourceStatus{
ID: selected.ID, Name: selected.Name, Kind: selected.Kind,
Endpoint: endpoint, Error: err.Error(),
}
writeJSON(w, http.StatusOK, body)
return
}
defer release()
st := conn.status()
body["dockerHost"] = st.Endpoint
body["dockerVersion"] = st.DockerVersion
body["source"] = st
writeJSON(w, http.StatusOK, body)
}
@@ -47,7 +63,14 @@ 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)
conn, release, err := s.source(ctx)
if err != nil {
s.writeDialError(w, err)
return
}
defer release()
inv, err := conn.docker.Inventory(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
@@ -69,7 +92,14 @@ 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)
conn, release, err := s.source(ctx)
if err != nil {
s.writeDialError(w, err)
return
}
defer release()
sizes, err := conn.docker.VolumeSizes(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
@@ -209,7 +239,14 @@ func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
inv, err := s.docker.Inventory(ctx)
conn, release, err := s.source(ctx)
if err != nil {
s.writeDialError(w, err)
return
}
defer release()
inv, err := conn.docker.Inventory(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
@@ -299,12 +336,23 @@ func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
return
}
// The source is held for the whole job: picking another source in the UI
// while this runs must not close the socket it is reading from.
srcCtx, srcCancel := context.WithTimeout(r.Context(), 60*time.Second)
src, release, err := s.source(srcCtx)
srcCancel()
if err != nil {
s.writeDialError(w, 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)
inv, err := src.docker.Inventory(invCtx)
cancel()
if err != nil {
release()
writeError(w, http.StatusBadGateway, "%v", err)
return
}
@@ -315,13 +363,14 @@ func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
client, err := sshx.Dial(dialCtx, cfg, s.hosts)
dialCancel()
if err != nil {
release()
s.writeDialError(w, err)
return
}
title := fmt.Sprintf("%d container(s) to %s", countIncluded(req.Plan), cfg.Name)
title := fmt.Sprintf("%d container(s) from %s to %s", countIncluded(req.Plan), src.src.Name, cfg.Name)
runner := &migrate.SSHRunner{
Src: s.docker,
Src: src.docker,
Dst: sshx.NewRemoteDocker(client),
Containers: inv.Containers,
Volumes: inv.Volumes,
@@ -331,8 +380,10 @@ func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
j := s.jobs.Run(context.Background(), job.KindSSH, title, req.Plan.Options.DryRun,
func(ctx context.Context, j *job.Job) error {
defer release()
defer client.Close()
j.Logf(job.LevelInfo, "", "migrating to %s@%s over ssh", cfg.User, cfg.Host)
j.Logf(job.LevelInfo, "", "migrating from %s to %s@%s over ssh",
src.docker.Endpoint, cfg.User, cfg.Host)
return runner.Run(ctx, j)
})
@@ -362,16 +413,25 @@ func (s *Server) handleBuildPackage(w http.ResponseWriter, r *http.Request) {
return
}
srcCtx, srcCancel := context.WithTimeout(r.Context(), 60*time.Second)
src, release, err := s.source(srcCtx)
srcCancel()
if err != nil {
s.writeDialError(w, err)
return
}
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
inv, err := s.docker.Inventory(invCtx)
inv, err := src.docker.Inventory(invCtx)
cancel()
if err != nil {
release()
writeError(w, http.StatusBadGateway, "%v", err)
return
}
packager := &migrate.Packager{
Src: s.docker,
Src: src.docker,
Containers: inv.Containers,
Volumes: inv.Volumes,
Networks: inv.Networks,
@@ -384,6 +444,8 @@ func (s *Server) handleBuildPackage(w http.ResponseWriter, r *http.Request) {
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 {
defer release()
j.Logf(job.LevelInfo, "", "reading from %s", src.docker.Endpoint)
res, err := packager.Run(ctx, j)
if err != nil {
return err