devlaunch 0.0.11__tar.gz → 0.0.12__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {devlaunch-0.0.11 → devlaunch-0.0.12}/PKG-INFO +1 -1
- devlaunch-0.0.12/devlaunch/devpod_ssh.py +143 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/dl.py +47 -3
- {devlaunch-0.0.11 → devlaunch-0.0.12}/pyproject.toml +1 -1
- {devlaunch-0.0.11 → devlaunch-0.0.12}/.gitignore +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/LICENSE +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/README.md +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/migration.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/repo_manager.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/storage.py +0 -0
- {devlaunch-0.0.11 → devlaunch-0.0.12}/devlaunch/worktree/workspace_clone.py +0 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""How a `devpod ssh` session ended, recovered from what devpod reports.
|
|
2
|
+
|
|
3
|
+
devpod means to pass a remote process's exit status through. Its top-level error
|
|
4
|
+
handler does:
|
|
5
|
+
|
|
6
|
+
if sshExitErr, ok := err.(*ssh.ExitError); ok {
|
|
7
|
+
os.Exit(sshExitErr.ExitStatus())
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
But by the time the error reaches there it has been wrapped three times —
|
|
11
|
+
`ssh session: %w` in cmd/machine/ssh.go, then "run in container", then "tunnel to
|
|
12
|
+
container" — and a bare type assertion does not see through `%w`. So every
|
|
13
|
+
nonzero remote exit misses that branch and lands on devpod's generic failure
|
|
14
|
+
path instead, which prints
|
|
15
|
+
|
|
16
|
+
error Try using the --debug flag to see a more verbose output root.go:106
|
|
17
|
+
fatal tunnel to container: run in container: ssh session: Process exited with status 130
|
|
18
|
+
|
|
19
|
+
and exits 1.
|
|
20
|
+
|
|
21
|
+
Nothing has gone wrong in that example. A login shell exits with the status of
|
|
22
|
+
its last command, so a single Ctrl-C before typing `exit` is enough to make a
|
|
23
|
+
perfectly ordinary session end 130. The session ran and it ended; devpod just has
|
|
24
|
+
no way left to say so.
|
|
25
|
+
|
|
26
|
+
Both of those lines are Error/Fatal level, which loft-sh/log sends to stderr
|
|
27
|
+
(Info-level progress goes to stdout, so reading stderr does not hold back the
|
|
28
|
+
"waiting for workspace" chatter). That makes the status recoverable: read
|
|
29
|
+
devpod's stderr, take the status out of the message it buried it in, and hold
|
|
30
|
+
back the two lines that only exist because devpod could not report it properly.
|
|
31
|
+
|
|
32
|
+
The distinction the rest of devlaunch needs is which process the resulting number
|
|
33
|
+
came from, so it is a type rather than a bare int — see SshOutcome.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import Iterable, NoReturn, Optional, TextIO
|
|
39
|
+
|
|
40
|
+
# devpod prints this immediately before the fatal it belongs to, so it has to be
|
|
41
|
+
# held for one line to see which fatal that is.
|
|
42
|
+
DEBUG_HINT = "Try using the --debug flag to see a more verbose output"
|
|
43
|
+
|
|
44
|
+
# The status golang.org/x/crypto/ssh formatted into an *ssh.ExitError:
|
|
45
|
+
# "Process exited with status 130", optionally " from signal SIGINT" and
|
|
46
|
+
# ". Reason was: ...". Anchored on devpod's "fatal" tag as well so a remote
|
|
47
|
+
# program printing the same sentence on its own stderr (which reaches us only
|
|
48
|
+
# when there is no pty) cannot be mistaken for devpod's report.
|
|
49
|
+
#
|
|
50
|
+
# No \b before "fatal": devpod colours the tag, and the escape it emits ends in
|
|
51
|
+
# "m", so there is no word boundary in front of it.
|
|
52
|
+
REMOTE_EXIT_RE = re.compile(r"fatal\b.*\bssh session: Process exited with status (\d+)")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class RemoteExit:
|
|
57
|
+
"""devpod ran the remote program, and it exited with `status`.
|
|
58
|
+
|
|
59
|
+
Not a devlaunch failure, whatever `status` is: the shell or command the user
|
|
60
|
+
asked for ran to completion. `status` belongs to that program.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
status: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class DevpodFailed:
|
|
68
|
+
"""devpod never ran the remote program, or lost it partway.
|
|
69
|
+
|
|
70
|
+
`exit_code` is devpod's own. devpod has already written its diagnostics to
|
|
71
|
+
stderr by the time this is constructed, so it carries no message of its own —
|
|
72
|
+
there is nothing devlaunch knows that the user has not already been told.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
exit_code: int
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
SshOutcome = RemoteExit | DevpodFailed
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def assert_never(value: NoReturn) -> NoReturn:
|
|
82
|
+
"""Fail loudly on an SshOutcome arm nobody handled.
|
|
83
|
+
|
|
84
|
+
A runtime backstop, not a compile-time one: `ty`, the checker this project
|
|
85
|
+
runs in CI, does not currently reject a `match` that drops an arm. It is
|
|
86
|
+
still worth having, because a `match` with no fallthrough returns None, and
|
|
87
|
+
`main` returning None makes `dl` exit 0 — a new outcome would otherwise go
|
|
88
|
+
out as success.
|
|
89
|
+
|
|
90
|
+
Stands in for typing.assert_never, which needs 3.11; this project is 3.10+.
|
|
91
|
+
"""
|
|
92
|
+
raise AssertionError(f"unhandled outcome: {value!r}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def filter_devpod_stderr(lines: Iterable[str], out: TextIO) -> Optional[int]:
|
|
96
|
+
"""Forward devpod's stderr, holding back its report of a remote exit status.
|
|
97
|
+
|
|
98
|
+
Returns that status if devpod reported one. Everything else is passed through
|
|
99
|
+
verbatim and unbuffered, so a genuine devpod failure still reads exactly as
|
|
100
|
+
it does today — including the --debug hint, which is released ahead of the
|
|
101
|
+
fatal it precedes rather than after it.
|
|
102
|
+
"""
|
|
103
|
+
remote_status: Optional[int] = None
|
|
104
|
+
held_hint: Optional[str] = None
|
|
105
|
+
|
|
106
|
+
for line in lines:
|
|
107
|
+
match = REMOTE_EXIT_RE.search(line)
|
|
108
|
+
if match:
|
|
109
|
+
remote_status = int(match.group(1))
|
|
110
|
+
# The hint introduced this fatal, so it goes with it.
|
|
111
|
+
held_hint = None
|
|
112
|
+
continue
|
|
113
|
+
if DEBUG_HINT in line:
|
|
114
|
+
held_hint = line
|
|
115
|
+
continue
|
|
116
|
+
if held_hint is not None:
|
|
117
|
+
out.write(held_hint)
|
|
118
|
+
held_hint = None
|
|
119
|
+
out.write(line)
|
|
120
|
+
out.flush()
|
|
121
|
+
|
|
122
|
+
if held_hint is not None:
|
|
123
|
+
out.write(held_hint)
|
|
124
|
+
out.flush()
|
|
125
|
+
|
|
126
|
+
return remote_status
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def interpret(devpod_exit_code: int, remote_status: Optional[int]) -> SshOutcome:
|
|
130
|
+
"""Decide what a finished `devpod ssh` actually reported.
|
|
131
|
+
|
|
132
|
+
A recovered remote status wins over devpod's own exit code, because devpod
|
|
133
|
+
reports 1 alongside it regardless of what the remote program returned.
|
|
134
|
+
"""
|
|
135
|
+
if remote_status is not None:
|
|
136
|
+
return RemoteExit(remote_status)
|
|
137
|
+
if devpod_exit_code == 0:
|
|
138
|
+
return RemoteExit(0)
|
|
139
|
+
# No status to recover. Either devpod really did fail, or a future devpod
|
|
140
|
+
# unwraps the error properly and exits with the remote status itself — in
|
|
141
|
+
# which case this is still the right number to pass on, and devpod stayed
|
|
142
|
+
# quiet, so nothing spurious is printed either way.
|
|
143
|
+
return DevpodFailed(devpod_exit_code)
|
|
@@ -33,7 +33,7 @@ from dataclasses import dataclass
|
|
|
33
33
|
from urllib.parse import urlparse
|
|
34
34
|
from urllib.request import url2pathname
|
|
35
35
|
|
|
36
|
-
from . import gh_auth
|
|
36
|
+
from . import devpod_ssh, gh_auth
|
|
37
37
|
from .completion import install_completions
|
|
38
38
|
from .workspace_id import TARGET_LENGTH, WorkspaceId, slug, source_workspace_id, validate_ref_name
|
|
39
39
|
from .worktree.config import get_worktree_config
|
|
@@ -832,6 +832,38 @@ def run_devpod(
|
|
|
832
832
|
raise DevpodNotInstalled(DEVPOD_MISSING_MESSAGE) from e
|
|
833
833
|
|
|
834
834
|
|
|
835
|
+
def run_devpod_session(
|
|
836
|
+
args: List[str], env: Optional[Dict[str, str]] = None
|
|
837
|
+
) -> devpod_ssh.SshOutcome:
|
|
838
|
+
"""Run a devpod command that hands its stdin/stdout to a terminal session.
|
|
839
|
+
|
|
840
|
+
stdin and stdout are inherited untouched — devpod puts the real terminal into
|
|
841
|
+
raw mode through them, and requests a pty on that basis. Only stderr is read,
|
|
842
|
+
which under a pty carries devpod's own warnings and errors and nothing else,
|
|
843
|
+
so that devpod's report of how the session ended can be interpreted rather
|
|
844
|
+
than dumped on the user. See devpod_ssh for why that is necessary.
|
|
845
|
+
"""
|
|
846
|
+
cmd = ["devpod"] + args
|
|
847
|
+
logging.debug("Running: %s", " ".join(cmd))
|
|
848
|
+
# nosec B603 - using list form, not shell=True; no command injection risk
|
|
849
|
+
with subprocess.Popen(
|
|
850
|
+
cmd,
|
|
851
|
+
stderr=subprocess.PIPE,
|
|
852
|
+
text=True,
|
|
853
|
+
encoding="utf-8",
|
|
854
|
+
errors="replace",
|
|
855
|
+
env=env,
|
|
856
|
+
) as proc:
|
|
857
|
+
# proc.stderr is a pipe because PIPE was asked for, but Popen's type
|
|
858
|
+
# cannot express that, so the narrowing happens here rather than by
|
|
859
|
+
# widening filter_devpod_stderr to a None it would have no answer for.
|
|
860
|
+
pipe = proc.stderr
|
|
861
|
+
remote_status = (
|
|
862
|
+
devpod_ssh.filter_devpod_stderr(pipe, sys.stderr) if pipe is not None else None
|
|
863
|
+
)
|
|
864
|
+
return devpod_ssh.interpret(proc.returncode, remote_status)
|
|
865
|
+
|
|
866
|
+
|
|
835
867
|
# The memoized `devpod list` snapshot. A dict rather than a module-level
|
|
836
868
|
# Optional so the accessors below need no `global`, and so "nothing read yet"
|
|
837
869
|
# (no key at all) stays distinguishable from "devpod has no workspaces" (an
|
|
@@ -1049,8 +1081,20 @@ def workspace_ssh(
|
|
|
1049
1081
|
args.extend(token_args)
|
|
1050
1082
|
|
|
1051
1083
|
logging.info(f"SSH command: devpod {' '.join(args)}")
|
|
1052
|
-
|
|
1053
|
-
|
|
1084
|
+
outcome = run_devpod_session(args, env=env)
|
|
1085
|
+
|
|
1086
|
+
# The two arms carry the same kind of number from different processes, which
|
|
1087
|
+
# is exactly the confusion this used to make: `dl` reported devpod's exit
|
|
1088
|
+
# code (always 1) for a session that had ended perfectly normally with, say,
|
|
1089
|
+
# 130. Whichever arm this is, the status returned is the session's.
|
|
1090
|
+
match outcome:
|
|
1091
|
+
case devpod_ssh.RemoteExit(status=status):
|
|
1092
|
+
return status
|
|
1093
|
+
case devpod_ssh.DevpodFailed(exit_code=exit_code):
|
|
1094
|
+
logging.debug("devpod ssh failed with exit code %s", exit_code)
|
|
1095
|
+
return exit_code
|
|
1096
|
+
case _ as unhandled:
|
|
1097
|
+
devpod_ssh.assert_never(unhandled)
|
|
1054
1098
|
|
|
1055
1099
|
|
|
1056
1100
|
def attach_workspace(workspace_id: str, shell_command: Optional[str] = None) -> int:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|