| 1 | // Package local implements legatus.Conn/legatus.Dialer on the local machine, so |
| 2 | // workstation-local steps run through the same Flow/Runner model as remote ones. |
| 3 | // Exec runs via os/exec; Put/Get use local file IO with atomic writes. No remote |
| 4 | // shell is involved, so arguments are passed structurally (os/exec argv) with no |
| 5 | // quoting; sudo wrapping mirrors the SSH transport's `sudo -- [env KEY=val …]` |
| 6 | // shape so a step behaves the same whether it targets a remote host or this one. |
| 7 | package local |
| 8 | |
| 9 | import ( |
| 10 | "bytes" |
| 11 | "context" |
| 12 | "errors" |
| 13 | "io" |
| 14 | "os" |
| 15 | "os/exec" |
| 16 | "path/filepath" |
| 17 | "sort" |
| 18 | "strconv" |
| 19 | "time" |
| 20 | |
| 21 | "go.bigb.es/auxilia/culpa" |
| 22 | "go.bigb.es/auxilia/legatus" |
| 23 | ) |
| 24 | |
| 25 | // cancelWaitDelay bounds how long Run waits for I/O after a ctx-cancel kill. A |
| 26 | // killed shell can leave a child (e.g. `sleep`) holding the stdout pipe open, so |
| 27 | // without this Run would block until that child exits; the delay force-closes the |
| 28 | // pipes so a streamed command (logs -f on Ctrl-C) returns promptly. |
| 29 | const cancelWaitDelay = 2 * time.Second |
| 30 | |
| 31 | // Conn is a legatus.Conn that operates on the local machine. |
| 32 | type Conn struct{} |
| 33 | |
| 34 | var _ legatus.Conn = (*Conn)(nil) |
| 35 | |
| 36 | // NewConn returns a local Conn. |
| 37 | func NewConn() *Conn { return &Conn{} } |
| 38 | |
| 39 | // Exec runs cmd locally. A process that runs to completion is not an error even |
| 40 | // on a non-zero exit: the status is reported via ExecResult.ExitCode and err is |
| 41 | // nil. err is reserved for failure to launch (binary not found, IO error) or |
| 42 | // context cancellation — matching the SSH transport's Conn.Exec contract so the |
| 43 | // engine cannot tell a local Conn from a remote one. |
| 44 | func (c *Conn) Exec(ctx context.Context, cmd legatus.Cmd) (legatus.ExecResult, error) { |
| 45 | return c.ExecStream(ctx, cmd, nil, nil) |
| 46 | } |
| 47 | |
| 48 | // ExecStream runs cmd, streaming each non-nil writer live and buffering each nil |
| 49 | // writer into the returned ExecResult (the buffered contract Exec relies on). The |
| 50 | // exit-code and launch-error contract is identical to Exec. |
| 51 | func (c *Conn) ExecStream(ctx context.Context, cmd legatus.Cmd, stdout, stderr io.Writer) (legatus.ExecResult, error) { |
| 52 | if cmd.Path == "" { |
| 53 | return legatus.ExecResult{}, culpa.WithCode(culpa.New("local: empty command path"), "LOCAL_EXEC") |
| 54 | } |
| 55 | |
| 56 | name, args := cmd.Path, cmd.Args |
| 57 | var env []string |
| 58 | if cmd.Sudo { |
| 59 | argv := sudoArgv(cmd) |
| 60 | name, args = argv[0], argv[1:] |
| 61 | } else if len(cmd.Env) > 0 { |
| 62 | env = append(os.Environ(), sortedEnv(cmd.Env)...) |
| 63 | } |
| 64 | |
| 65 | ec := exec.CommandContext(ctx, name, args...) |
| 66 | ec.WaitDelay = cancelWaitDelay |
| 67 | if env != nil { |
| 68 | ec.Env = env |
| 69 | } |
| 70 | if cmd.Stdin != nil { |
| 71 | ec.Stdin = cmd.Stdin |
| 72 | } |
| 73 | // A nil writer falls back to a buffer copied into ExecResult; a non-nil writer |
| 74 | // receives the stream live and leaves the ExecResult field nil. |
| 75 | var outBuf, errBuf bytes.Buffer |
| 76 | if stdout != nil { |
| 77 | ec.Stdout = stdout |
| 78 | } else { |
| 79 | ec.Stdout = &outBuf |
| 80 | } |
| 81 | if stderr != nil { |
| 82 | ec.Stderr = stderr |
| 83 | } else { |
| 84 | ec.Stderr = &errBuf |
| 85 | } |
| 86 | |
| 87 | err := ec.Run() |
| 88 | res := legatus.ExecResult{} |
| 89 | if stdout == nil { |
| 90 | res.Stdout = outBuf.Bytes() |
| 91 | } |
| 92 | if stderr == nil { |
| 93 | res.Stderr = errBuf.Bytes() |
| 94 | } |
| 95 | if err != nil { |
| 96 | // A cancelled ctx kills the process, which surfaces as an ExitError; the |
| 97 | // contract (IV4, matching SSH) is to report ctx.Err(), not a non-zero exit. |
| 98 | if ctx.Err() != nil { |
| 99 | return res, culpa.WithCode(culpa.Wrapf(ctx.Err(), "local: run %s cancelled", cmd.Path), "LOCAL_EXEC") |
| 100 | } |
| 101 | var ee *exec.ExitError |
| 102 | if errors.As(err, &ee) { |
| 103 | res.ExitCode = ee.ExitCode() |
| 104 | return res, nil |
| 105 | } |
| 106 | return res, culpa.WithCode(culpa.Wrapf(err, "local: run %s", cmd.Path), "LOCAL_EXEC") |
| 107 | } |
| 108 | return res, nil |
| 109 | } |
| 110 | |
| 111 | // Put writes content to remote with mode. Without sudo it writes atomically |
| 112 | // (temp file in the destination directory, then rename). With sudo it stages to |
| 113 | // a private /tmp file then `sudo mv` into place, mirroring the SSH transport. An |
| 114 | // owner, if given, is applied with `chown` (sudo-wrapped when sudo is set). |
| 115 | func (c *Conn) Put(ctx context.Context, content io.Reader, remote string, mode legatus.FileMode, opts ...legatus.PutOption) error { |
| 116 | if remote == "" { |
| 117 | return culpa.WithCode(culpa.New("local: empty remote path"), "LOCAL_PUT") |
| 118 | } |
| 119 | var o legatus.PutOptions |
| 120 | for _, opt := range opts { |
| 121 | opt(&o) |
| 122 | } |
| 123 | data, err := io.ReadAll(content) |
| 124 | if err != nil { |
| 125 | return culpa.WithCode(culpa.Wrap(err, "local: read content"), "LOCAL_PUT") |
| 126 | } |
| 127 | |
| 128 | if !o.Sudo { |
| 129 | if err := atomicWrite(remote, data, mode); err != nil { |
| 130 | return err |
| 131 | } |
| 132 | if o.Owner != "" { |
| 133 | return c.chown(ctx, o.Owner, remote, false) |
| 134 | } |
| 135 | return nil |
| 136 | } |
| 137 | |
| 138 | tmp := localTempPath(remote) |
| 139 | if err := atomicWrite(tmp, data, mode); err != nil { |
| 140 | return err |
| 141 | } |
| 142 | if err := c.runChecked(ctx, legatus.Cmd{Path: "mkdir", Args: []string{"-p", filepath.Dir(remote)}, Sudo: true}); err != nil { |
| 143 | _ = os.Remove(tmp) |
| 144 | return culpa.WithCode(culpa.Wrap(err, "local: sudo mkdir"), "LOCAL_PUT") |
| 145 | } |
| 146 | if err := c.runChecked(ctx, legatus.Cmd{Path: "mv", Args: []string{tmp, remote}, Sudo: true}); err != nil { |
| 147 | _ = os.Remove(tmp) |
| 148 | return culpa.WithCode(culpa.Wrap(err, "local: sudo mv into place"), "LOCAL_PUT") |
| 149 | } |
| 150 | if o.Owner != "" { |
| 151 | return c.chown(ctx, o.Owner, remote, true) |
| 152 | } |
| 153 | return nil |
| 154 | } |
| 155 | |
| 156 | // Get streams the local file at remote to w. |
| 157 | func (c *Conn) Get(_ context.Context, remote string, w io.Writer) error { |
| 158 | if remote == "" { |
| 159 | return culpa.WithCode(culpa.New("local: empty remote path"), "LOCAL_GET") |
| 160 | } |
| 161 | f, err := os.Open(remote) |
| 162 | if err != nil { |
| 163 | return culpa.WithCode(culpa.Wrapf(err, "local: open %s", remote), "LOCAL_GET") |
| 164 | } |
| 165 | defer func() { _ = f.Close() }() |
| 166 | if _, err := io.Copy(w, f); err != nil { |
| 167 | return culpa.WithCode(culpa.Wrapf(err, "local: read %s", remote), "LOCAL_GET") |
| 168 | } |
| 169 | return nil |
| 170 | } |
| 171 | |
| 172 | // Close is a no-op: a local Conn holds no connection. |
| 173 | func (c *Conn) Close() error { return nil } |
| 174 | |
| 175 | // chown sets the owner of remote via the chown command (sudo-wrapped when sudo), |
| 176 | // matching how the SSH transport applies ownership (handles user:group names). |
| 177 | func (c *Conn) chown(ctx context.Context, owner, remote string, sudo bool) error { |
| 178 | return c.runChecked(ctx, legatus.Cmd{Path: "chown", Args: []string{owner, remote}, Sudo: sudo}) |
| 179 | } |
| 180 | |
| 181 | // runChecked runs a command that must succeed, turning a non-zero exit into an |
| 182 | // error so a failed sudo mv/chown is never silently swallowed. |
| 183 | func (c *Conn) runChecked(ctx context.Context, cmd legatus.Cmd) error { |
| 184 | res, err := c.Exec(ctx, cmd) |
| 185 | if err == nil && res.ExitCode != 0 { |
| 186 | err = culpa.Errorf("exit %d: %s", res.ExitCode, bytes.TrimSpace(res.Combined())) |
| 187 | } |
| 188 | return err |
| 189 | } |
| 190 | |
| 191 | // sudoArgv builds the argv for a sudo-wrapped command: `sudo -- [env KEY=val …] |
| 192 | // path args`. env(1) re-exports the environment under sudo (sudo strips it), |
| 193 | // mirroring the SSH transport. No shell quoting is needed — os/exec passes argv |
| 194 | // structurally, so values can never be re-split. |
| 195 | func sudoArgv(cmd legatus.Cmd) []string { |
| 196 | argv := []string{"sudo", "--"} |
| 197 | if len(cmd.Env) > 0 { |
| 198 | argv = append(argv, "env") |
| 199 | argv = append(argv, sortedEnv(cmd.Env)...) |
| 200 | } |
| 201 | argv = append(argv, cmd.Path) |
| 202 | return append(argv, cmd.Args...) |
| 203 | } |
| 204 | |
| 205 | // sortedEnv renders env as deterministic KEY=value tokens. |
| 206 | func sortedEnv(env map[string]string) []string { |
| 207 | keys := make([]string, 0, len(env)) |
| 208 | for k := range env { |
| 209 | keys = append(keys, k) |
| 210 | } |
| 211 | sort.Strings(keys) |
| 212 | out := make([]string, 0, len(keys)) |
| 213 | for _, k := range keys { |
| 214 | out = append(out, k+"="+env[k]) |
| 215 | } |
| 216 | return out |
| 217 | } |
| 218 | |
| 219 | // atomicWrite writes data to dst via a temp file in the same directory then |
| 220 | // rename, so a failed or interrupted write never leaves a partial target. |
| 221 | func atomicWrite(dst string, data []byte, mode legatus.FileMode) error { |
| 222 | dir := filepath.Dir(dst) |
| 223 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 224 | return culpa.WithCode(culpa.Wrapf(err, "local: mkdir %s", dir), "LOCAL_PUT") |
| 225 | } |
| 226 | tmp, err := os.CreateTemp(dir, ".legatus-*") |
| 227 | if err != nil { |
| 228 | return culpa.WithCode(culpa.Wrapf(err, "local: temp in %s", dir), "LOCAL_PUT") |
| 229 | } |
| 230 | tmpName := tmp.Name() |
| 231 | if _, err := tmp.Write(data); err != nil { |
| 232 | _ = tmp.Close() |
| 233 | _ = os.Remove(tmpName) |
| 234 | return culpa.WithCode(culpa.Wrapf(err, "local: write %s", tmpName), "LOCAL_PUT") |
| 235 | } |
| 236 | if err := tmp.Close(); err != nil { |
| 237 | _ = os.Remove(tmpName) |
| 238 | return culpa.WithCode(culpa.Wrapf(err, "local: close %s", tmpName), "LOCAL_PUT") |
| 239 | } |
| 240 | if err := os.Chmod(tmpName, mode); err != nil { |
| 241 | _ = os.Remove(tmpName) |
| 242 | return culpa.WithCode(culpa.Wrapf(err, "local: chmod %s", tmpName), "LOCAL_PUT") |
| 243 | } |
| 244 | if err := os.Rename(tmpName, dst); err != nil { |
| 245 | _ = os.Remove(tmpName) |
| 246 | return culpa.WithCode(culpa.Wrapf(err, "local: rename into %s", dst), "LOCAL_PUT") |
| 247 | } |
| 248 | return nil |
| 249 | } |
| 250 | |
| 251 | // localTempPath derives a private /tmp staging path for the sudo move path. |
| 252 | func localTempPath(remote string) string { |
| 253 | return filepath.Join(os.TempDir(), "legatus."+ |
| 254 | strconv.Itoa(os.Getpid())+"."+ |
| 255 | strconv.FormatInt(time.Now().UnixNano(), 10)+"."+ |
| 256 | filepath.Base(remote)) |
| 257 | } |
| 258 | |