mantis-cli 22.3.0__tar.gz → 22.5.0__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.
Files changed (48) hide show
  1. {mantis_cli-22.3.0/mantis_cli.egg-info → mantis_cli-22.5.0}/PKG-INFO +37 -27
  2. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/README.md +33 -4
  3. mantis_cli-22.5.0/mantis/__init__.py +1 -0
  4. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/managers.py +84 -9
  5. {mantis_cli-22.3.0 → mantis_cli-22.5.0/mantis_cli.egg-info}/PKG-INFO +38 -28
  6. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis_cli.egg-info/entry_points.txt +1 -0
  7. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/tests/test_managers.py +35 -0
  8. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/tests/test_tunnel.py +70 -1
  9. mantis_cli-22.3.0/mantis/__init__.py +0 -1
  10. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/LICENSE +0 -0
  11. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/MANIFEST.in +0 -0
  12. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/__main__.py +0 -0
  13. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/app.py +0 -0
  14. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/command_line.py +0 -0
  15. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/commands/__init__.py +0 -0
  16. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/commands/compose.py +0 -0
  17. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/commands/configuration.py +0 -0
  18. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/commands/connection.py +0 -0
  19. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/commands/containers.py +0 -0
  20. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/commands/core.py +0 -0
  21. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/commands/django.py +0 -0
  22. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/commands/images.py +0 -0
  23. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/commands/nginx.py +0 -0
  24. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/commands/postgres.py +0 -0
  25. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/commands/secrets.py +0 -0
  26. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/commands/services.py +0 -0
  27. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/commands/volumes.py +0 -0
  28. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/config.py +0 -0
  29. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/cryptography.py +0 -0
  30. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/environment.py +0 -0
  31. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/extensions/__init__.py +0 -0
  32. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/extensions/django.py +0 -0
  33. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/extensions/nginx.py +0 -0
  34. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/extensions/postgres.py +0 -0
  35. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/helpers.py +0 -0
  36. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/mantis.tpl +0 -0
  37. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis/schema.py +0 -0
  38. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis_cli.egg-info/SOURCES.txt +0 -0
  39. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis_cli.egg-info/dependency_links.txt +0 -0
  40. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis_cli.egg-info/requires.txt +0 -0
  41. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/mantis_cli.egg-info/top_level.txt +0 -0
  42. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/setup.cfg +0 -0
  43. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/setup.py +0 -0
  44. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/tests/__init__.py +0 -0
  45. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/tests/test_build_config.py +0 -0
  46. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/tests/test_command_line.py +0 -0
  47. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/tests/test_config.py +0 -0
  48. {mantis_cli-22.3.0 → mantis_cli-22.5.0}/tests/test_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: mantis_cli
3
- Version: 22.3.0
3
+ Version: 22.5.0
4
4
  Summary: Management command to build and deploy webapps, especially based on Django
5
5
  Home-page: https://github.com/PragmaticMates/mantis-cli
6
6
  Author: Erik Telepovský
@@ -9,6 +9,7 @@ Maintainer: Pragmatic Mates
9
9
  Maintainer-email: info@pragmaticmates.com
10
10
  License: GNU General Public License (GPL)
11
11
  Keywords: management deployment docker command
12
+ Platform: UNKNOWN
12
13
  Classifier: Programming Language :: Python
13
14
  Classifier: Operating System :: OS Independent
14
15
  Classifier: Environment :: Web Environment
@@ -18,27 +19,6 @@ Classifier: License :: OSI Approved :: GNU General Public License (GPL)
18
19
  Classifier: Development Status :: 5 - Production/Stable
19
20
  Description-Content-Type: text/markdown
20
21
  License-File: LICENSE
21
- Requires-Dist: cffi
22
- Requires-Dist: click
23
- Requires-Dist: cryptography
24
- Requires-Dist: pycryptodome
25
- Requires-Dist: pydantic
26
- Requires-Dist: PyYAML
27
- Requires-Dist: rich
28
- Requires-Dist: typer
29
- Dynamic: author
30
- Dynamic: author-email
31
- Dynamic: classifier
32
- Dynamic: description
33
- Dynamic: description-content-type
34
- Dynamic: home-page
35
- Dynamic: keywords
36
- Dynamic: license
37
- Dynamic: license-file
38
- Dynamic: maintainer
39
- Dynamic: maintainer-email
40
- Dynamic: requires-dist
41
- Dynamic: summary
42
22
 
43
23
  # mantis-cli
44
24
 
@@ -210,10 +190,39 @@ alone: `ssh -L` to a unix socket succeeds even when nothing listens on the far e
210
190
  tunnel only proves itself once the daemon has replied. Set `enabled` explicitly once
211
191
  `check-tunnel` (below) confirms a server, and that round-trip goes away.
212
192
 
213
- Requirements on the server: the connecting user must be able to read the docker socket
214
- (typically membership in the `docker` group), and `AllowStreamLocalForwarding` must not
215
- be disabled — it defaults to `yes`. Whenever the tunnel cannot be used, mantis warns and
216
- falls back to a connection per docker command, so deployments keep working either way.
193
+ Requirements on the server: it must be an OpenSSH server (see below), the connecting user
194
+ must be able to read the docker socket (typically membership in the `docker` group), and
195
+ `AllowStreamLocalForwarding` must not be disabled — it defaults to `yes`. Whenever the
196
+ tunnel cannot be used, mantis warns and falls back to a connection per docker command, so
197
+ deployments keep working either way.
198
+
199
+ #### Servers that cannot forward a unix socket
200
+
201
+ Forwarding one is `direct-streamlocal@openssh.com`, an OpenSSH extension, and SSH front-ends
202
+ built on other implementations do not have it. [ssh2incus](https://ssh2incus.com), which
203
+ routes connections into Incus instances by login name, rejects the channel outright:
204
+
205
+ ```
206
+ channel 2: open failed: unknown channel type: unsupported channel type
207
+ ```
208
+
209
+ No `remote_socket` helps there — the socket is never reached. What works instead is
210
+ multiplexing the per-command connections, which needs no forward at all:
211
+
212
+ ```
213
+ Host <server>
214
+ ControlMaster auto
215
+ ControlPath ~/.ssh/mantis-%r@%h:%p
216
+ ControlPersist 5m
217
+ ```
218
+
219
+ Docker spawns a plain `ssh` per command and passes it no options of its own, so the ssh
220
+ client reads this block by itself and every command reuses one connection. Measured against
221
+ an ssh2incus server, that took a docker command from 0.29 s to 0.09 s. This is a weaker
222
+ guarantee than the tunnel — sessions do count against sshd's `MaxSessions`, where forwarded
223
+ channels do not — so on OpenSSH prefer the tunnel and keep multiplexing for the servers that
224
+ cannot offer it. Set `"tunnel": {"enabled": false}` there, so no run pays for a probe that
225
+ cannot succeed.
217
226
 
218
227
  The check ignores `tunnel.enabled`, so it answers "would this work" rather than "is this
219
228
  on" — use it before setting `enabled` to `true`:
@@ -611,3 +620,4 @@ Works as follows:
611
620
  ## Release notes
612
621
 
613
622
  Mantis uses semantic versioning. See more in [changelog](https://github.com/PragmaticMates/mantis-cli/blob/master/CHANGES.md).
623
+
@@ -168,10 +168,39 @@ alone: `ssh -L` to a unix socket succeeds even when nothing listens on the far e
168
168
  tunnel only proves itself once the daemon has replied. Set `enabled` explicitly once
169
169
  `check-tunnel` (below) confirms a server, and that round-trip goes away.
170
170
 
171
- Requirements on the server: the connecting user must be able to read the docker socket
172
- (typically membership in the `docker` group), and `AllowStreamLocalForwarding` must not
173
- be disabled — it defaults to `yes`. Whenever the tunnel cannot be used, mantis warns and
174
- falls back to a connection per docker command, so deployments keep working either way.
171
+ Requirements on the server: it must be an OpenSSH server (see below), the connecting user
172
+ must be able to read the docker socket (typically membership in the `docker` group), and
173
+ `AllowStreamLocalForwarding` must not be disabled — it defaults to `yes`. Whenever the
174
+ tunnel cannot be used, mantis warns and falls back to a connection per docker command, so
175
+ deployments keep working either way.
176
+
177
+ #### Servers that cannot forward a unix socket
178
+
179
+ Forwarding one is `direct-streamlocal@openssh.com`, an OpenSSH extension, and SSH front-ends
180
+ built on other implementations do not have it. [ssh2incus](https://ssh2incus.com), which
181
+ routes connections into Incus instances by login name, rejects the channel outright:
182
+
183
+ ```
184
+ channel 2: open failed: unknown channel type: unsupported channel type
185
+ ```
186
+
187
+ No `remote_socket` helps there — the socket is never reached. What works instead is
188
+ multiplexing the per-command connections, which needs no forward at all:
189
+
190
+ ```
191
+ Host <server>
192
+ ControlMaster auto
193
+ ControlPath ~/.ssh/mantis-%r@%h:%p
194
+ ControlPersist 5m
195
+ ```
196
+
197
+ Docker spawns a plain `ssh` per command and passes it no options of its own, so the ssh
198
+ client reads this block by itself and every command reuses one connection. Measured against
199
+ an ssh2incus server, that took a docker command from 0.29 s to 0.09 s. This is a weaker
200
+ guarantee than the tunnel — sessions do count against sshd's `MaxSessions`, where forwarded
201
+ channels do not — so on OpenSSH prefer the tunnel and keep multiplexing for the servers that
202
+ cannot offer it. Set `"tunnel": {"enabled": false}` there, so no run pays for a probe that
203
+ cannot succeed.
175
204
 
176
205
  The check ignores `tunnel.enabled`, so it answers "would this work" rather than "is this
177
206
  on" — use it before setting `enabled` to `true`:
@@ -0,0 +1 @@
1
+ VERSION = '22.5.0'
@@ -136,6 +136,10 @@ class AbstractManager(object):
136
136
  def tunnel_config(self) -> Dict[str, Any]:
137
137
  return self.config.get('tunnel', {})
138
138
 
139
+ @property
140
+ def tunnel_remote_socket(self) -> str:
141
+ return self.tunnel_config.get('remote_socket', '/var/run/docker.sock')
142
+
139
143
  def ensure_tunnel(self) -> Optional[str]:
140
144
  """
141
145
  Returns path of a local unix socket forwarded to the remote docker socket,
@@ -187,8 +191,9 @@ class AbstractManager(object):
187
191
  CLI.success(f'Tunnel is available (docker {version}). '
188
192
  f'Enable it in config to skip this check.')
189
193
  else:
194
+ reason, remedy = self.diagnose_tunnel()
190
195
  CLI.warning(
191
- 'Docker daemon did not answer through the SSH tunnel.\n'
196
+ f'{reason}\n{remedy}\n'
192
197
  'Falling back to a separate SSH connection per docker command.'
193
198
  )
194
199
  self.stop_tunnel()
@@ -212,7 +217,7 @@ class AbstractManager(object):
212
217
  Opens a single backgrounded SSH master forwarding the remote docker socket.
213
218
  Every docker command then reuses it instead of opening its own connection.
214
219
  """
215
- remote_socket = self.tunnel_config.get('remote_socket', '/var/run/docker.sock')
220
+ remote_socket = self.tunnel_remote_socket
216
221
  ssh_options = self.tunnel_config.get('ssh_options', [])
217
222
 
218
223
  # unix socket paths are limited to ~104 characters, so prefer a shallow base
@@ -224,7 +229,9 @@ class AbstractManager(object):
224
229
  control_socket = str(Path(self._tunnel_dir) / 'ssh.ctl')
225
230
 
226
231
  command = [
227
- 'ssh', '-f', '-N', '-M',
232
+ # -v, because a channel the server refuses is only reported at verbose level,
233
+ # and the master writes to a file nobody reads unless something goes wrong
234
+ 'ssh', '-f', '-N', '-M', '-v',
228
235
  '-S', control_socket,
229
236
  '-o', 'ExitOnForwardFailure=yes',
230
237
  '-o', 'ServerAliveInterval=30',
@@ -251,7 +258,7 @@ class AbstractManager(object):
251
258
 
252
259
  if result.returncode != 0:
253
260
  CLI.warning(
254
- f'Failed to open SSH tunnel: {output_file.read_text().strip()}\n'
261
+ f'Failed to open SSH tunnel: {self.ssh_log_errors(output_file.read_text())}\n'
255
262
  f'Falling back to a separate SSH connection per docker command.'
256
263
  )
257
264
  self.remove_tunnel_dir()
@@ -281,6 +288,62 @@ class AbstractManager(object):
281
288
  shutil.rmtree(self._tunnel_dir, ignore_errors=True)
282
289
  self._tunnel_dir = None
283
290
 
291
+ @staticmethod
292
+ def ssh_log_errors(log: str) -> str:
293
+ """Keeps the lines worth showing, dropping the debug chatter -v produces."""
294
+ return '\n'.join(line for line in log.splitlines() if not line.startswith('debug')).strip()
295
+
296
+ def read_tunnel_log(self) -> str:
297
+ """
298
+ Returns whatever the SSH master has written so far.
299
+
300
+ The master is backgrounded with -f and keeps writing to the same file long after it
301
+ exited successfully, so a channel it refuses later is only ever reported there.
302
+ """
303
+ if not self._tunnel_dir:
304
+ return ''
305
+
306
+ try:
307
+ return (Path(self._tunnel_dir) / 'ssh.log').read_text()
308
+ except OSError:
309
+ return ''
310
+
311
+ def diagnose_tunnel(self) -> Tuple[str, str]:
312
+ """
313
+ Explains why nothing answered through the forward, as (reason, remedy).
314
+
315
+ Opening the forward proves nothing: `ssh -L` to a unix socket succeeds even when the
316
+ far end is dead, so the reason only appears once a connection is made through it.
317
+ Reads the master's log, and therefore has to run before the tunnel is stopped.
318
+ """
319
+ log = self.read_tunnel_log()
320
+
321
+ if 'unknown channel type' in log:
322
+ return (
323
+ 'The server refused to forward a unix socket: it does not implement '
324
+ 'direct-streamlocal, so it is not an OpenSSH server. SSH front-ends such as '
325
+ 'ssh2incus reject that channel outright, and no remote_socket can help.',
326
+ 'Share one connection without a forward instead, by adding to ~/.ssh/config:\n'
327
+ f' Host {self.host}\n'
328
+ ' ControlMaster auto\n'
329
+ ' ControlPath ~/.ssh/mantis-%r@%h:%p\n'
330
+ ' ControlPersist 5m\n'
331
+ 'Every docker command then reuses that connection, and setting '
332
+ '"tunnel": {"enabled": false} skips this check from now on.',
333
+ )
334
+
335
+ if 'administratively prohibited' in log:
336
+ return (
337
+ 'The server refused the forward.',
338
+ 'Check "AllowStreamLocalForwarding" in its sshd_config. It defaults to "yes", '
339
+ 'so it has been turned off.',
340
+ )
341
+
342
+ return (
343
+ f'The forward works, but the docker daemon did not answer on {self.tunnel_remote_socket}.',
344
+ f'Make sure user "{self.user}" can access it (usually by being in the "docker" group).',
345
+ )
346
+
284
347
  def check_tunnel(self) -> bool:
285
348
  """
286
349
  Verifies the SSH tunnel can be used for this connection, regardless of whether it
@@ -297,7 +360,7 @@ class AbstractManager(object):
297
360
  if not self.connection.startswith('ssh://'):
298
361
  CLI.error(f'Tunnel requires an ssh:// connection, but this one is "{self.connection}".')
299
362
 
300
- remote_socket = self.tunnel_config.get('remote_socket', '/var/run/docker.sock')
363
+ remote_socket = self.tunnel_remote_socket
301
364
 
302
365
  if self.tunnel_config.get('enabled') is False:
303
366
  CLI.warning('Tunnel is disabled in config, checking anyway...')
@@ -317,12 +380,14 @@ class AbstractManager(object):
317
380
 
318
381
  version = self.probe_tunnel()
319
382
 
383
+ # the master's log goes away with the tunnel directory
384
+ reason, remedy = ('', '') if version else self.diagnose_tunnel()
385
+
320
386
  self.stop_tunnel()
321
387
 
322
388
  if not version:
323
389
  CLI.danger('Tunnel is NOT available.')
324
- CLI.info(f'The forward works, but the docker daemon did not answer on {remote_socket}. '
325
- f'Make sure user "{self.user}" can access it (usually by being in the "docker" group).')
390
+ CLI.info(f'{reason}\n{remedy}')
326
391
  return False
327
392
 
328
393
  CLI.success(f'Tunnel is available (docker {version}).')
@@ -521,6 +586,16 @@ class AbstractManager(object):
521
586
  except OSError as e:
522
587
  CLI.error(f"{error_message}: {e}")
523
588
 
589
+ @property
590
+ def quiet_flag(self) -> str:
591
+ """
592
+ Layer progress is meant to be repainted in place, which needs a terminal. Redirected
593
+ to a log file or a CI pipe, docker prints every progress event on its own line
594
+ instead -- hundreds per image -- so ask for no progress at all when there is nobody
595
+ to watch it move. Errors and the final result are not progress and still print.
596
+ """
597
+ return '' if sys.stdout.isatty() else '--quiet'
598
+
524
599
  def build_docker_command(self, command: str, use_connection: bool = True) -> str:
525
600
  docker_connection = self.docker_connection if use_connection else ''
526
601
 
@@ -1229,7 +1304,7 @@ class BaseManager(AbstractManager):
1229
1304
  CLI.info(f'Services = {params}')
1230
1305
 
1231
1306
  # Push using docker compose
1232
- self.docker_compose(f'push {params}', use_connection=False)
1307
+ self.docker_compose(f'push {self.quiet_flag} {params}', use_connection=False)
1233
1308
 
1234
1309
  def pull(self, services: Optional[List[str]] = None) -> None:
1235
1310
  """
@@ -1240,7 +1315,7 @@ class BaseManager(AbstractManager):
1240
1315
  CLI.info(f'Services = {params}')
1241
1316
 
1242
1317
  # Pull using docker compose
1243
- self.docker_compose(f'pull {params}')
1318
+ self.docker_compose(f'pull {self.quiet_flag} {params}')
1244
1319
 
1245
1320
  def upload(self) -> None:
1246
1321
  """
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
2
- Name: mantis_cli
3
- Version: 22.3.0
1
+ Metadata-Version: 2.1
2
+ Name: mantis-cli
3
+ Version: 22.5.0
4
4
  Summary: Management command to build and deploy webapps, especially based on Django
5
5
  Home-page: https://github.com/PragmaticMates/mantis-cli
6
6
  Author: Erik Telepovský
@@ -9,6 +9,7 @@ Maintainer: Pragmatic Mates
9
9
  Maintainer-email: info@pragmaticmates.com
10
10
  License: GNU General Public License (GPL)
11
11
  Keywords: management deployment docker command
12
+ Platform: UNKNOWN
12
13
  Classifier: Programming Language :: Python
13
14
  Classifier: Operating System :: OS Independent
14
15
  Classifier: Environment :: Web Environment
@@ -18,27 +19,6 @@ Classifier: License :: OSI Approved :: GNU General Public License (GPL)
18
19
  Classifier: Development Status :: 5 - Production/Stable
19
20
  Description-Content-Type: text/markdown
20
21
  License-File: LICENSE
21
- Requires-Dist: cffi
22
- Requires-Dist: click
23
- Requires-Dist: cryptography
24
- Requires-Dist: pycryptodome
25
- Requires-Dist: pydantic
26
- Requires-Dist: PyYAML
27
- Requires-Dist: rich
28
- Requires-Dist: typer
29
- Dynamic: author
30
- Dynamic: author-email
31
- Dynamic: classifier
32
- Dynamic: description
33
- Dynamic: description-content-type
34
- Dynamic: home-page
35
- Dynamic: keywords
36
- Dynamic: license
37
- Dynamic: license-file
38
- Dynamic: maintainer
39
- Dynamic: maintainer-email
40
- Dynamic: requires-dist
41
- Dynamic: summary
42
22
 
43
23
  # mantis-cli
44
24
 
@@ -210,10 +190,39 @@ alone: `ssh -L` to a unix socket succeeds even when nothing listens on the far e
210
190
  tunnel only proves itself once the daemon has replied. Set `enabled` explicitly once
211
191
  `check-tunnel` (below) confirms a server, and that round-trip goes away.
212
192
 
213
- Requirements on the server: the connecting user must be able to read the docker socket
214
- (typically membership in the `docker` group), and `AllowStreamLocalForwarding` must not
215
- be disabled — it defaults to `yes`. Whenever the tunnel cannot be used, mantis warns and
216
- falls back to a connection per docker command, so deployments keep working either way.
193
+ Requirements on the server: it must be an OpenSSH server (see below), the connecting user
194
+ must be able to read the docker socket (typically membership in the `docker` group), and
195
+ `AllowStreamLocalForwarding` must not be disabled — it defaults to `yes`. Whenever the
196
+ tunnel cannot be used, mantis warns and falls back to a connection per docker command, so
197
+ deployments keep working either way.
198
+
199
+ #### Servers that cannot forward a unix socket
200
+
201
+ Forwarding one is `direct-streamlocal@openssh.com`, an OpenSSH extension, and SSH front-ends
202
+ built on other implementations do not have it. [ssh2incus](https://ssh2incus.com), which
203
+ routes connections into Incus instances by login name, rejects the channel outright:
204
+
205
+ ```
206
+ channel 2: open failed: unknown channel type: unsupported channel type
207
+ ```
208
+
209
+ No `remote_socket` helps there — the socket is never reached. What works instead is
210
+ multiplexing the per-command connections, which needs no forward at all:
211
+
212
+ ```
213
+ Host <server>
214
+ ControlMaster auto
215
+ ControlPath ~/.ssh/mantis-%r@%h:%p
216
+ ControlPersist 5m
217
+ ```
218
+
219
+ Docker spawns a plain `ssh` per command and passes it no options of its own, so the ssh
220
+ client reads this block by itself and every command reuses one connection. Measured against
221
+ an ssh2incus server, that took a docker command from 0.29 s to 0.09 s. This is a weaker
222
+ guarantee than the tunnel — sessions do count against sshd's `MaxSessions`, where forwarded
223
+ channels do not — so on OpenSSH prefer the tunnel and keep multiplexing for the servers that
224
+ cannot offer it. Set `"tunnel": {"enabled": false}` there, so no run pays for a probe that
225
+ cannot succeed.
217
226
 
218
227
  The check ignores `tunnel.enabled`, so it answers "would this work" rather than "is this
219
228
  on" — use it before setting `enabled` to `true`:
@@ -611,3 +620,4 @@ Works as follows:
611
620
  ## Release notes
612
621
 
613
622
  Mantis uses semantic versioning. See more in [changelog](https://github.com/PragmaticMates/mantis-cli/blob/master/CHANGES.md).
623
+
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  mantis = mantis.command_line:run
3
+
@@ -512,3 +512,38 @@ class TestResolveContainers:
512
512
  manager.get_containers = lambda *args, **kwargs: pytest.fail('containers listed again')
513
513
 
514
514
  assert manager.resolve_container('app', ['itfitness-app']) == 'itfitness-app'
515
+
516
+
517
+ class TestQuietProgress:
518
+ """Layer progress is repainted in place, which only works on a terminal."""
519
+
520
+ @staticmethod
521
+ def _manager():
522
+ manager = BaseManager.__new__(BaseManager)
523
+ manager.commands = []
524
+ manager.docker_compose = lambda command, **kwargs: manager.commands.append(
525
+ ' '.join(command.split())
526
+ )
527
+ return manager
528
+
529
+ @patch('sys.stdout', new_callable=MagicMock)
530
+ def test_push_and_pull_are_quiet_off_a_terminal(self, mock_stdout):
531
+ """A log file or a CI pipe would otherwise get every progress event as its own line."""
532
+ mock_stdout.isatty.return_value = False
533
+ manager = self._manager()
534
+
535
+ manager.push()
536
+ manager.pull()
537
+
538
+ assert manager.commands == ['push --quiet', 'pull --quiet']
539
+
540
+ @patch('sys.stdout', new_callable=MagicMock)
541
+ def test_push_and_pull_keep_progress_on_a_terminal(self, mock_stdout):
542
+ """Interactively the progress is the point, so nothing changes."""
543
+ mock_stdout.isatty.return_value = True
544
+ manager = self._manager()
545
+
546
+ manager.push(['app'])
547
+ manager.pull(['app'])
548
+
549
+ assert manager.commands == ['push app', 'pull app']
@@ -114,6 +114,8 @@ class TestTunnelEnabled:
114
114
  assert command[0] == 'ssh'
115
115
  # backgrounded master with a control socket we can close deterministically
116
116
  assert '-f' in command and '-N' in command and '-M' in command
117
+ # a refused channel is only reported at verbose level, and only in the log file
118
+ assert '-v' in command
117
119
  assert command[command.index('-S') + 1] == f'{ssh.dir}/ssh.ctl'
118
120
  # a refused forward must fail loudly instead of yielding a dead socket
119
121
  assert 'ExitOnForwardFailure=yes' in command
@@ -207,6 +209,17 @@ class TestTunnelAutoDetection:
207
209
 
208
210
  assert 'did not answer' in warning.call_args[0][0]
209
211
 
212
+ def test_fallback_says_why(self, ssh):
213
+ ssh.result.stdout = ''
214
+ ssh.result.output = 'channel 2: open failed: unknown channel type: unsupported channel type\n'
215
+ manager = _manager(tunnel={})
216
+
217
+ with patch('mantis.managers.CLI.warning') as warning:
218
+ manager.docker_connection
219
+
220
+ assert 'direct-streamlocal' in warning.call_args[0][0]
221
+ assert 'ControlMaster' in warning.call_args[0][0]
222
+
210
223
  def test_failed_detection_closes_the_tunnel(self, ssh):
211
224
  ssh.result.stdout = ''
212
225
  manager = _manager(tunnel={})
@@ -259,7 +272,7 @@ class TestTunnelAutoDetection:
259
272
 
260
273
 
261
274
  class TestCheckTunnel:
262
- """check-tunnel tells apart a refused forward from a missing socket access."""
275
+ """check-tunnel tells apart the reasons a tunnel can be unusable."""
263
276
 
264
277
  def test_reports_available(self, ssh):
265
278
  ssh.result.stdout = '24.0.6\n'
@@ -311,6 +324,62 @@ class TestCheckTunnel:
311
324
  assert 'docker' in info.call_args[0][0]
312
325
  assert 'deploy' in info.call_args[0][0]
313
326
 
327
+ def test_debug_chatter_is_not_reported_to_the_user(self, ssh):
328
+ """The master runs with -v, so its log cannot be shown as it is."""
329
+ ssh.result.returncode = 255
330
+ ssh.result.output = (
331
+ 'debug1: Connecting to example.com port 2222.\n'
332
+ 'ssh: connect to host example.com port 2222: Connection refused\n'
333
+ )
334
+ manager = _manager(tunnel={'enabled': True})
335
+
336
+ with patch('mantis.managers.CLI.warning') as warning, patch('mantis.managers.CLI.danger'), \
337
+ patch('mantis.managers.CLI.info'):
338
+ manager.check_tunnel()
339
+
340
+ message = warning.call_args[0][0]
341
+
342
+ assert 'Connection refused' in message
343
+ assert 'debug1' not in message
344
+
345
+ def test_unsupported_channel_points_at_multiplexing(self, ssh):
346
+ """Not every SSH server is OpenSSH: some reject the forward channel outright."""
347
+ ssh.result.stdout = ''
348
+ ssh.result.output = 'channel 2: open failed: unknown channel type: unsupported channel type\n'
349
+ manager = _manager(tunnel={'enabled': True})
350
+
351
+ with patch('mantis.managers.CLI.danger'), patch('mantis.managers.CLI.info') as info:
352
+ assert manager.check_tunnel() is False
353
+
354
+ message = info.call_args[0][0]
355
+
356
+ # no remote_socket can fix this, so the advice must not be about the socket
357
+ assert 'direct-streamlocal' in message
358
+ assert 'ControlMaster' in message
359
+ assert 'docker' not in message.split('ControlMaster')[0]
360
+
361
+ def test_prohibited_forward_points_at_sshd_config(self, ssh):
362
+ ssh.result.stdout = ''
363
+ ssh.result.output = 'channel 2: open failed: administratively prohibited: open failed\n'
364
+ manager = _manager(tunnel={'enabled': True})
365
+
366
+ with patch('mantis.managers.CLI.danger'), patch('mantis.managers.CLI.info') as info:
367
+ assert manager.check_tunnel() is False
368
+
369
+ assert 'AllowStreamLocalForwarding' in info.call_args[0][0]
370
+
371
+ def test_reason_is_read_before_the_tunnel_is_removed(self, ssh):
372
+ """The log lives in the tunnel directory, which stop_tunnel deletes."""
373
+ ssh.result.stdout = ''
374
+ ssh.result.output = 'channel 2: open failed: unknown channel type: unsupported channel type\n'
375
+ manager = _manager(tunnel={'enabled': True})
376
+
377
+ with patch('mantis.managers.CLI.danger'), patch('mantis.managers.CLI.info') as info:
378
+ manager.check_tunnel()
379
+
380
+ assert not ssh.dir.exists()
381
+ assert 'direct-streamlocal' in info.call_args[0][0]
382
+
314
383
  def test_non_ssh_connection_is_an_error(self, ssh):
315
384
  manager = _manager(connection='context://production', tunnel={'enabled': True})
316
385
 
@@ -1 +0,0 @@
1
- VERSION = '22.3.0'
File without changes
File without changes
File without changes
File without changes
File without changes