mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
Merge pull request #13767 from esengine/a5/wsl-link
feat(remote): bring up a kernel in a WSL distribution and reach it over loopback
This commit is contained in:
23 files changed
+1808
-23
No files matched your search
+12
-2
@@ -458,8 +458,18 @@ A WSL distribution on a Windows machine is a host with `kind = "wsl"` and a `dis
|
||||
|
||||
- It has no address, so it takes only the folder and install fields. `host`, `port`, `user`, keys, passwords,
|
||||
`proxy_jump`, `use_ssh_config` and forwards are rejected on it.
|
||||
- Connecting to one is not available yet. An SSH dial of such an entry fails with `remote: host is not reached over
|
||||
ssh` rather than guessing an address.
|
||||
- Studio opens it like any other machine in its list. Connecting needs WSL 2 and a distribution that has `ps`
|
||||
and `nohup`; a minimal image without them is refused with the missing name.
|
||||
- The kernel runs inside a shell session Studio keeps open, because WSL ends everything a session started when it ends.
|
||||
- When Studio closes its link, the kernel goes away with the distribution (WSL stops an idle one after about 15
|
||||
seconds).
|
||||
- Conversations are kept on disk and resume on the next connect.
|
||||
- Nothing keeps a distribution running behind your back.
|
||||
- Model credentials come from the distribution's own configuration (`provider = "remote"`): under WSL's default
|
||||
networking the distribution cannot reach this machine's loopback, so this machine's provider is not shared.
|
||||
- A folder chosen as a Windows path (`C:\work\app` or `\\wsl.localhost\<distro>\home\me\app`) is translated by
|
||||
the distribution; a folder it cannot see is refused. The command-line `reasonix remote connect` still dials
|
||||
SSH only: such an entry fails with `remote: host is not reached over ssh`.
|
||||
|
||||
CLI:
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"reasonix/internal/platform/remote"
|
||||
"reasonix/internal/platform/remote/bootstrap"
|
||||
"reasonix/internal/platform/remote/forward"
|
||||
"reasonix/internal/platform/wsl"
|
||||
)
|
||||
|
||||
// Broker is this machine's provider broker: the loopback address it listens on
|
||||
@@ -51,6 +52,9 @@ type Options struct {
|
||||
// which is the only part of an attach that needs a configured machine, so
|
||||
// substituting it is what lets the rest be exercised against a real one.
|
||||
Dial func(host string, prompts Prompts) (*remote.Client, error)
|
||||
// DialWSL builds a WSL machine's connection. Nil binds the distribution the
|
||||
// entry names through the system wsl.exe.
|
||||
DialWSL func(entry config.RemoteHostEntry) (Link, error)
|
||||
}
|
||||
|
||||
// Call is what one attach decides for itself: where the progress of a first
|
||||
@@ -120,7 +124,7 @@ func (g *gate) wait(ctx context.Context) error {
|
||||
type link struct {
|
||||
gate
|
||||
host string
|
||||
client *remote.Client
|
||||
client Link
|
||||
install string // the host entry's own strategy, read once at dial
|
||||
// provider is where this host's model credentials come from, read at dial
|
||||
// beside install. A host set to resolve its own gets no broker forward.
|
||||
@@ -170,7 +174,7 @@ func (p *Pool) broker(l *link) (string, error) {
|
||||
}
|
||||
|
||||
// boundBroker is the remote address the broker forward is listening on.
|
||||
func boundBroker(client *remote.Client) (string, bool) {
|
||||
func boundBroker(client Link) (string, bool) {
|
||||
for _, e := range client.Forwards().List() {
|
||||
if e.Spec.Name == brokerForwardName && e.Up && e.BoundAddr != "" {
|
||||
return e.BoundAddr, true
|
||||
@@ -181,7 +185,7 @@ func boundBroker(client *remote.Client) (string, bool) {
|
||||
|
||||
// followBroker points every kernel on l at the broker's forward after the link
|
||||
// comes back. The kernels outlive the link; the port they were told may not.
|
||||
func (p *Pool) followBroker(l *link, client *remote.Client) {
|
||||
func (p *Pool) followBroker(l *link, client Link) {
|
||||
if !p.brokers(l) {
|
||||
return
|
||||
}
|
||||
@@ -349,15 +353,20 @@ func (p *Pool) dial(l *link, call Call) (unsubscribe func()) {
|
||||
fail(err)
|
||||
return nil
|
||||
}
|
||||
if entry, ok := cfg.RemoteHost(l.host); ok {
|
||||
entry, configured := cfg.RemoteHost(l.host)
|
||||
if configured {
|
||||
l.install = entry.ServeInstallMode()
|
||||
l.provider = entry.ProviderMode()
|
||||
}
|
||||
build := p.opts.Dial
|
||||
if build == nil {
|
||||
build = func(host string, prompts Prompts) (*remote.Client, error) { return Dial(cfg, host, prompts) }
|
||||
var client Link
|
||||
if configured && entry.HostKind() == config.RemoteKindWSL {
|
||||
// A distribution has no tunnel to publish this machine's broker through,
|
||||
// so it resolves providers from its own configuration.
|
||||
l.provider = config.RemoteProviderRemote
|
||||
client, err = p.openWSL(entry)
|
||||
} else {
|
||||
client, err = p.openSSH(cfg, l.host)
|
||||
}
|
||||
client, err := build(l.host, p.opts.Prompts)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return nil
|
||||
@@ -387,6 +396,25 @@ func (p *Pool) dial(l *link, call Call) (unsubscribe func()) {
|
||||
return unsubscribe
|
||||
}
|
||||
|
||||
func (p *Pool) openSSH(cfg *config.Config, host string) (Link, error) {
|
||||
build := p.opts.Dial
|
||||
if build == nil {
|
||||
build = func(host string, prompts Prompts) (*remote.Client, error) { return Dial(cfg, host, prompts) }
|
||||
}
|
||||
c, err := build(host, p.opts.Prompts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sshLink{c}, nil
|
||||
}
|
||||
|
||||
func (p *Pool) openWSL(entry config.RemoteHostEntry) (Link, error) {
|
||||
if p.opts.DialWSL != nil {
|
||||
return p.opts.DialWSL(entry)
|
||||
}
|
||||
return newWSLLink(p.ctx, entry, wsl.System(), wsl.SystemCommand())
|
||||
}
|
||||
|
||||
func (p *Pool) forgetLink(l *link) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
@@ -430,6 +458,15 @@ func (p *Pool) holdSpace(l *link, workspace string) (*space, bool) {
|
||||
// that reaches it.
|
||||
func (p *Pool) serve(ctx context.Context, l *link, s *space, workspace string, call Call) {
|
||||
defer close(s.ready)
|
||||
if m, ok := l.client.(workspaceMapper); ok {
|
||||
mapped, err := m.Workspace(ctx, workspace)
|
||||
if err != nil {
|
||||
s.err = err
|
||||
p.forgetSpace(l, s)
|
||||
return
|
||||
}
|
||||
workspace = mapped
|
||||
}
|
||||
install := p.opts.Install
|
||||
if install == "" {
|
||||
install = l.install
|
||||
|
||||
@@ -59,21 +59,10 @@ func (p *Pool) Browse(ctx context.Context, host, dir string) (Listing, error) {
|
||||
// Released on a timer, not at return — see browseLinger.
|
||||
defer func() { time.AfterFunc(browseLinger, func() { p.dropLink(l) }) }()
|
||||
|
||||
fsys, err := l.client.SFTP()
|
||||
if err != nil {
|
||||
return Listing{}, err
|
||||
}
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
dir = "~"
|
||||
}
|
||||
// The far machine resolves it: ~ belongs to the account that logged in, and
|
||||
// a symlinked project must be listed under the path it really has, or
|
||||
// stepping up from it would leave the tree it was reached through.
|
||||
at, err := fsys.RealPath(ctx, dir)
|
||||
if err != nil {
|
||||
return Listing{}, err
|
||||
}
|
||||
entries, err := fsys.List(ctx, at)
|
||||
at, entries, err := l.client.Dirs(ctx, dir)
|
||||
if err != nil {
|
||||
return Listing{}, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package attach
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"reasonix/internal/platform/remote"
|
||||
"reasonix/internal/platform/remote/bootstrap"
|
||||
"reasonix/internal/platform/remote/forward"
|
||||
"reasonix/internal/platform/remote/sftpfs"
|
||||
)
|
||||
|
||||
// Link is one machine's connection as the pool uses it: commands and files for
|
||||
// the bootstrap, a way to reach a port that machine listens on, and a state to
|
||||
// report. An SSH client is one, and a WSL distribution is the other.
|
||||
type Link interface {
|
||||
bootstrap.Conn
|
||||
Start(ctx context.Context) error
|
||||
Close() error
|
||||
Subscribe(fn func(remote.StatusEvent)) (cancel func())
|
||||
Forwards() Forwards
|
||||
// Dirs resolves dir (empty or ~ meaning the login home) on that machine and
|
||||
// lists what is inside it, in that machine's own spelling.
|
||||
Dirs(ctx context.Context, dir string) (at string, entries []sftpfs.Entry, err error)
|
||||
}
|
||||
|
||||
// workspaceMapper is a Link whose machine spells folders its own way, so a path
|
||||
// chosen on this one has to be translated before the kernel there is asked to
|
||||
// open it.
|
||||
type workspaceMapper interface {
|
||||
Workspace(ctx context.Context, p string) (string, error)
|
||||
}
|
||||
|
||||
// Forwards is the part of a forward registry the pool drives.
|
||||
type Forwards interface {
|
||||
Add(spec forward.Spec) (string, error)
|
||||
List() []forward.Entry
|
||||
Remove(name string) error
|
||||
}
|
||||
|
||||
// sshLink adapts a supervised SSH client to Link.
|
||||
type sshLink struct{ *remote.Client }
|
||||
|
||||
func (l sshLink) Forwards() Forwards { return l.Client.Forwards() }
|
||||
|
||||
func (l sshLink) Dirs(ctx context.Context, dir string) (string, []sftpfs.Entry, error) {
|
||||
fsys, err := l.SFTP()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
// The far machine resolves it: ~ belongs to the account that logged in, and
|
||||
// a symlinked project must be listed under the path it really has, or
|
||||
// stepping up from it would leave the tree it was reached through.
|
||||
at, err := fsys.RealPath(ctx, dir)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
entries, err := fsys.List(ctx, at)
|
||||
return at, entries, err
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package attach
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/platform/remote"
|
||||
"reasonix/internal/platform/remote/forward"
|
||||
"reasonix/internal/platform/remote/wslconn"
|
||||
"reasonix/internal/platform/wsl"
|
||||
)
|
||||
|
||||
// ErrNoRemoteForward: a WSL distribution has no channel to publish a port of
|
||||
// this machine into, so the one thing that would ask for it must not be sent.
|
||||
var ErrNoRemoteForward = errors.New("attach: a WSL machine cannot be given a port of this one")
|
||||
|
||||
// wslLink is a WSL distribution as a Link. The distribution's loopback is
|
||||
// reachable from here, so a "forward" is the address itself: nothing listens
|
||||
// on this side and nothing has to be torn down.
|
||||
type wslLink struct {
|
||||
*wslconn.Conn
|
||||
host string
|
||||
|
||||
anchorStop context.CancelFunc
|
||||
anchorDone chan struct{}
|
||||
anchorIn io.Writer // guarded by mu; nil while no session is up
|
||||
anchorUp chan struct{}
|
||||
upOnce sync.Once
|
||||
|
||||
mu sync.Mutex
|
||||
subs map[int]func(remote.StatusEvent)
|
||||
next int
|
||||
last remote.StatusEvent
|
||||
have bool
|
||||
}
|
||||
|
||||
func newWSLLink(ctx context.Context, entry config.RemoteHostEntry, list wsl.Runner, run wsl.Command) (Link, error) {
|
||||
conn, err := wslconn.New(ctx, list, run, entry.Distro)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &wslLink{Conn: conn, host: entry.Name, subs: map[int]func(remote.StatusEvent){}, anchorUp: make(chan struct{})}, nil
|
||||
}
|
||||
|
||||
func (l *wslLink) publish(ev remote.StatusEvent) {
|
||||
ev.Host, ev.At = l.host, time.Now()
|
||||
l.mu.Lock()
|
||||
l.last, l.have = ev, true
|
||||
fns := make([]func(remote.StatusEvent), 0, len(l.subs))
|
||||
for _, fn := range l.subs {
|
||||
fns = append(fns, fn)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
for _, fn := range fns {
|
||||
fn(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// requiredTools prints the first program a kernel launch depends on that the
|
||||
// distribution lacks and stops with exitMissingTool. Minimal images omit ps,
|
||||
// which is how the launched process is identified as the kernel.
|
||||
const (
|
||||
requiredTools = `for t in ps nohup; do command -v "$t" >/dev/null 2>&1 || { echo "$t"; exit 46; }; done`
|
||||
exitMissingTool = 46
|
||||
)
|
||||
|
||||
// Workspace spells a folder the way the distribution sees it: a Windows path
|
||||
// is translated by the distribution itself, anything else is already its own.
|
||||
func (l *wslLink) Workspace(ctx context.Context, p string) (string, error) {
|
||||
if !wsl.IsWindowsPath(p) {
|
||||
return p, nil
|
||||
}
|
||||
return l.ToLinux(ctx, p)
|
||||
}
|
||||
|
||||
// Start wakes the distribution: the first command is what boots it, and a
|
||||
// distribution that cannot answer one is reported as the connect failing.
|
||||
func (l *wslLink) Start(ctx context.Context) error {
|
||||
l.publish(remote.StatusEvent{Status: remote.StatusConnecting})
|
||||
res, err := l.Exec(ctx, requiredTools)
|
||||
switch {
|
||||
case err != nil:
|
||||
case res.ExitCode == exitMissingTool:
|
||||
err = fmt.Errorf("%w: %q has no %s, which starting a kernel there needs", wsl.ErrUnsupported, l.Distro(), strings.TrimSpace(string(res.Stdout)))
|
||||
case res.ExitCode != 0:
|
||||
err = fmt.Errorf("%w: %q answered with status %d", wsl.ErrUnsupported, l.Distro(), res.ExitCode)
|
||||
}
|
||||
if err != nil {
|
||||
l.publish(remote.StatusEvent{Status: remote.StatusStopped, Err: err})
|
||||
return err
|
||||
}
|
||||
l.publish(remote.StatusEvent{Status: remote.StatusConnected})
|
||||
anchorCtx, stop := context.WithCancel(context.WithoutCancel(ctx))
|
||||
l.anchorStop, l.anchorDone = stop, make(chan struct{})
|
||||
go l.holdAnchor(anchorCtx)
|
||||
select {
|
||||
case <-l.anchorUp:
|
||||
case <-ctx.Done():
|
||||
stop()
|
||||
<-l.anchorDone
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// anchorRetry is how long a lost anchor waits before the distribution is asked
|
||||
// for another.
|
||||
var anchorRetry = 2 * time.Second
|
||||
|
||||
// holdAnchor keeps one shell session open and feeds it the commands that must
|
||||
// outlive the call that sent them: WSL ends everything a wsl.exe session
|
||||
// started when it ends, setsid and nohup included. A lost session is asked for
|
||||
// again, and the kernel it held is gone.
|
||||
func (l *wslLink) holdAnchor(ctx context.Context) {
|
||||
defer close(l.anchorDone)
|
||||
for attempt := 1; ctx.Err() == nil; attempt++ {
|
||||
pr, pw := io.Pipe()
|
||||
l.mu.Lock()
|
||||
l.anchorIn = pw
|
||||
l.mu.Unlock()
|
||||
l.upOnce.Do(func() { close(l.anchorUp) })
|
||||
sessionDone := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = pw.Close()
|
||||
case <-sessionDone:
|
||||
}
|
||||
}()
|
||||
_, err := l.Run(ctx, pr, "/bin/sh")
|
||||
close(sessionDone)
|
||||
l.mu.Lock()
|
||||
l.anchorIn = nil
|
||||
l.mu.Unlock()
|
||||
_ = pw.Close()
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
err = fmt.Errorf("%w: the session holding %q open ended", wsl.ErrUnsupported, l.Distro())
|
||||
}
|
||||
l.publish(remote.StatusEvent{Status: remote.StatusReconnecting, Attempt: attempt, Err: err})
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(anchorRetry):
|
||||
}
|
||||
l.publish(remote.StatusEvent{Status: remote.StatusConnected, Attempt: attempt})
|
||||
}
|
||||
}
|
||||
|
||||
// ErrNoSession: the session that launches long-lived processes is not up, or
|
||||
// ended before the command it was given finished.
|
||||
var ErrNoSession = errors.New("attach: the WSL session that keeps a kernel alive is not open")
|
||||
|
||||
// ErrUnsafeScratch: the directory launch commands are staged in is not the
|
||||
// current user's private directory, so what is written there could be read or
|
||||
// replaced by someone else.
|
||||
var ErrUnsafeScratch = errors.New("attach: the WSL launch directory is not private to this user")
|
||||
|
||||
// detachedPoll is how often a launched command is asked whether it finished.
|
||||
const detachedPoll = 100 * time.Millisecond
|
||||
|
||||
const (
|
||||
exitUnsafeScratch = 47
|
||||
exitNotYet = 44
|
||||
// scratch is a directory under the account's home that must be a real
|
||||
// directory it owns and nobody else can enter. A path under /tmp would be
|
||||
// accepted by mkdir -p whoever had made it.
|
||||
scratch = `d="$HOME/.reasonix/link"`
|
||||
)
|
||||
|
||||
// ExecDetached runs cmd inside the held session, so what it starts is not
|
||||
// ended when this call returns. The command is written to a file and only its
|
||||
// name is typed into the session, which keeps the script's text out of the
|
||||
// session's input. The wait ends when the command does or when the session it
|
||||
// was given to does, whichever is first.
|
||||
func (l *wslLink) ExecDetached(ctx context.Context, cmd string) (remote.ExecResult, error) {
|
||||
var raw [8]byte
|
||||
_, _ = rand.Read(raw[:])
|
||||
id := hex.EncodeToString(raw[:])
|
||||
put, err := l.Script(ctx, strings.NewReader(cmd), scratch+`
|
||||
mkdir -p -m 700 "$d" || exit 1
|
||||
[ -d "$d" ] && [ ! -L "$d" ] && [ "$(stat -c %u "$d")" = "$(id -u)" ] && [ "$(stat -c %a "$d")" = 700 ] || exit 47
|
||||
cat > "$d/$1.sh"`, id)
|
||||
if err != nil {
|
||||
return remote.ExecResult{}, err
|
||||
}
|
||||
switch put.ExitCode {
|
||||
case 0:
|
||||
case exitUnsafeScratch:
|
||||
return remote.ExecResult{}, ErrUnsafeScratch
|
||||
default:
|
||||
return remote.ExecResult{}, fmt.Errorf("attach: stage the launch command: exit %d: %s", put.ExitCode, strings.TrimSpace(string(put.Stderr)))
|
||||
}
|
||||
in := l.session()
|
||||
if in == nil {
|
||||
return remote.ExecResult{}, ErrNoSession
|
||||
}
|
||||
line := fmt.Sprintf(scratch+`; /bin/sh "$d/%[1]s.sh" </dev/null >"$d/%[1]s.out" 2>"$d/%[1]s.err"; echo $? >"$d/%[1]s.rc"`+"\n", id)
|
||||
wrote := make(chan error, 1)
|
||||
go func() { _, err := io.WriteString(in, line); wrote <- err }()
|
||||
select {
|
||||
case err := <-wrote:
|
||||
if err != nil {
|
||||
return remote.ExecResult{}, fmt.Errorf("%w: %w", ErrNoSession, err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return remote.ExecResult{}, ctx.Err()
|
||||
}
|
||||
for {
|
||||
res, err := l.Script(ctx, nil, scratch+`; [ -s "$d/$1.rc" ] || exit 44`, id)
|
||||
if err != nil {
|
||||
return remote.ExecResult{}, err
|
||||
}
|
||||
if res.ExitCode != exitNotYet {
|
||||
break
|
||||
}
|
||||
if l.session() != in {
|
||||
return remote.ExecResult{}, ErrNoSession
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return remote.ExecResult{}, ctx.Err()
|
||||
case <-time.After(detachedPoll):
|
||||
}
|
||||
}
|
||||
return l.collect(ctx, id)
|
||||
}
|
||||
|
||||
// session is the input of the shell session currently held, nil when none is.
|
||||
func (l *wslLink) session() io.Writer {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.anchorIn
|
||||
}
|
||||
|
||||
// collect reads what a finished launch printed and how it ended, then removes
|
||||
// its files. The status is read from its own file, so no exit code of the
|
||||
// command can be mistaken for the poll's "not yet".
|
||||
func (l *wslLink) collect(ctx context.Context, id string) (remote.ExecResult, error) {
|
||||
read := func(name string) ([]byte, error) {
|
||||
res, err := l.Script(ctx, nil, scratch+`; cat "$d/$1.`+name+`"`, id)
|
||||
return res.Stdout, err
|
||||
}
|
||||
out, err := read("out")
|
||||
if err != nil {
|
||||
return remote.ExecResult{}, err
|
||||
}
|
||||
errb, err := read("err")
|
||||
if err != nil {
|
||||
return remote.ExecResult{}, err
|
||||
}
|
||||
rc, err := read("rc")
|
||||
if err != nil {
|
||||
return remote.ExecResult{}, err
|
||||
}
|
||||
_, _ = l.Script(ctx, nil, scratch+`; rm -f "$d/$1.sh" "$d/$1.out" "$d/$1.err" "$d/$1.rc"`, id)
|
||||
code, err := strconv.Atoi(strings.TrimSpace(string(rc)))
|
||||
if err != nil {
|
||||
return remote.ExecResult{}, fmt.Errorf("attach: the launch left no readable status: %w", err)
|
||||
}
|
||||
return remote.ExecResult{Stdout: out, Stderr: errb, ExitCode: code}, nil
|
||||
}
|
||||
|
||||
func (l *wslLink) Close() error {
|
||||
if l.anchorStop != nil {
|
||||
l.anchorStop()
|
||||
<-l.anchorDone
|
||||
}
|
||||
l.publish(remote.StatusEvent{Status: remote.StatusStopped})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *wslLink) Subscribe(fn func(remote.StatusEvent)) func() {
|
||||
l.mu.Lock()
|
||||
id := l.next
|
||||
l.next++
|
||||
l.subs[id] = fn
|
||||
last, have := l.last, l.have
|
||||
l.mu.Unlock()
|
||||
if have {
|
||||
fn(last)
|
||||
}
|
||||
return func() {
|
||||
l.mu.Lock()
|
||||
delete(l.subs, id)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *wslLink) Forwards() Forwards { return loopbackForwards{} }
|
||||
|
||||
// loopbackForwards reaches a port on the distribution's loopback directly.
|
||||
type loopbackForwards struct{}
|
||||
|
||||
func (loopbackForwards) Add(spec forward.Spec) (string, error) {
|
||||
if spec.Direction != forward.Local {
|
||||
return "", ErrNoRemoteForward
|
||||
}
|
||||
return spec.TargetAddr, nil
|
||||
}
|
||||
|
||||
func (loopbackForwards) List() []forward.Entry { return nil }
|
||||
|
||||
func (loopbackForwards) Remove(string) error { return nil }
|
||||
@@ -0,0 +1,73 @@
|
||||
//go:build windows
|
||||
|
||||
package attach
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/platform/wsl"
|
||||
)
|
||||
|
||||
// REASONIX_LIVE_WSL names an installed WSL2 distribution that already has a
|
||||
// reasonix 2.x on its PATH (install "never"); the test brings up a kernel in it
|
||||
// through the pool and calls it from Windows over loopback.
|
||||
func TestLiveAttachBringsUpAKernelInADistribution(t *testing.T) {
|
||||
name := os.Getenv("REASONIX_LIVE_WSL")
|
||||
if name == "" {
|
||||
t.Skip("set REASONIX_LIVE_WSL to a WSL2 distribution with reasonix installed")
|
||||
}
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
|
||||
return nil, c.UpsertRemoteHost(config.RemoteHostEntry{Name: "dev", Kind: "wsl", Distro: name, ServeInstall: "never"})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := wsl.SystemCommand()(t.Context(), nil, wsl.ExecArgs(name, "/bin/mkdir", "-p", "/tmp/reasonix-live-workspace")...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := NewPool(t.Context(), Options{Install: "never"})
|
||||
defer p.Close()
|
||||
ep, err := p.Attach(t.Context(), "dev", "/tmp/reasonix-live-workspace", Call{Progress: func(step, detail string) { t.Logf("step %s %s", step, detail) }})
|
||||
if err != nil {
|
||||
t.Fatalf("Attach: %v", err)
|
||||
}
|
||||
t.Logf("kernel at %s workspace %s", ep.Addr, ep.Workspace)
|
||||
|
||||
req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+ep.Addr+"/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+ep.Token)
|
||||
var resp *http.Response
|
||||
for range 20 {
|
||||
if resp, err = http.DefaultClient.Do(req); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("the kernel did not answer over loopback: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
t.Logf("GET / = %d", resp.StatusCode)
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode >= 500 {
|
||||
t.Fatalf("the kernel refused the token it was started with: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// A second pane on the same workspace shares the one kernel.
|
||||
again, err := p.Attach(t.Context(), "dev", "/tmp/reasonix-live-workspace", Call{})
|
||||
if err != nil || again.Addr != ep.Addr {
|
||||
t.Fatalf("second attach = %+v, %v; want the same address %s", again, err, ep.Addr)
|
||||
}
|
||||
again.Release()
|
||||
|
||||
// The same folder named the Windows way reaches the same kernel.
|
||||
unc, err := p.Attach(t.Context(), "dev", `\\wsl.localhost\`+name+`\tmp\reasonix-live-workspace`, Call{})
|
||||
if err != nil || unc.Addr != ep.Addr || unc.Workspace != ep.Workspace {
|
||||
t.Fatalf("UNC attach = %+v, %v; want the kernel at %s for %s", unc, err, ep.Addr, ep.Workspace)
|
||||
}
|
||||
unc.Release()
|
||||
ep.Release()
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//go:build linux
|
||||
|
||||
package attach
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/platform/remote"
|
||||
"reasonix/internal/platform/wsl"
|
||||
)
|
||||
|
||||
// localSession answers wsl.exe's --exec by running the program here with HOME
|
||||
// pointing at a directory the test owns, so the staging scripts and the session
|
||||
// shell meet a real GNU userland.
|
||||
func localSession(home string) wsl.Command {
|
||||
return func(ctx context.Context, stdin io.Reader, args ...string) (wsl.Result, error) {
|
||||
for i, a := range args {
|
||||
if a == "--exec" {
|
||||
args = args[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
cmd.Env = append(os.Environ(), "HOME="+home)
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = stdin, &out, &errb
|
||||
err := cmd.Run()
|
||||
res := wsl.Result{Stdout: out.Bytes(), Stderr: errb.Bytes()}
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
res.ExitCode, err = ee.ExitCode(), nil
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
|
||||
func localLink(t *testing.T, home string) Link {
|
||||
t.Helper()
|
||||
if err := exec.Command("stat", "-c", "%a", "/").Run(); err != nil {
|
||||
t.Skip("needs GNU stat")
|
||||
}
|
||||
m := &wslMachine{t: t}
|
||||
l, err := newWSLLink(t.Context(), config.RemoteHostEntry{Name: "dev", Kind: "wsl", Distro: "Ubuntu"}, m.list, localSession(home))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func detach(l Link) func(context.Context, string) (remote.ExecResult, error) {
|
||||
return l.(interface {
|
||||
ExecDetached(context.Context, string) (remote.ExecResult, error)
|
||||
}).ExecDetached
|
||||
}
|
||||
|
||||
func TestALaunchRunsInARealSessionAndKeepsItsOwnStatus(t *testing.T) {
|
||||
l := localLink(t, t.TempDir())
|
||||
if err := l.Start(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
res, err := detach(l)(t.Context(), "echo out; echo err >&2; exit 44")
|
||||
if err != nil || string(res.Stdout) != "out\n" || string(res.Stderr) != "err\n" || res.ExitCode != 44 {
|
||||
t.Fatalf("ExecDetached = %+v, %v", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
// mkdir -p succeeds on a directory someone else prepared, so the staging
|
||||
// directory is checked after it is made and never trusted for existing.
|
||||
func TestAScratchDirectoryOthersCouldUseIsRefused(t *testing.T) {
|
||||
for name, prepare := range map[string]func(t *testing.T, dir string){
|
||||
"group and world accessible": func(t *testing.T, dir string) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
"world writable": func(t *testing.T, dir string) {
|
||||
if err := os.MkdirAll(dir, 0o777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
"a symlink to elsewhere": func(t *testing.T, dir string) {
|
||||
if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
elsewhere := t.TempDir()
|
||||
if err := os.Chmod(elsewhere, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(elsewhere, dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
prepare(t, filepath.Join(home, ".reasonix", "link"))
|
||||
l := localLink(t, home)
|
||||
if err := l.Start(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
if _, err := detach(l)(t.Context(), "echo hi"); !errors.Is(err, ErrUnsafeScratch) {
|
||||
t.Fatalf("err = %v, want ErrUnsafeScratch", err)
|
||||
}
|
||||
entries, _ := os.ReadDir(filepath.Join(home, ".reasonix", "link"))
|
||||
for _, e := range entries {
|
||||
t.Errorf("a command was staged in the unsafe directory: %s", e.Name())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package attach
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/platform/remote"
|
||||
"reasonix/internal/platform/remote/forward"
|
||||
"reasonix/internal/platform/wsl"
|
||||
)
|
||||
|
||||
// wslMachine is a fake wsl.exe: it lists one distribution and records the
|
||||
// commands it is asked to run.
|
||||
type wslMachine struct {
|
||||
t *testing.T
|
||||
version int
|
||||
mu sync.Mutex
|
||||
calls [][]string
|
||||
// anchor decides how the long-lived session behaves; nil blocks until cancelled.
|
||||
anchor func(ctx context.Context, stdin io.Reader) (wsl.Result, error)
|
||||
// script answers every other command; nil answers success with no output.
|
||||
script func(args []string, stdin io.Reader) (wsl.Result, error)
|
||||
// typed collects what the session was sent.
|
||||
typed strings.Builder
|
||||
typedMu sync.Mutex
|
||||
// started gets one value each time the long-lived session is launched.
|
||||
started chan struct{}
|
||||
}
|
||||
|
||||
func (m *wslMachine) list(context.Context, ...string) ([]byte, error) {
|
||||
v := m.version
|
||||
if v == 0 {
|
||||
v = 2
|
||||
}
|
||||
return []byte(" NAME STATE VERSION\n* Ubuntu Running " + string(rune('0'+v)) + "\n"), nil
|
||||
}
|
||||
|
||||
func (m *wslMachine) run(ctx context.Context, stdin io.Reader, args ...string) (wsl.Result, error) {
|
||||
m.mu.Lock()
|
||||
m.calls = append(m.calls, slices.Clone(args))
|
||||
m.mu.Unlock()
|
||||
if slices.Equal(args, []string{"-d", "Ubuntu", "--exec", "/bin/sh"}) {
|
||||
if m.started != nil {
|
||||
m.started <- struct{}{}
|
||||
}
|
||||
if m.anchor != nil {
|
||||
return m.anchor(ctx, stdin)
|
||||
}
|
||||
return m.session(ctx, stdin)
|
||||
}
|
||||
if m.script != nil {
|
||||
return m.script(args, stdin)
|
||||
}
|
||||
return wsl.Result{}, nil
|
||||
}
|
||||
|
||||
// session is a shell that reads its commands until it is cancelled or its
|
||||
// input closes, the way /bin/sh does.
|
||||
func (m *wslMachine) session(ctx context.Context, stdin io.Reader) (wsl.Result, error) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := stdin.Read(buf)
|
||||
m.typedMu.Lock()
|
||||
m.typed.Write(buf[:n])
|
||||
m.typedMu.Unlock()
|
||||
if err != nil {
|
||||
close(done)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return wsl.Result{}, ctx.Err()
|
||||
case <-done:
|
||||
return wsl.Result{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *wslMachine) link(entry config.RemoteHostEntry) (Link, error) {
|
||||
return newWSLLink(m.t.Context(), entry, m.list, m.run)
|
||||
}
|
||||
|
||||
func wslEntry() config.RemoteHostEntry {
|
||||
return config.RemoteHostEntry{Name: "dev", Kind: "wsl", Distro: "Ubuntu"}
|
||||
}
|
||||
|
||||
func TestAWSLLinkIsBoundToAnInstalledWSL2Distribution(t *testing.T) {
|
||||
m := &wslMachine{t: t}
|
||||
if _, err := m.link(config.RemoteHostEntry{Name: "x", Kind: "wsl", Distro: "Nope"}); !errors.Is(err, wsl.ErrNoDistro) {
|
||||
t.Fatalf("unknown distribution = %v", err)
|
||||
}
|
||||
m.version = 1
|
||||
if _, err := m.link(wslEntry()); !errors.Is(err, wsl.ErrUnsupported) {
|
||||
t.Fatalf("WSL1 = %v, want ErrUnsupported", err)
|
||||
}
|
||||
m.version = 2
|
||||
if _, err := m.link(wslEntry()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartingAWSLLinkHoldsOneSessionOpenUntilClose(t *testing.T) {
|
||||
m := &wslMachine{t: t, started: make(chan struct{}, 4)}
|
||||
l, err := m.link(wslEntry())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var seen []remote.Status
|
||||
l.Subscribe(func(ev remote.StatusEvent) { mu.Lock(); seen = append(seen, ev.Status); mu.Unlock() })
|
||||
if err := l.Start(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-m.started
|
||||
if err := l.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !slices.Equal(seen, []remote.Status{remote.StatusConnecting, remote.StatusConnected, remote.StatusStopped}) {
|
||||
t.Fatalf("states = %v", seen)
|
||||
}
|
||||
var anchors int
|
||||
for _, c := range m.calls {
|
||||
if slices.Equal(c, []string{"-d", "Ubuntu", "--exec", "/bin/sh"}) {
|
||||
anchors++
|
||||
}
|
||||
}
|
||||
if anchors != 1 {
|
||||
t.Fatalf("%d sessions were held open, want 1", anchors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALostSessionIsReportedAndAskedForAgain(t *testing.T) {
|
||||
defer func(d time.Duration) { anchorRetry = d }(anchorRetry)
|
||||
anchorRetry = time.Millisecond
|
||||
var starts int
|
||||
var mu sync.Mutex
|
||||
m := &wslMachine{t: t, started: make(chan struct{}, 4), anchor: func(ctx context.Context, _ io.Reader) (wsl.Result, error) {
|
||||
mu.Lock()
|
||||
starts++
|
||||
n := starts
|
||||
mu.Unlock()
|
||||
if n == 1 {
|
||||
return wsl.Result{}, nil
|
||||
}
|
||||
<-ctx.Done()
|
||||
return wsl.Result{}, ctx.Err()
|
||||
}}
|
||||
l, _ := m.link(wslEntry())
|
||||
events := make(chan remote.StatusEvent, 16)
|
||||
l.Subscribe(func(ev remote.StatusEvent) { events <- ev })
|
||||
if err := l.Start(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.After(5 * time.Second)
|
||||
var sawLost bool
|
||||
for !sawLost {
|
||||
select {
|
||||
case ev := <-events:
|
||||
sawLost = ev.Status == remote.StatusReconnecting && ev.Err != nil && ev.Attempt == 1
|
||||
case <-deadline:
|
||||
t.Fatal("a session that ended was not reported")
|
||||
}
|
||||
}
|
||||
<-m.started
|
||||
<-m.started
|
||||
_ = l.Close()
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if starts != 2 {
|
||||
t.Fatalf("the session was started %d times, want 2", starts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWSLLinkReachesTheKernelDirectlyAndRefusesAnInboundPort(t *testing.T) {
|
||||
f := loopbackForwards{}
|
||||
addr, err := f.Add(forward.Spec{Direction: forward.Local, BindAddr: "127.0.0.1:0", TargetAddr: "127.0.0.1:4242"})
|
||||
if err != nil || addr != "127.0.0.1:4242" {
|
||||
t.Fatalf("Add local = %q, %v", addr, err)
|
||||
}
|
||||
if _, err := f.Add(forward.Spec{Direction: forward.Remote, BindAddr: "127.0.0.1:0", TargetAddr: "127.0.0.1:1"}); !errors.Is(err, ErrNoRemoteForward) {
|
||||
t.Fatalf("Add remote = %v, want ErrNoRemoteForward", err)
|
||||
}
|
||||
if len(f.List()) != 0 || f.Remove("x") != nil {
|
||||
t.Fatal("a direct route has nothing to list or remove")
|
||||
}
|
||||
}
|
||||
|
||||
// A WSL entry resolves providers on the distribution: a broker configured on
|
||||
// this pool must not be published to it.
|
||||
func TestAPoolOpensAWSLEntryThroughTheWSLLinkAndSendsNoBroker(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
|
||||
return nil, c.UpsertRemoteHost(wslEntry())
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &wslMachine{t: t}
|
||||
var opened int
|
||||
p := NewPool(t.Context(), Options{
|
||||
Broker: Broker{Addr: "127.0.0.1:9", Token: "t"},
|
||||
Dial: func(string, Prompts) (*remote.Client, error) {
|
||||
t.Error("an SSH dial was made for a WSL entry")
|
||||
return nil, errors.New("no")
|
||||
},
|
||||
DialWSL: func(e config.RemoteHostEntry) (Link, error) { opened++; return m.link(e) },
|
||||
})
|
||||
defer p.Close()
|
||||
l, dialer := p.holdLink("dev")
|
||||
if !dialer {
|
||||
t.Fatal("first hold must dial")
|
||||
}
|
||||
p.dial(l, Call{})
|
||||
if err := l.wait(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opened != 1 || p.brokers(l) {
|
||||
t.Fatalf("opened = %d, brokers = %v", opened, p.brokers(l))
|
||||
}
|
||||
}
|
||||
|
||||
// What a launch starts must belong to the held session, not to the call that
|
||||
// sent it: the command is staged in a file, only its name is typed into the
|
||||
// session, and the call returns what the command printed and its own status.
|
||||
func TestADetachedCommandRunsInTheHeldSessionAndReturnsItsOutput(t *testing.T) {
|
||||
var staged string
|
||||
var polls int
|
||||
m := &wslMachine{t: t}
|
||||
m.script = func(args []string, stdin io.Reader) (wsl.Result, error) {
|
||||
body := args[5]
|
||||
switch {
|
||||
case strings.Contains(body, `cat > "$d/$1.sh"`):
|
||||
b, _ := io.ReadAll(stdin)
|
||||
staged = string(b)
|
||||
case strings.Contains(body, `exit 44`):
|
||||
polls++
|
||||
if polls < 3 {
|
||||
return wsl.Result{ExitCode: 44}, nil
|
||||
}
|
||||
case strings.HasSuffix(body, `.out"`):
|
||||
return wsl.Result{Stdout: []byte("12345\n")}, nil
|
||||
case strings.HasSuffix(body, `.err"`):
|
||||
return wsl.Result{Stdout: []byte("warned\n")}, nil
|
||||
case strings.HasSuffix(body, `.rc"`):
|
||||
return wsl.Result{Stdout: []byte("44\n")}, nil
|
||||
}
|
||||
return wsl.Result{}, nil
|
||||
}
|
||||
l, _ := m.link(wslEntry())
|
||||
d := l.(interface {
|
||||
ExecDetached(context.Context, string) (remote.ExecResult, error)
|
||||
})
|
||||
if _, err := d.ExecDetached(t.Context(), "x"); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("before the session is up: %v, want ErrNoSession", err)
|
||||
}
|
||||
if err := l.Start(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
res, err := d.ExecDetached(t.Context(), "nohup serve & echo $!")
|
||||
if err != nil || string(res.Stdout) != "12345\n" || string(res.Stderr) != "warned\n" || res.ExitCode != 44 {
|
||||
t.Fatalf("ExecDetached = %+v, %v; a command's own status 44 must not read as 'not yet'", res, err)
|
||||
}
|
||||
if staged != "nohup serve & echo $!" || polls != 3 {
|
||||
t.Fatalf("staged = %q, polls = %d", staged, polls)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
m.typedMu.Lock()
|
||||
typed := m.typed.String()
|
||||
m.typedMu.Unlock()
|
||||
if strings.Contains(typed, `$HOME/.reasonix/link`) && strings.HasSuffix(typed, "\n") && !strings.Contains(typed, "nohup") && !strings.Contains(typed, "/tmp") {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the session was sent %q, want a line naming the private staged file and none of its text", typed)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// The wait for a launched command is bounded by the session it was given to:
|
||||
// one that dies after the write leaves nothing that will ever finish.
|
||||
func TestALaunchWhoseSessionDiesAfterTheWriteStopsWaiting(t *testing.T) {
|
||||
m := &wslMachine{t: t}
|
||||
m.anchor = func(ctx context.Context, stdin io.Reader) (wsl.Result, error) {
|
||||
buf := make([]byte, 4096)
|
||||
_, _ = stdin.Read(buf)
|
||||
return wsl.Result{}, nil
|
||||
}
|
||||
m.script = func(args []string, _ io.Reader) (wsl.Result, error) {
|
||||
if strings.Contains(args[5], `exit 44`) {
|
||||
return wsl.Result{ExitCode: 44}, nil
|
||||
}
|
||||
return wsl.Result{}, nil
|
||||
}
|
||||
l, _ := m.link(wslEntry())
|
||||
if err := l.Start(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
_, err := l.(interface {
|
||||
ExecDetached(context.Context, string) (remote.ExecResult, error)
|
||||
}).ExecDetached(ctx, "serve")
|
||||
if !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("ExecDetached = %v, want ErrNoSession (not a wait until the deadline)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAScratchDirectoryThatIsNotPrivateIsRefusedWithATypedError(t *testing.T) {
|
||||
m := &wslMachine{t: t, script: func(args []string, _ io.Reader) (wsl.Result, error) {
|
||||
if strings.Contains(args[5], `cat > "$d/$1.sh"`) {
|
||||
return wsl.Result{ExitCode: exitUnsafeScratch}, nil
|
||||
}
|
||||
return wsl.Result{}, nil
|
||||
}}
|
||||
l, _ := m.link(wslEntry())
|
||||
if err := l.Start(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
_, err := l.(interface {
|
||||
ExecDetached(context.Context, string) (remote.ExecResult, error)
|
||||
}).ExecDetached(t.Context(), "serve")
|
||||
if !errors.Is(err, ErrUnsafeScratch) {
|
||||
t.Fatalf("ExecDetached = %v, want ErrUnsafeScratch", err)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,14 @@ type Conn interface {
|
||||
Files() (sftpfs.Files, error)
|
||||
}
|
||||
|
||||
// Detacher is a connection whose commands end every process they started when
|
||||
// they return. Such a machine launches the kernel through ExecDetached, which
|
||||
// starts it under something that outlives the call, so the kernel lives as long
|
||||
// as that does.
|
||||
type Detacher interface {
|
||||
ExecDetached(ctx context.Context, cmd string) (remote.ExecResult, error)
|
||||
}
|
||||
|
||||
// Install strategies.
|
||||
const (
|
||||
InstallAuto = "auto"
|
||||
@@ -208,7 +216,11 @@ func launchServe(ctx context.Context, conn Conn, target remoteOS, fs sftpfs.File
|
||||
_ = fs.Remove(cleanup, scriptPath, false)
|
||||
}()
|
||||
}
|
||||
launchRes, err := conn.Exec(ctx, target.Launch(spec, paths))
|
||||
launch := conn.Exec
|
||||
if d, ok := conn.(Detacher); ok {
|
||||
launch = d.ExecDetached
|
||||
}
|
||||
launchRes, err := launch(ctx, target.Launch(spec, paths))
|
||||
if err != nil {
|
||||
cleanupFailedLaunch(conn, target, fs, paths, 0)
|
||||
return Result{}, fmt.Errorf("bootstrap: launch: %w", err)
|
||||
|
||||
@@ -742,3 +742,52 @@ func TestRepointBrokerRewritesOnlyAServeThatFollowsItsFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// detachConn is a machine that ends what a command started when the command
|
||||
// returns: the launch must go through ExecDetached, never plain Exec.
|
||||
type detachConn struct {
|
||||
*fakeConn
|
||||
t *testing.T
|
||||
detached []string
|
||||
}
|
||||
|
||||
func (d *detachConn) Exec(ctx context.Context, cmd string) (remote.ExecResult, error) {
|
||||
if strings.Contains(cmd, "nohup") {
|
||||
d.t.Errorf("the launch went through Exec: %s", cmd)
|
||||
}
|
||||
return d.fakeConn.Exec(ctx, cmd)
|
||||
}
|
||||
|
||||
func (d *detachConn) ExecDetached(ctx context.Context, cmd string) (remote.ExecResult, error) {
|
||||
d.detached = append(d.detached, cmd)
|
||||
return d.fakeConn.Exec(ctx, cmd)
|
||||
}
|
||||
|
||||
func TestEnsureServeLaunchesThroughTheDetachedPathWhenTheMachineAsksForIt(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
root := testenv.TempDir(t)
|
||||
var portFile string
|
||||
inner := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
|
||||
switch {
|
||||
case strings.Contains(cmd, "uname"):
|
||||
return ok("Linux x86_64\n")
|
||||
case strings.Contains(cmd, "command -v reasonix"):
|
||||
return ok("bin /usr/bin/reasonix\nver reasonix v9.9.0\n" + allFlagsYes())
|
||||
case strings.Contains(cmd, "nohup"):
|
||||
_ = os.WriteFile(portFile, []byte("127.0.0.1:44321\n"), 0o600)
|
||||
return ok("54321\n")
|
||||
case strings.Contains(cmd, "ps -p 54321"):
|
||||
return ok("1\n")
|
||||
}
|
||||
return ok("")
|
||||
})
|
||||
portFile = pathsFor(root, root).PortFile
|
||||
conn := &detachConn{fakeConn: inner, t: t}
|
||||
res, err := EnsureServe(context.Background(), conn, Options{Workspace: "~", MinVersion: "1.0.0", Clock: time.Now})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureServe: %v", err)
|
||||
}
|
||||
if res.State.PID != 54321 || len(conn.detached) != 1 || !strings.Contains(conn.detached[0], "nohup") {
|
||||
t.Fatalf("pid = %d, detached = %v", res.State.PID, conn.detached)
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,17 @@ func (c *envConn) Exec(ctx context.Context, cmd string) (remote.ExecResult, erro
|
||||
return c.Conn.Exec(ctx, "export PATH="+shellQuote(strings.Join(c.env.Path, ":"))+`:"$PATH"; `+cmd)
|
||||
}
|
||||
|
||||
// ExecDetached keeps the captured PATH on a launch and leaves it to the wrapped
|
||||
// connection how the launched process is kept alive; one with nothing special
|
||||
// to do runs it as any other command.
|
||||
func (c *envConn) ExecDetached(ctx context.Context, cmd string) (remote.ExecResult, error) {
|
||||
cmd = "export PATH=" + shellQuote(strings.Join(c.env.Path, ":")) + `:"$PATH"; ` + cmd
|
||||
if d, ok := c.Conn.(Detacher); ok {
|
||||
return d.ExecDetached(ctx, cmd)
|
||||
}
|
||||
return c.Conn.Exec(ctx, cmd)
|
||||
}
|
||||
|
||||
// withLoginEnv wraps conn only when the capture produced a PATH.
|
||||
func withLoginEnv(env loginEnv, conn Conn) Conn {
|
||||
if env.Outcome != EnvCaptured {
|
||||
|
||||
@@ -341,3 +341,39 @@ func TestEachEntryPointCapturesOnceOnTheRawConnection(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type detachingConn struct {
|
||||
scriptConn
|
||||
detached []string
|
||||
}
|
||||
|
||||
func (c *detachingConn) ExecDetached(_ context.Context, cmd string) (remote.ExecResult, error) {
|
||||
c.detached = append(c.detached, cmd)
|
||||
return remote.ExecResult{}, nil
|
||||
}
|
||||
|
||||
// A machine that needs launches kept alive by something outside the call must
|
||||
// still get them with the login PATH in front, and one that does not runs a
|
||||
// launch as a plain command.
|
||||
func TestAWrappedConnectionStillLaunchesThroughTheDetachedPath(t *testing.T) {
|
||||
env := loginEnv{Outcome: EnvCaptured, Path: []string{"/opt/bin"}}
|
||||
inner := &detachingConn{scriptConn: scriptConn{fn: func(string) (remote.ExecResult, error) { return remote.ExecResult{}, nil }}}
|
||||
d, ok := withLoginEnv(env, inner).(Detacher)
|
||||
if !ok {
|
||||
t.Fatal("the wrapper hides the detached path")
|
||||
}
|
||||
if _, err := d.ExecDetached(context.Background(), "serve"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inner.detached) != 1 || !strings.HasPrefix(inner.detached[0], "export PATH='/opt/bin':") || !strings.HasSuffix(inner.detached[0], "serve") || len(inner.execs) != 0 {
|
||||
t.Fatalf("detached = %q, execs = %q", inner.detached, inner.execs)
|
||||
}
|
||||
|
||||
plain := &scriptConn{fn: func(string) (remote.ExecResult, error) { return remote.ExecResult{}, nil }}
|
||||
if _, err := withLoginEnv(env, plain).(Detacher).ExecDetached(context.Background(), "serve"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plain.execs) != 1 || !strings.HasSuffix(plain.execs[0], "serve") {
|
||||
t.Fatalf("a plain machine did not get the launch as a command: %q", plain.execs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package wslconn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/platform/remote"
|
||||
"reasonix/internal/platform/remote/sftpfs"
|
||||
"reasonix/internal/platform/wsl"
|
||||
)
|
||||
|
||||
// Conn is one distribution as a bootstrap connection.
|
||||
type Conn struct {
|
||||
distro string
|
||||
run wsl.Command
|
||||
}
|
||||
|
||||
// New binds a connection to an installed distribution. The name must be one
|
||||
// wsl.exe lists, which keeps anything else out of its argument list.
|
||||
func New(ctx context.Context, list wsl.Runner, run wsl.Command, distro string) (*Conn, error) {
|
||||
all, err := wsl.List(ctx, list)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, d := range all {
|
||||
if d.Name == distro {
|
||||
if d.Version != 2 {
|
||||
return nil, fmt.Errorf("%w: %q is WSL%d", wsl.ErrUnsupported, distro, d.Version)
|
||||
}
|
||||
return &Conn{distro: distro, run: run}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %q", wsl.ErrNoDistro, distro)
|
||||
}
|
||||
|
||||
// Distro is the distribution's name.
|
||||
func (c *Conn) Distro() string { return c.distro }
|
||||
|
||||
// Exec runs cmd under /bin/sh in the distribution, as an SSH exec would run it
|
||||
// under the account's shell.
|
||||
func (c *Conn) Exec(ctx context.Context, cmd string) (remote.ExecResult, error) {
|
||||
res, err := c.run(ctx, nil, wsl.ExecArgs(c.distro, "/bin/sh", "-c", cmd)...)
|
||||
return remote.ExecResult{Stdout: res.Stdout, Stderr: res.Stderr, ExitCode: res.ExitCode}, err
|
||||
}
|
||||
|
||||
// Files is the distribution's file layer.
|
||||
func (c *Conn) Files() (sftpfs.Files, error) { return &files{c: c}, nil }
|
||||
|
||||
// Script runs body under /bin/sh with args as $1... and stdin.
|
||||
func (c *Conn) Script(ctx context.Context, stdin io.Reader, body string, args ...string) (wsl.Result, error) {
|
||||
return c.script(ctx, stdin, body, args...)
|
||||
}
|
||||
|
||||
// Run starts a wsl.exe session that lives until ctx ends, feeding stdin to the
|
||||
// program argv names, and returns when it ends.
|
||||
func (c *Conn) Run(ctx context.Context, stdin io.Reader, argv ...string) (wsl.Result, error) {
|
||||
return c.run(ctx, stdin, wsl.ExecArgs(c.distro, argv...)...)
|
||||
}
|
||||
|
||||
// script runs body with args as $1... and stdin, returning its result.
|
||||
func (c *Conn) script(ctx context.Context, stdin io.Reader, body string, args ...string) (wsl.Result, error) {
|
||||
argv := append([]string{"/bin/sh", "-c", body, "sh"}, args...)
|
||||
return c.run(ctx, stdin, wsl.ExecArgs(c.distro, argv...)...)
|
||||
}
|
||||
|
||||
// Exit statuses of the file scripts. Anything else non-zero is a failure
|
||||
// whose text is the tool's, kept for the log and never matched.
|
||||
const (
|
||||
exitMissing = 44
|
||||
exitIsDir = 45
|
||||
)
|
||||
|
||||
func failure(op string, res wsl.Result) error {
|
||||
switch res.ExitCode {
|
||||
case 0:
|
||||
return nil
|
||||
case exitMissing:
|
||||
return fmt.Errorf("%s: %w", op, fs.ErrNotExist)
|
||||
case exitIsDir:
|
||||
return fmt.Errorf("%s: is a directory", op)
|
||||
}
|
||||
return fmt.Errorf("%s: exit %d: %s", op, res.ExitCode, strings.TrimSpace(string(res.Stderr)))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package wslconn
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileModeReadsTheRawBits(t *testing.T) {
|
||||
for raw, want := range map[uint32]fs.FileMode{
|
||||
0o100644: 0o644,
|
||||
0o040755: fs.ModeDir | 0o755,
|
||||
0o120777: fs.ModeSymlink | 0o777,
|
||||
0o104755: fs.ModeSetuid | 0o755,
|
||||
0o041777: fs.ModeDir | fs.ModeSticky | 0o777,
|
||||
} {
|
||||
if got := fileMode(raw); got != want {
|
||||
t.Errorf("fileMode(%o) = %v, want %v", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Package wslconn lets the kernel bootstrap treat a WSL distribution as a
|
||||
// machine it can run commands on and keep files in, the way it does an SSH host.
|
||||
// Commands go through `wsl.exe --exec /bin/sh -c`, and file operations are
|
||||
// small fixed POSIX scripts that take every path as a positional argument, so
|
||||
// no path is ever spliced into script text. A missing path is reported by an
|
||||
// exit status this package defines, never by reading a tool's message.
|
||||
package wslconn
|
||||
@@ -0,0 +1,195 @@
|
||||
package wslconn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/platform/remote/sftpfs"
|
||||
)
|
||||
|
||||
type files struct{ c *Conn }
|
||||
|
||||
var _ sftpfs.Files = (*files)(nil)
|
||||
|
||||
const (
|
||||
statScript = `[ -e "$1" ] || exit 44
|
||||
exec stat -L -c '%s %f %Y' -- "$1"`
|
||||
|
||||
readScript = `[ -e "$1" ] || exit 44
|
||||
[ -d "$1" ] && exit 45
|
||||
exec head -c "$2" -- "$1"`
|
||||
|
||||
writeScript = `t="$(dirname -- "$1")/.$(basename -- "$1").reasonix-tmp-$$"
|
||||
{ cat > "$t" && chmod "$2" "$t" && mv -f -- "$t" "$1"; } || { rm -f -- "$t"; exit 1; }`
|
||||
|
||||
mkdirAllScript = `exec mkdir -p -- "$1"`
|
||||
mkdirScript = `exec mkdir -- "$1"`
|
||||
chmodScript = `[ -e "$1" ] || exit 44
|
||||
exec chmod "$2" -- "$1"`
|
||||
|
||||
removeScript = `[ -e "$1" ] || [ -L "$1" ] || exit 44
|
||||
if [ -d "$1" ] && [ ! -L "$1" ]; then
|
||||
if [ "$2" = 1 ]; then exec rm -rf -- "$1"; else exec rmdir -- "$1"; fi
|
||||
fi
|
||||
exec rm -f -- "$1"`
|
||||
|
||||
realPathScript = `case "$1" in
|
||||
"~") p=$HOME ;;
|
||||
"~/"*) p=$HOME/${1#"~/"} ;;
|
||||
*) p=$1 ;;
|
||||
esac
|
||||
exec readlink -f -- "$p"`
|
||||
)
|
||||
|
||||
func (f *files) Stat(ctx context.Context, p string) (sftpfs.Entry, error) {
|
||||
res, err := f.c.script(ctx, nil, statScript, p)
|
||||
if err != nil {
|
||||
return sftpfs.Entry{}, err
|
||||
}
|
||||
if err := failure("stat "+p, res); err != nil {
|
||||
return sftpfs.Entry{}, err
|
||||
}
|
||||
var size, mtime int64
|
||||
var rawMode uint64
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(string(res.Stdout)), "%d %x %d", &size, &rawMode, &mtime); err != nil {
|
||||
return sftpfs.Entry{}, fmt.Errorf("stat %s: unreadable answer: %w", p, err)
|
||||
}
|
||||
mode := fileMode(uint32(rawMode))
|
||||
return sftpfs.Entry{Name: path.Base(p), Path: p, Size: size, Mode: mode, ModTime: mtime, IsDir: mode.IsDir()}, nil
|
||||
}
|
||||
|
||||
// fileMode decodes a raw st_mode by its bit fields: the format bits say what
|
||||
// the file is, the low twelve are the permissions.
|
||||
func fileMode(raw uint32) fs.FileMode {
|
||||
mode := fs.FileMode(raw & 0o777)
|
||||
if raw&0o4000 != 0 {
|
||||
mode |= fs.ModeSetuid
|
||||
}
|
||||
if raw&0o2000 != 0 {
|
||||
mode |= fs.ModeSetgid
|
||||
}
|
||||
if raw&0o1000 != 0 {
|
||||
mode |= fs.ModeSticky
|
||||
}
|
||||
switch raw & 0o170000 {
|
||||
case 0o040000:
|
||||
mode |= fs.ModeDir
|
||||
case 0o120000:
|
||||
mode |= fs.ModeSymlink
|
||||
case 0o010000:
|
||||
mode |= fs.ModeNamedPipe
|
||||
case 0o140000:
|
||||
mode |= fs.ModeSocket
|
||||
case 0o020000:
|
||||
mode |= fs.ModeDevice | fs.ModeCharDevice
|
||||
case 0o060000:
|
||||
mode |= fs.ModeDevice
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
func (f *files) ReadFile(ctx context.Context, p string, maxSize int64) ([]byte, bool, sftpfs.Kind, error) {
|
||||
if maxSize <= 0 {
|
||||
maxSize = sftpfs.DefaultReadCap
|
||||
}
|
||||
res, err := f.c.script(ctx, nil, readScript, p, strconv.FormatInt(maxSize+1, 10))
|
||||
if err != nil {
|
||||
return nil, false, sftpfs.KindBinary, err
|
||||
}
|
||||
if err := failure("read "+p, res); err != nil {
|
||||
return nil, false, sftpfs.KindBinary, err
|
||||
}
|
||||
data, truncated := res.Stdout, false
|
||||
if int64(len(data)) > maxSize {
|
||||
data, truncated = data[:maxSize], true
|
||||
}
|
||||
return data, truncated, sftpfs.DetectKind(data), nil
|
||||
}
|
||||
|
||||
func (f *files) WriteFileAtomic(ctx context.Context, p string, data []byte, perm fs.FileMode) error {
|
||||
res, err := f.c.script(ctx, bytes.NewReader(data), writeScript, p, strconv.FormatUint(uint64(perm.Perm()), 8))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return failure("write "+p, res)
|
||||
}
|
||||
|
||||
func (f *files) MkdirAll(ctx context.Context, p string) error {
|
||||
return f.simple(ctx, "mkdir "+p, mkdirAllScript, p)
|
||||
}
|
||||
|
||||
func (f *files) MkdirExclusive(ctx context.Context, p string) error {
|
||||
return f.simple(ctx, "mkdir "+p, mkdirScript, p)
|
||||
}
|
||||
|
||||
func (f *files) Chmod(ctx context.Context, p string, perm fs.FileMode) error {
|
||||
return f.simple(ctx, "chmod "+p, chmodScript, p, strconv.FormatUint(uint64(perm.Perm()), 8))
|
||||
}
|
||||
|
||||
func (f *files) Remove(ctx context.Context, p string, recursive bool) error {
|
||||
flag := "0"
|
||||
if recursive {
|
||||
flag = "1"
|
||||
}
|
||||
return f.simple(ctx, "remove "+p, removeScript, p, flag)
|
||||
}
|
||||
|
||||
func (f *files) RealPath(ctx context.Context, p string) (string, error) {
|
||||
res, err := f.c.script(ctx, nil, realPathScript, p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := failure("realpath "+p, res); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(string(res.Stdout), "\n"), nil
|
||||
}
|
||||
|
||||
func (f *files) simple(ctx context.Context, op, body string, args ...string) error {
|
||||
res, err := f.c.script(ctx, nil, body, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return failure(op, res)
|
||||
}
|
||||
|
||||
// dirsScript prints the directories under $1 (symlinks to directories
|
||||
// included), each name closed by a NUL so no file name can end it early.
|
||||
const dirsScript = `cd -- "$1" 2>/dev/null || exit 44
|
||||
for f in ./* ./.[!.]* ./..?*; do
|
||||
[ -d "$f" ] && printf '%s\0' "${f#./}"
|
||||
done
|
||||
exit 0`
|
||||
|
||||
// Dirs resolves dir (empty or ~ meaning the home directory) and lists the
|
||||
// directories inside it.
|
||||
func (c *Conn) Dirs(ctx context.Context, dir string) (string, []sftpfs.Entry, error) {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
dir = "~"
|
||||
}
|
||||
fsys := &files{c: c}
|
||||
at, err := fsys.RealPath(ctx, dir)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
res, err := c.script(ctx, nil, dirsScript, at)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := failure("list "+at, res); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var out []sftpfs.Entry
|
||||
for name := range strings.SplitSeq(string(res.Stdout), "\x00") {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, sftpfs.Entry{Name: name, Path: path.Join(at, name), IsDir: true, Mode: fs.ModeDir})
|
||||
}
|
||||
return at, out, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//go:build windows
|
||||
|
||||
package wslconn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/platform/wsl"
|
||||
)
|
||||
|
||||
// REASONIX_LIVE_WSL names an installed WSL2 distribution to run the file layer in.
|
||||
func TestFileLayerInARealDistribution(t *testing.T) {
|
||||
name := os.Getenv("REASONIX_LIVE_WSL")
|
||||
if name == "" {
|
||||
t.Skip("set REASONIX_LIVE_WSL to an installed WSL2 distribution")
|
||||
}
|
||||
ctx := context.Background()
|
||||
c, err := New(ctx, wsl.System(), wsl.SystemCommand(), name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := c.Exec(ctx, "mktemp -d")
|
||||
if err != nil || res.ExitCode != 0 {
|
||||
t.Fatalf("mktemp = %+v, %v", res, err)
|
||||
}
|
||||
dir := string(res.Stdout[:len(res.Stdout)-1])
|
||||
defer c.Exec(ctx, "rm -rf '"+dir+"'")
|
||||
runFileScenario(t, c, dir)
|
||||
runPathScenario(t, c)
|
||||
}
|
||||
|
||||
// runPathScenario holds the translation to what a real distribution answers.
|
||||
func runPathScenario(t *testing.T, c *Conn) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
for in, want := range map[string]string{
|
||||
`C:\Users\someone\a b`: "/mnt/c/Users/someone/a b",
|
||||
`c:/Users/x`: "/mnt/c/Users/x",
|
||||
`C:\a b\it's "q"\-dash\back`: "/mnt/c/a b/it's \"q\"/-dash/back",
|
||||
`C:\a b\`: "/mnt/c/a b/",
|
||||
`C:\dollar $HOME\` + "`z`" + `\ünï`: "/mnt/c/dollar $HOME/`z`/ünï",
|
||||
`\\wsl.localhost\` + c.Distro() + `\home\dev`: "/home/dev",
|
||||
} {
|
||||
if got, err := c.ToLinux(ctx, in); err != nil || got != want {
|
||||
t.Errorf("ToLinux(%q) = %q, %v; want %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{`\\wsl.localhost\Another\x`, `\\server\share\x`, `Z:\nodrive`, `relative\dir`, `/home/dev`, `C:`, ``} {
|
||||
if _, err := c.ToLinux(ctx, in); !errors.Is(err, ErrUntranslatable) {
|
||||
t.Errorf("ToLinux(%q) = %v, want ErrUntranslatable", in, err)
|
||||
}
|
||||
}
|
||||
if got, err := c.ToWindows(ctx, "/mnt/c/Users"); err != nil || got != `C:\Users` {
|
||||
t.Errorf("ToWindows(/mnt/c/Users) = %q, %v", got, err)
|
||||
}
|
||||
if got, err := c.ToWindows(ctx, "/home/dev"); err != nil || got != `\\wsl.localhost\`+c.Distro()+`\home\dev` {
|
||||
t.Errorf("ToWindows(/home/dev) = %q, %v", got, err)
|
||||
}
|
||||
for _, in := range []string{"relative", "", `C:\x`} {
|
||||
if _, err := c.ToWindows(ctx, in); !errors.Is(err, ErrUntranslatable) {
|
||||
t.Errorf("ToWindows(%q) = %v, want ErrUntranslatable", in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//go:build linux
|
||||
|
||||
package wslconn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/platform/wsl"
|
||||
)
|
||||
|
||||
// localCommand answers wsl.exe's --exec by running the program here, so the
|
||||
// scripts meet a real GNU userland without a distribution.
|
||||
func localCommand(t *testing.T) wsl.Command {
|
||||
t.Helper()
|
||||
return func(ctx context.Context, stdin io.Reader, args ...string) (wsl.Result, error) {
|
||||
for i, a := range args {
|
||||
if a == "--exec" {
|
||||
args = args[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = stdin, &out, &errb
|
||||
err := cmd.Run()
|
||||
res := wsl.Result{Stdout: out.Bytes(), Stderr: errb.Bytes()}
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
res.ExitCode, err = ee.ExitCode(), nil
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileLayerAgainstALocalPOSIXShell(t *testing.T) {
|
||||
if err := exec.Command("stat", "-c", "%s", "/").Run(); err != nil {
|
||||
t.Skip("needs GNU stat")
|
||||
}
|
||||
c := &Conn{distro: "local", run: localCommand(t)}
|
||||
runFileScenario(t, c, t.TempDir())
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package wslconn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/platform/wsl"
|
||||
)
|
||||
|
||||
// ErrUntranslatable: the path has no spelling on the other side, such as a
|
||||
// drive that is not mounted or another distribution's share.
|
||||
var ErrUntranslatable = errors.New("wslconn: path cannot be translated")
|
||||
|
||||
// ToLinux turns an absolute Windows path, a drive path or this distribution's
|
||||
// own \\wsl.localhost share, into the path the distribution sees. The
|
||||
// distribution's wslpath does it, because only it knows where drives are mounted.
|
||||
func (c *Conn) ToLinux(ctx context.Context, p string) (string, error) {
|
||||
if !wsl.IsWindowsPath(p) {
|
||||
return "", fmt.Errorf("%w: %q is not an absolute Windows path", ErrUntranslatable, p)
|
||||
}
|
||||
out, err := c.wslpath(ctx, "-u", p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.HasPrefix(out, "/") {
|
||||
return "", fmt.Errorf("%w: %q has no absolute form in %q", ErrUntranslatable, p, c.distro)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ToWindows is the reverse: an absolute Linux path as Windows spells it, a
|
||||
// drive path where it is under a mount and this distribution's share otherwise.
|
||||
func (c *Conn) ToWindows(ctx context.Context, p string) (string, error) {
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
return "", fmt.Errorf("%w: %q is not an absolute Linux path", ErrUntranslatable, p)
|
||||
}
|
||||
out, err := c.wslpath(ctx, "-w", p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !wsl.IsWindowsPath(out) {
|
||||
return "", fmt.Errorf("%w: %q has no Windows form", ErrUntranslatable, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Conn) wslpath(ctx context.Context, flag, p string) (string, error) {
|
||||
res, err := c.Run(ctx, nil, "wslpath", flag, p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res.ExitCode != 0 {
|
||||
return "", fmt.Errorf("%w: %q", ErrUntranslatable, p)
|
||||
}
|
||||
return strings.TrimRight(string(res.Stdout), "\r\n"), nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package wslconn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/platform/wsl"
|
||||
)
|
||||
|
||||
// answers is a distribution whose wslpath says what the table says.
|
||||
func answers(table map[string]wsl.Result) *Conn {
|
||||
return &Conn{distro: "Ubuntu", run: func(_ context.Context, _ io.Reader, args ...string) (wsl.Result, error) {
|
||||
// -d Ubuntu --exec wslpath <flag> <path>
|
||||
return table[args[5]], nil
|
||||
}}
|
||||
}
|
||||
|
||||
func TestATranslationIsTheDistributionsAnswerOrARefusal(t *testing.T) {
|
||||
c := answers(map[string]wsl.Result{
|
||||
`C:\work\app`: {Stdout: []byte("/mnt/c/work/app\n")},
|
||||
`Z:\none`: {ExitCode: 1},
|
||||
`C:\odd`: {Stdout: []byte("C:\\odd\n")},
|
||||
"/mnt/c/work": {Stdout: []byte("C:\\work\r\n")},
|
||||
"/home/dev": {Stdout: []byte("\\\\wsl.localhost\\Ubuntu\\home\\dev\n")},
|
||||
"/odd": {Stdout: []byte("/odd\n")},
|
||||
})
|
||||
ctx := context.Background()
|
||||
if got, err := c.ToLinux(ctx, `C:\work\app`); err != nil || got != "/mnt/c/work/app" {
|
||||
t.Fatalf("ToLinux = %q, %v", got, err)
|
||||
}
|
||||
for _, in := range []string{`Z:\none`, `C:\odd`, `/home/dev`, `rel`, ``} {
|
||||
if _, err := c.ToLinux(ctx, in); !errors.Is(err, ErrUntranslatable) {
|
||||
t.Errorf("ToLinux(%q) = %v, want ErrUntranslatable", in, err)
|
||||
}
|
||||
}
|
||||
if got, err := c.ToWindows(ctx, "/mnt/c/work"); err != nil || got != `C:\work` {
|
||||
t.Fatalf("ToWindows = %q, %v", got, err)
|
||||
}
|
||||
if got, err := c.ToWindows(ctx, "/home/dev"); err != nil || got != `\\wsl.localhost\Ubuntu\home\dev` {
|
||||
t.Fatalf("ToWindows share = %q, %v", got, err)
|
||||
}
|
||||
for _, in := range []string{"/odd", "rel", `C:\x`} {
|
||||
if _, err := c.ToWindows(ctx, in); !errors.Is(err, ErrUntranslatable) {
|
||||
t.Errorf("ToWindows(%q) = %v, want ErrUntranslatable", in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//go:build linux || windows
|
||||
|
||||
package wslconn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/platform/remote/sftpfs"
|
||||
)
|
||||
|
||||
// runFileScenario drives the file layer against a real POSIX shell: the local
|
||||
// one in CI, a distribution in the live test. dir is a scratch directory there.
|
||||
func runFileScenario(t *testing.T, c *Conn, dir string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
fsys, _ := c.Files()
|
||||
awkward := path.Join(dir, `a b "q" 'x' $HOME `+"`z`"+` ünï back\slash trailing\ -dash`)
|
||||
|
||||
if err := fsys.MkdirAll(ctx, awkward+"/deep/er"); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := fsys.MkdirExclusive(ctx, awkward+"/deep"); err == nil {
|
||||
t.Fatal("MkdirExclusive on an existing directory succeeded")
|
||||
}
|
||||
if err := fsys.MkdirExclusive(ctx, awkward+"/lock"); err != nil {
|
||||
t.Fatalf("MkdirExclusive: %v", err)
|
||||
}
|
||||
|
||||
if err := fsys.MkdirAll(ctx, awkward+"/-dash"); err != nil {
|
||||
t.Fatalf("MkdirAll of a component that starts with a dash: %v", err)
|
||||
}
|
||||
file := awkward + "/-dash/state.json"
|
||||
want := []byte("{\"port\": 4242}\n")
|
||||
if err := fsys.WriteFileAtomic(ctx, file, want, 0o600); err != nil {
|
||||
t.Fatalf("WriteFileAtomic: %v", err)
|
||||
}
|
||||
if err := fsys.WriteFileAtomic(ctx, file, append(want, 'x'), 0o600); err != nil {
|
||||
t.Fatalf("overwrite: %v", err)
|
||||
}
|
||||
got, truncated, kind, err := fsys.ReadFile(ctx, file, 0)
|
||||
if err != nil || truncated || kind != sftpfs.KindText || !bytes.Equal(got, append(want, 'x')) {
|
||||
t.Fatalf("ReadFile = %q, %v, %v, %v", got, truncated, kind, err)
|
||||
}
|
||||
if head, truncated, _, err := fsys.ReadFile(ctx, file, 4); err != nil || !truncated || string(head) != "{\"po" {
|
||||
t.Fatalf("a capped read = %q, %v, %v", head, truncated, err)
|
||||
}
|
||||
en, err := fsys.Stat(ctx, file)
|
||||
if err != nil || en.IsDir || en.Size != int64(len(want)+1) || en.Mode.Perm() != 0o600 || en.ModTime == 0 {
|
||||
t.Fatalf("Stat file = %+v, %v", en, err)
|
||||
}
|
||||
if en, err := fsys.Stat(ctx, awkward); err != nil || !en.IsDir {
|
||||
t.Fatalf("Stat dir = %+v, %v", en, err)
|
||||
}
|
||||
if _, _, _, err := fsys.ReadFile(ctx, awkward+"/deep", 0); err == nil || errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("reading a directory = %v, want a failure that is not 'missing'", err)
|
||||
}
|
||||
|
||||
if err := fsys.Chmod(ctx, file, 0o640); err != nil {
|
||||
t.Fatalf("Chmod: %v", err)
|
||||
}
|
||||
if en, _ := fsys.Stat(ctx, file); en.Mode.Perm() != 0o640 {
|
||||
t.Fatalf("mode after chmod = %v", en.Mode)
|
||||
}
|
||||
|
||||
for name, call := range map[string]func() error{
|
||||
"stat": func() error { _, err := fsys.Stat(ctx, awkward+"/none"); return err },
|
||||
"read": func() error { _, _, _, err := fsys.ReadFile(ctx, awkward+"/none", 0); return err },
|
||||
"chmod": func() error { return fsys.Chmod(ctx, awkward+"/none", 0o600) },
|
||||
"remove": func() error { return fsys.Remove(ctx, awkward+"/none", false) },
|
||||
} {
|
||||
if err := call(); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("%s of a missing path = %v, want fs.ErrNotExist", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
real, err := fsys.RealPath(ctx, awkward+"/deep/../-dash/state.json")
|
||||
if err != nil || !strings.HasSuffix(real, "/-dash/state.json") || strings.Contains(real, "..") {
|
||||
t.Fatalf("RealPath = %q, %v", real, err)
|
||||
}
|
||||
if home, err := fsys.RealPath(ctx, "~"); err != nil || !strings.HasPrefix(home, "/") {
|
||||
t.Fatalf("RealPath(~) = %q, %v", home, err)
|
||||
}
|
||||
|
||||
if err := fsys.Remove(ctx, awkward+"/deep", false); err == nil {
|
||||
t.Fatal("a non-recursive remove of a non-empty directory succeeded")
|
||||
}
|
||||
if err := fsys.Remove(ctx, file, false); err != nil {
|
||||
t.Fatalf("Remove file: %v", err)
|
||||
}
|
||||
if err := fsys.Remove(ctx, awkward, true); err != nil {
|
||||
t.Fatalf("Remove tree: %v", err)
|
||||
}
|
||||
if _, err := fsys.Stat(ctx, awkward); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("the removed tree is still there: %v", err)
|
||||
}
|
||||
|
||||
res, err := c.Exec(ctx, "printf '%s' \"$((6*7))\"; echo err >&2; exit 3")
|
||||
if err != nil || string(res.Stdout) != "42" || strings.TrimSpace(string(res.Stderr)) != "err" || res.ExitCode != 3 {
|
||||
t.Fatalf("Exec = %+v, %v", res, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package wsl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/proc"
|
||||
)
|
||||
|
||||
// maxCapture bounds what one command may hand back on each stream. A file the
|
||||
// caller wants whole is read in pieces by the layer above, not by raising it.
|
||||
const maxCapture = 8 << 20
|
||||
|
||||
// Result is what a command in a distribution wrote and how it ended. A command
|
||||
// that ran and failed is a Result with a non-zero ExitCode, not an error: the
|
||||
// error return is for a command that could not be run at all.
|
||||
type Result struct {
|
||||
Stdout, Stderr []byte
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
// Command runs wsl.exe with args, feeding stdin when it is not nil.
|
||||
type Command func(ctx context.Context, stdin io.Reader, args ...string) (Result, error)
|
||||
|
||||
// SystemCommand runs the wsl.exe in System32, so a PATH entry cannot stand in for it.
|
||||
func SystemCommand() Command {
|
||||
return func(ctx context.Context, stdin io.Reader, args ...string) (Result, error) {
|
||||
dir := systemDirectory()
|
||||
if runtime.GOOS != "windows" || dir == "" {
|
||||
return Result{}, ErrUnavailable
|
||||
}
|
||||
exe := filepath.Join(dir, "wsl.exe")
|
||||
if _, err := os.Stat(exe); err != nil {
|
||||
return Result{}, fmt.Errorf("%w: %w", ErrUnavailable, err)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, exe, args...)
|
||||
proc.HideWindow(cmd)
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdin = stdin
|
||||
cmd.Stdout = &limited{w: &out, left: maxCapture}
|
||||
cmd.Stderr = &limited{w: &errb, left: maxCapture}
|
||||
err := cmd.Run()
|
||||
res := Result{Stdout: out.Bytes(), Stderr: errb.Bytes()}
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
res.ExitCode = ee.ExitCode()
|
||||
return res, nil
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
|
||||
// ExecArgs is the wsl.exe argument list that runs argv inside a distribution
|
||||
// with no shell between wsl.exe and the program. The distribution name is its
|
||||
// own argument, so it cannot become an option.
|
||||
func ExecArgs(distro string, argv ...string) []string {
|
||||
return append([]string{"-d", distro, "--exec"}, argv...)
|
||||
}
|
||||
|
||||
// IsWindowsPath reports whether p is spelled as an absolute Windows path: a
|
||||
// drive letter and a colon, or a UNC prefix. It reads the shape only, so a
|
||||
// Linux path with a colon in a later component is not mistaken for one.
|
||||
func IsWindowsPath(p string) bool {
|
||||
if strings.HasPrefix(p, `\\`) {
|
||||
return true
|
||||
}
|
||||
if len(p) < 2 || p[1] != ':' {
|
||||
return false
|
||||
}
|
||||
c := p[0] | 0x20
|
||||
return c >= 'a' && c <= 'z'
|
||||
}
|
||||
@@ -116,3 +116,14 @@ func TestSystemIsUnavailableOffWindows(t *testing.T) {
|
||||
t.Fatalf("List = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowsPathIsRecognizedByItsShape(t *testing.T) {
|
||||
for p, want := range map[string]bool{
|
||||
`C:\Users`: true, `c:/x`: true, `Z:`: true, `\\wsl.localhost\Ubuntu\x`: true, `\\server\share`: true,
|
||||
`/mnt/c/x`: false, `/home/a:b`: false, `relative\dir`: false, `1:\x`: false, ``: false, `C`: false, `\x`: false,
|
||||
} {
|
||||
if got := IsWindowsPath(p); got != want {
|
||||
t.Errorf("IsWindowsPath(%q) = %v, want %v", p, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user