mantis-cli 22.0.3__tar.gz → 22.2.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.0.3 → mantis_cli-22.2.0}/PKG-INFO +98 -1
  2. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/README.md +97 -0
  3. mantis_cli-22.2.0/mantis/__init__.py +1 -0
  4. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/app.py +2 -1
  5. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/command_line.py +5 -1
  6. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/connection.py +10 -1
  7. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/managers.py +238 -6
  8. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/mantis.tpl +5 -0
  9. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/schema.py +17 -0
  10. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis_cli.egg-info/PKG-INFO +98 -1
  11. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis_cli.egg-info/SOURCES.txt +3 -1
  12. mantis_cli-22.2.0/tests/test_build_config.py +143 -0
  13. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/tests/test_managers.py +89 -0
  14. mantis_cli-22.2.0/tests/test_tunnel.py +379 -0
  15. mantis_cli-22.0.3/mantis/__init__.py +0 -1
  16. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/LICENSE +0 -0
  17. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/MANIFEST.in +0 -0
  18. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/__main__.py +0 -0
  19. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/__init__.py +0 -0
  20. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/compose.py +0 -0
  21. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/configuration.py +0 -0
  22. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/containers.py +0 -0
  23. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/core.py +0 -0
  24. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/django.py +0 -0
  25. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/images.py +0 -0
  26. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/nginx.py +0 -0
  27. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/postgres.py +0 -0
  28. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/secrets.py +0 -0
  29. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/services.py +0 -0
  30. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/volumes.py +0 -0
  31. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/config.py +0 -0
  32. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/cryptography.py +0 -0
  33. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/environment.py +0 -0
  34. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/extensions/__init__.py +0 -0
  35. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/extensions/django.py +0 -0
  36. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/extensions/nginx.py +0 -0
  37. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/extensions/postgres.py +0 -0
  38. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/helpers.py +0 -0
  39. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis_cli.egg-info/dependency_links.txt +0 -0
  40. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis_cli.egg-info/entry_points.txt +0 -0
  41. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis_cli.egg-info/requires.txt +0 -0
  42. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis_cli.egg-info/top_level.txt +0 -0
  43. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/setup.cfg +0 -0
  44. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/setup.py +0 -0
  45. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/tests/__init__.py +0 -0
  46. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/tests/test_command_line.py +0 -0
  47. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/tests/test_config.py +0 -0
  48. {mantis_cli-22.0.3 → mantis_cli-22.2.0}/tests/test_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mantis_cli
3
- Version: 22.0.3
3
+ Version: 22.2.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ý
@@ -69,12 +69,44 @@ You can use ``<MANTIS>`` variable in your paths if needed as a relative referenc
69
69
  | project_path | string | path to folder with project files on remote server |
70
70
  | connection | string | single connection string (use instead of connections) |
71
71
  | connections | dict | definition of your connections for each environment |
72
+ | tunnel | dict | SSH tunnel settings |
73
+ | tunnel.enabled | bool | share a single SSH connection for the whole run (null = auto)|
74
+ | tunnel.remote_socket | string | path to the docker socket on the server |
75
+ | tunnel.ssh_options | array | extra options passed to the ssh command |
72
76
 
73
77
  TODO:
74
78
  - default values
75
79
 
76
80
  See [template file](https://github.com/PragmaticMates/mantis-cli/blob/master/mantis/mantis.tpl) for exact JSON structure.
77
81
 
82
+ ### Build settings
83
+
84
+ With `build.tool` set to `"docker"`, mantis builds each service that declares a `build:` section
85
+ in your compose files, reading `context`, `dockerfile`, `args`, `platform`, `cache_from` and
86
+ `cache_to` from it. Nothing else needs configuring — the compose file stays the single source of
87
+ truth:
88
+
89
+ ```yaml
90
+ services:
91
+ backend:
92
+ image: acme/app:production
93
+ build:
94
+ context: ../../../
95
+ dockerfile: ./configs/docker/production/backend/Dockerfile
96
+ cache_from:
97
+ - type=registry,ref=acme/app:production
98
+ cache_to:
99
+ - type=inline
100
+ ```
101
+
102
+ `cache_from` and `cache_to` become `--cache-from` / `--cache-to` on `docker build`, which is how
103
+ you keep a layer cache across machines that share no local docker state — CI runners, for
104
+ instance. Inline cache stores the metadata in the image being pushed and reads it back from the
105
+ previous one, so no separate cache tag is needed; note that it records only the final image's
106
+ layers, which is enough whenever earlier build stages copy their output forward.
107
+
108
+ With `build.tool` set to `"compose"`, `docker compose build` handles all of this itself.
109
+
78
110
  ### Connections
79
111
 
80
112
  Mantis supports two connection modes: **multi-environment** and **single connection**.
@@ -116,6 +148,70 @@ Environment files are looked up directly in the `environment.folder` instead of
116
148
 
117
149
  **Note:** You cannot define both `connection` and `connections` in the same config file.
118
150
 
151
+ #### SSH tunnel
152
+
153
+ With a plain `ssh://` connection, Docker opens a **separate SSH connection for every
154
+ command** mantis runs. A single deployment issues dozens of them, and `compose pull` fans
155
+ out over all services at once. That burst runs into sshd's `MaxStartups`, which drops
156
+ connections beyond its threshold:
157
+
158
+ ```
159
+ kex_exchange_identification: read: Connection reset by peer
160
+ ```
161
+
162
+ It is also slow when the connection goes through a `ProxyCommand` (Cloudflare Access,
163
+ bastion hosts, ...), because each connection spawns its own proxy process.
164
+
165
+ The tunnel makes mantis open **one** SSH connection per run, forwarding the remote docker
166
+ socket to a local one and pointing every docker command at it.
167
+
168
+ Docker itself offers no way to pass `ssh` options (`ControlMaster` and friends), so this
169
+ is handled entirely by mantis — no `~/.ssh/config` changes are needed, and connection
170
+ details keep coming from your `ssh://` connection string. Unlike `ControlMaster`
171
+ multiplexing, forwarded channels are not counted against sshd's `MaxSessions`, so no
172
+ server-side configuration is required either.
173
+
174
+ `tunnel.enabled` decides how much mantis verifies before trusting it:
175
+
176
+ | value | behaviour |
177
+ |-------|-----------|
178
+ | unset (default) | detect per run: tunnel, and keep it only if the docker daemon answers through it |
179
+ | `true` | tunnel without detecting, for a server already known to support it |
180
+ | `false` | never tunnel |
181
+
182
+ ```json
183
+ "tunnel": {
184
+ "enabled": true
185
+ }
186
+ ```
187
+
188
+ Detection costs one extra round-trip per run and cannot be skipped by opening the forward
189
+ alone: `ssh -L` to a unix socket succeeds even when nothing listens on the far end, so a
190
+ tunnel only proves itself once the daemon has replied. Set `enabled` explicitly once
191
+ `check-tunnel` (below) confirms a server, and that round-trip goes away.
192
+
193
+ Requirements on the server: the connecting user must be able to read the docker socket
194
+ (typically membership in the `docker` group), and `AllowStreamLocalForwarding` must not
195
+ be disabled — it defaults to `yes`. Whenever the tunnel cannot be used, mantis warns and
196
+ falls back to a connection per docker command, so deployments keep working either way.
197
+
198
+ The check ignores `tunnel.enabled`, so it answers "would this work" rather than "is this
199
+ on" — use it before setting `enabled` to `true`:
200
+
201
+ ```bash
202
+ mantis -e production check-tunnel
203
+ ```
204
+
205
+ It opens the tunnel, asks the remote docker daemon for its version and closes it again,
206
+ exiting non-zero on failure. A refused forward and an unreachable socket are reported
207
+ separately, since they need different fixes (`sshd_config` versus group membership).
208
+
209
+ Pass `--no-tunnel` to disable it for a single run:
210
+
211
+ ```bash
212
+ mantis -e production --no-tunnel deploy
213
+ ```
214
+
119
215
  ### Encryption
120
216
 
121
217
  If you plan to use encryption and decryption of your environment files, you need to create encryption key.
@@ -295,6 +391,7 @@ Run `mantis --help` to see all available commands with their descriptions.
295
391
  | contexts | Prints all docker contexts |
296
392
  | create-context | Creates docker context |
297
393
  | ssh | Connects to remote host via SSH |
394
+ | check-tunnel | Checks if the docker socket can be tunnelled over SSH |
298
395
 
299
396
  **Django extension:**
300
397
 
@@ -47,12 +47,44 @@ You can use ``<MANTIS>`` variable in your paths if needed as a relative referenc
47
47
  | project_path | string | path to folder with project files on remote server |
48
48
  | connection | string | single connection string (use instead of connections) |
49
49
  | connections | dict | definition of your connections for each environment |
50
+ | tunnel | dict | SSH tunnel settings |
51
+ | tunnel.enabled | bool | share a single SSH connection for the whole run (null = auto)|
52
+ | tunnel.remote_socket | string | path to the docker socket on the server |
53
+ | tunnel.ssh_options | array | extra options passed to the ssh command |
50
54
 
51
55
  TODO:
52
56
  - default values
53
57
 
54
58
  See [template file](https://github.com/PragmaticMates/mantis-cli/blob/master/mantis/mantis.tpl) for exact JSON structure.
55
59
 
60
+ ### Build settings
61
+
62
+ With `build.tool` set to `"docker"`, mantis builds each service that declares a `build:` section
63
+ in your compose files, reading `context`, `dockerfile`, `args`, `platform`, `cache_from` and
64
+ `cache_to` from it. Nothing else needs configuring — the compose file stays the single source of
65
+ truth:
66
+
67
+ ```yaml
68
+ services:
69
+ backend:
70
+ image: acme/app:production
71
+ build:
72
+ context: ../../../
73
+ dockerfile: ./configs/docker/production/backend/Dockerfile
74
+ cache_from:
75
+ - type=registry,ref=acme/app:production
76
+ cache_to:
77
+ - type=inline
78
+ ```
79
+
80
+ `cache_from` and `cache_to` become `--cache-from` / `--cache-to` on `docker build`, which is how
81
+ you keep a layer cache across machines that share no local docker state — CI runners, for
82
+ instance. Inline cache stores the metadata in the image being pushed and reads it back from the
83
+ previous one, so no separate cache tag is needed; note that it records only the final image's
84
+ layers, which is enough whenever earlier build stages copy their output forward.
85
+
86
+ With `build.tool` set to `"compose"`, `docker compose build` handles all of this itself.
87
+
56
88
  ### Connections
57
89
 
58
90
  Mantis supports two connection modes: **multi-environment** and **single connection**.
@@ -94,6 +126,70 @@ Environment files are looked up directly in the `environment.folder` instead of
94
126
 
95
127
  **Note:** You cannot define both `connection` and `connections` in the same config file.
96
128
 
129
+ #### SSH tunnel
130
+
131
+ With a plain `ssh://` connection, Docker opens a **separate SSH connection for every
132
+ command** mantis runs. A single deployment issues dozens of them, and `compose pull` fans
133
+ out over all services at once. That burst runs into sshd's `MaxStartups`, which drops
134
+ connections beyond its threshold:
135
+
136
+ ```
137
+ kex_exchange_identification: read: Connection reset by peer
138
+ ```
139
+
140
+ It is also slow when the connection goes through a `ProxyCommand` (Cloudflare Access,
141
+ bastion hosts, ...), because each connection spawns its own proxy process.
142
+
143
+ The tunnel makes mantis open **one** SSH connection per run, forwarding the remote docker
144
+ socket to a local one and pointing every docker command at it.
145
+
146
+ Docker itself offers no way to pass `ssh` options (`ControlMaster` and friends), so this
147
+ is handled entirely by mantis — no `~/.ssh/config` changes are needed, and connection
148
+ details keep coming from your `ssh://` connection string. Unlike `ControlMaster`
149
+ multiplexing, forwarded channels are not counted against sshd's `MaxSessions`, so no
150
+ server-side configuration is required either.
151
+
152
+ `tunnel.enabled` decides how much mantis verifies before trusting it:
153
+
154
+ | value | behaviour |
155
+ |-------|-----------|
156
+ | unset (default) | detect per run: tunnel, and keep it only if the docker daemon answers through it |
157
+ | `true` | tunnel without detecting, for a server already known to support it |
158
+ | `false` | never tunnel |
159
+
160
+ ```json
161
+ "tunnel": {
162
+ "enabled": true
163
+ }
164
+ ```
165
+
166
+ Detection costs one extra round-trip per run and cannot be skipped by opening the forward
167
+ alone: `ssh -L` to a unix socket succeeds even when nothing listens on the far end, so a
168
+ tunnel only proves itself once the daemon has replied. Set `enabled` explicitly once
169
+ `check-tunnel` (below) confirms a server, and that round-trip goes away.
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.
175
+
176
+ The check ignores `tunnel.enabled`, so it answers "would this work" rather than "is this
177
+ on" — use it before setting `enabled` to `true`:
178
+
179
+ ```bash
180
+ mantis -e production check-tunnel
181
+ ```
182
+
183
+ It opens the tunnel, asks the remote docker daemon for its version and closes it again,
184
+ exiting non-zero on failure. A refused forward and an unreachable socket are reported
185
+ separately, since they need different fixes (`sshd_config` versus group membership).
186
+
187
+ Pass `--no-tunnel` to disable it for a single run:
188
+
189
+ ```bash
190
+ mantis -e production --no-tunnel deploy
191
+ ```
192
+
97
193
  ### Encryption
98
194
 
99
195
  If you plan to use encryption and decryption of your environment files, you need to create encryption key.
@@ -273,6 +369,7 @@ Run `mantis --help` to see all available commands with their descriptions.
273
369
  | contexts | Prints all docker contexts |
274
370
  | create-context | Creates docker context |
275
371
  | ssh | Connects to remote host via SSH |
372
+ | check-tunnel | Checks if the docker socket can be tunnelled over SSH |
276
373
 
277
374
  **Django extension:**
278
375
 
@@ -0,0 +1 @@
1
+ VERSION = '22.2.0'
@@ -191,6 +191,7 @@ def main(
191
191
  environment: Optional[str] = typer.Option(None, "--env", "-e", help="Environment ID"),
192
192
  mode: str = typer.Option("remote", "--mode", "-m", help="Execution mode: remote, ssh, host"),
193
193
  dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show commands without executing"),
194
+ no_tunnel: bool = typer.Option(False, "--no-tunnel", help="Do not tunnel the remote docker socket over a single SSH connection"),
194
195
  version: bool = typer.Option(False, "--version", "-v", callback=version_callback, is_eager=True, help="Show version and exit"),
195
196
  ):
196
197
  """Mantis CLI - Docker deployment tool."""
@@ -216,4 +217,4 @@ def main(
216
217
 
217
218
  state._mode = mode
218
219
  state._dry_run = dry_run
219
- state._manager = get_manager(environment, mode, dry_run=dry_run, commands=commands)
220
+ state._manager = get_manager(environment, mode, dry_run=dry_run, commands=commands, use_tunnel=not no_tunnel)
@@ -99,6 +99,7 @@ def parse_global_options(global_opts: List[str]) -> dict:
99
99
  'env': None,
100
100
  'mode': 'remote',
101
101
  'dry_run': False,
102
+ 'use_tunnel': True,
102
103
  }
103
104
 
104
105
  i = 0
@@ -113,6 +114,9 @@ def parse_global_options(global_opts: List[str]) -> dict:
113
114
  elif opt in ('-n', '--dry-run'):
114
115
  result['dry_run'] = True
115
116
  i += 1
117
+ elif opt == '--no-tunnel':
118
+ result['use_tunnel'] = False
119
+ i += 1
116
120
  else:
117
121
  i += 1
118
122
 
@@ -185,7 +189,7 @@ def run():
185
189
  all_commands = [group[0] for group in cmd_groups if group]
186
190
  state._mode = opts['mode']
187
191
  state._dry_run = opts['dry_run']
188
- state._manager = get_manager(opts['env'], opts['mode'], dry_run=opts['dry_run'], commands=all_commands)
192
+ state._manager = get_manager(opts['env'], opts['mode'], dry_run=opts['dry_run'], commands=all_commands, use_tunnel=opts['use_tunnel'])
189
193
 
190
194
  # Get Click app from Typer
191
195
  click_app = typer.main.get_command(app)
@@ -1,4 +1,6 @@
1
- """Connection commands: contexts, create-context, ssh."""
1
+ """Connection commands: contexts, create-context, ssh, check-tunnel."""
2
+ import typer
3
+
2
4
  from mantis.app import command, state
3
5
 
4
6
 
@@ -18,3 +20,10 @@ def create_context():
18
20
  def ssh_cmd():
19
21
  """Connects to remote host via SSH"""
20
22
  state.ssh()
23
+
24
+
25
+ @command(name="check-tunnel", panel="Connections")
26
+ def check_tunnel():
27
+ """Checks if the docker socket can be tunnelled over SSH"""
28
+ if not state.check_tunnel():
29
+ raise typer.Exit(code=1)
@@ -1,8 +1,12 @@
1
1
  import asyncio
2
+ import atexit
2
3
  import json
3
4
  import os
5
+ import shutil
4
6
  import subprocess
5
7
  import sys
8
+ import tempfile
9
+ import threading
6
10
  import time
7
11
  import yaml
8
12
  from collections import defaultdict
@@ -27,11 +31,19 @@ class AbstractManager(object):
27
31
  """
28
32
  environment_id = None
29
33
 
30
- def __init__(self, config_file: str = None, environment_id: str = None, mode: str = 'remote', dry_run: bool = False):
34
+ def __init__(self, config_file: str = None, environment_id: str = None, mode: str = 'remote', dry_run: bool = False, use_tunnel: bool = True):
31
35
  self.environment_id = environment_id
32
36
  self.mode = mode
33
37
  self.dry_run = dry_run
34
38
 
39
+ # SSH tunnel state (see ensure_tunnel)
40
+ self.use_tunnel = use_tunnel
41
+ self._tunnel_lock = threading.Lock()
42
+ self._tunnel_socket = None
43
+ self._tunnel_control_socket = None
44
+ self._tunnel_dir = None
45
+ self._tunnel_failed = False
46
+
35
47
  # config file
36
48
  self.config_file = config_file
37
49
 
@@ -118,6 +130,202 @@ class AbstractManager(object):
118
130
 
119
131
  return details
120
132
 
133
+ @property
134
+ def tunnel_config(self) -> Dict[str, Any]:
135
+ return self.config.get('tunnel', {})
136
+
137
+ def ensure_tunnel(self) -> Optional[str]:
138
+ """
139
+ Returns path of a local unix socket forwarded to the remote docker socket,
140
+ opening the SSH master connection on first use.
141
+
142
+ Returns None when tunnelling does not apply (disabled, non-ssh connection, ...) or
143
+ when it turns out to be unavailable. In both cases the caller falls back to a
144
+ plain ssh:// docker host.
145
+
146
+ An explicitly enabled tunnel is used straight away. An unset one is detected: the
147
+ forward succeeds even when nothing listens on the remote socket, so availability
148
+ is only known once the docker daemon has answered through it.
149
+ """
150
+ if self._tunnel_socket:
151
+ return self._tunnel_socket
152
+
153
+ with self._tunnel_lock:
154
+ # another thread may have opened it meanwhile
155
+ if self._tunnel_socket or self._tunnel_failed:
156
+ return self._tunnel_socket
157
+
158
+ enabled = self.tunnel_config.get('enabled')
159
+
160
+ if not self.use_tunnel or enabled is False:
161
+ return None
162
+
163
+ if self.mode != 'remote':
164
+ return None
165
+
166
+ if not self.connection or not self.connection.startswith('ssh://'):
167
+ return None
168
+
169
+ if not self.host or not self.user:
170
+ return None
171
+
172
+ # an explicitly enabled tunnel is trusted, an unset one is verified
173
+ detect = enabled is None and not self.dry_run
174
+
175
+ if detect:
176
+ CLI.info('Tunnel not configured, checking if it is available...')
177
+
178
+ # assigned before probing, so the docker call below does not re-enter here
179
+ self._tunnel_socket = self.start_tunnel()
180
+
181
+ if self._tunnel_socket and detect:
182
+ version = self.probe_tunnel()
183
+
184
+ if version:
185
+ CLI.success(f'Tunnel is available (docker {version}). '
186
+ f'Enable it in config to skip this check.')
187
+ else:
188
+ CLI.warning(
189
+ 'Docker daemon did not answer through the SSH tunnel.\n'
190
+ 'Falling back to a separate SSH connection per docker command.'
191
+ )
192
+ self.stop_tunnel()
193
+
194
+ self._tunnel_failed = self._tunnel_socket is None
195
+
196
+ return self._tunnel_socket
197
+
198
+ def probe_tunnel(self) -> Optional[str]:
199
+ """
200
+ Returns the version of the docker daemon answering through the tunnel,
201
+ or None when nothing answers on the remote socket.
202
+ """
203
+ version = self.docker('version --format "{{.Server.Version}}"', return_output=True)
204
+ version = version.strip() if version else ''
205
+
206
+ return version or None
207
+
208
+ def start_tunnel(self) -> Optional[str]:
209
+ """
210
+ Opens a single backgrounded SSH master forwarding the remote docker socket.
211
+ Every docker command then reuses it instead of opening its own connection.
212
+ """
213
+ remote_socket = self.tunnel_config.get('remote_socket', '/var/run/docker.sock')
214
+ ssh_options = self.tunnel_config.get('ssh_options', [])
215
+
216
+ # unix socket paths are limited to ~104 characters, so prefer a shallow base
217
+ # directory over the default temp folder (macOS uses a deep /var/folders/... one)
218
+ base_dir = '/tmp' if Path('/tmp').is_dir() else None
219
+
220
+ self._tunnel_dir = tempfile.mkdtemp(prefix='mantis-', dir=base_dir)
221
+ local_socket = str(Path(self._tunnel_dir) / 'docker.sock')
222
+ control_socket = str(Path(self._tunnel_dir) / 'ssh.ctl')
223
+
224
+ command = [
225
+ 'ssh', '-f', '-N', '-M',
226
+ '-S', control_socket,
227
+ '-o', 'ExitOnForwardFailure=yes',
228
+ '-o', 'ServerAliveInterval=30',
229
+ '-o', 'ServerAliveCountMax=3',
230
+ '-L', f'{local_socket}:{remote_socket}',
231
+ '-p', str(self.port or 22),
232
+ *ssh_options,
233
+ f'{self.user}@{self.host}',
234
+ ]
235
+
236
+ if self.dry_run:
237
+ CLI.warning(f'[DRY-RUN] {" ".join(command)}')
238
+ self.remove_tunnel_dir()
239
+ return local_socket
240
+
241
+ CLI.info(f'Opening SSH tunnel to {self.user}@{self.host}...')
242
+
243
+ # ssh -f keeps its inherited output open in the backgrounded process, so a pipe
244
+ # would never reach EOF and subprocess.run would block forever. Use a file.
245
+ output_file = Path(self._tunnel_dir) / 'ssh.log'
246
+
247
+ with open(output_file, 'w') as output:
248
+ result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=output, stderr=output)
249
+
250
+ if result.returncode != 0:
251
+ CLI.warning(
252
+ f'Failed to open SSH tunnel: {output_file.read_text().strip()}\n'
253
+ f'Falling back to a separate SSH connection per docker command.'
254
+ )
255
+ self.remove_tunnel_dir()
256
+ return None
257
+
258
+ self._tunnel_control_socket = control_socket
259
+ atexit.register(self.stop_tunnel)
260
+
261
+ return local_socket
262
+
263
+ def stop_tunnel(self) -> None:
264
+ """
265
+ Closes the SSH master connection and removes its temporary files. Idempotent.
266
+ """
267
+ if self._tunnel_control_socket:
268
+ subprocess.run(
269
+ ['ssh', '-S', self._tunnel_control_socket, '-O', 'exit', f'{self.user}@{self.host}'],
270
+ capture_output=True, text=True
271
+ )
272
+ self._tunnel_control_socket = None
273
+
274
+ self._tunnel_socket = None
275
+ self.remove_tunnel_dir()
276
+
277
+ def remove_tunnel_dir(self) -> None:
278
+ if self._tunnel_dir:
279
+ shutil.rmtree(self._tunnel_dir, ignore_errors=True)
280
+ self._tunnel_dir = None
281
+
282
+ def check_tunnel(self) -> bool:
283
+ """
284
+ Verifies the SSH tunnel can be used for this connection, regardless of whether it
285
+ is enabled in the config. Opens it, queries the remote docker daemon and closes it
286
+ again, telling apart a refused forward from a missing access to the docker socket.
287
+ """
288
+ if self.mode != 'remote':
289
+ CLI.error(f'Tunnel is only used in remote mode, not in "{self.mode}" mode.')
290
+
291
+ if not self.connection:
292
+ env_info = f' for environment {self.environment.id}' if self.environment.id else ''
293
+ CLI.error(f'Connection{env_info} not defined!')
294
+
295
+ if not self.connection.startswith('ssh://'):
296
+ CLI.error(f'Tunnel requires an ssh:// connection, but this one is "{self.connection}".')
297
+
298
+ remote_socket = self.tunnel_config.get('remote_socket', '/var/run/docker.sock')
299
+
300
+ if self.tunnel_config.get('enabled') is False:
301
+ CLI.warning('Tunnel is disabled in config, checking anyway...')
302
+
303
+ CLI.step(1, 2, f'Forwarding {self.user}@{self.host}:{remote_socket}')
304
+
305
+ # bypass ensure_tunnel, the point of the check is to ignore the config
306
+ self._tunnel_socket = self.start_tunnel()
307
+
308
+ if not self._tunnel_socket:
309
+ CLI.danger('Tunnel is NOT available.')
310
+ CLI.info(f'The server may not allow forwarding of unix sockets. '
311
+ f'Check "AllowStreamLocalForwarding" in its sshd_config.')
312
+ return False
313
+
314
+ CLI.step(2, 2, 'Querying docker daemon through the tunnel')
315
+
316
+ version = self.probe_tunnel()
317
+
318
+ self.stop_tunnel()
319
+
320
+ if not version:
321
+ CLI.danger('Tunnel is NOT available.')
322
+ CLI.info(f'The forward works, but the docker daemon did not answer on {remote_socket}. '
323
+ f'Make sure user "{self.user}" can access it (usually by being in the "docker" group).')
324
+ return False
325
+
326
+ CLI.success(f'Tunnel is available (docker {version}).')
327
+ return True
328
+
121
329
  @property
122
330
  def docker_connection(self) -> str:
123
331
  # In single connection mode or when env.id contains 'local', no extra connection needed
@@ -129,6 +337,11 @@ class AbstractManager(object):
129
337
  env_info = f' for environment {self.environment.id}' if self.environment.id else ''
130
338
  CLI.error(f'Connection{env_info} not defined!')
131
339
  if self.connection.startswith('ssh://'):
340
+ tunnel_socket = self.ensure_tunnel()
341
+
342
+ if tunnel_socket:
343
+ return f'DOCKER_HOST="unix://{tunnel_socket}"'
344
+
132
345
  return f'DOCKER_HOST="{self.connection}"'
133
346
  elif self.connection.startswith('context://'):
134
347
  context_name = self.connection.replace('context://', '')
@@ -823,6 +1036,7 @@ class BaseManager(AbstractManager):
823
1036
  for service, info in self.services_to_build().items():
824
1037
  platform = f"--platform={info['platform']}" if info['platform'] != '' else ''
825
1038
  cache_from = ' '.join([f"--cache-from {cache}" for cache in info['cache_from']]) if info['cache_from'] != [] else ''
1039
+ cache_to = ' '.join([f"--cache-to {cache}" for cache in info['cache_to']]) if info['cache_to'] != [] else ''
826
1040
  args = ' '.join([f"--build-arg {key}={value}" for key, value in info['args'].items()]) if info['args'] != {} else ''
827
1041
  image = info['image'] if info['image'] != '' else f"{info['project_name']}-{service}".lstrip('-')
828
1042
 
@@ -831,7 +1045,7 @@ class BaseManager(AbstractManager):
831
1045
  dockerfile = str(Path(context) / info['dockerfile'])
832
1046
 
833
1047
  self.docker(
834
- f"build {context} {build_args} {args} {platform} {cache_from} -t {image} -f {dockerfile} {params}",
1048
+ f"build {context} {build_args} {args} {platform} {cache_from} {cache_to} -t {image} -f {dockerfile} {params}",
835
1049
  use_connection=False)
836
1050
  else:
837
1051
  CLI.error(f'Unknown build tool: {build_tool}. Available tools: {", ".join(available_tools)}')
@@ -899,6 +1113,7 @@ class BaseManager(AbstractManager):
899
1113
  'dockerfile': build.get('dockerfile', 'Dockerfile'),
900
1114
  'context': build.get('context', '.'),
901
1115
  'cache_from': build.get('cache_from', []),
1116
+ 'cache_to': build.get('cache_to', []),
902
1117
  'args': build.get('args', {}),
903
1118
  'image': service_config.get('image', ''),
904
1119
  'platform': service_config.get('platform', '')
@@ -1666,6 +1881,11 @@ def get_extension_classes(extensions: List[str]) -> List[type]:
1666
1881
 
1667
1882
  SECRETS_COMMANDS = {'show-env', 'encrypt-env', 'decrypt-env', 'check-env'}
1668
1883
 
1884
+ # Commands running against the local docker daemon, so they need no connection
1885
+ # (see use_connection=False in build and push). They accept any environment which is
1886
+ # either defined as a connection or has an environment folder.
1887
+ LOCAL_COMMANDS = {'build', 'b', 'push', 'p'}
1888
+
1669
1889
 
1670
1890
  def validate_environment_for_commands(environment_id: str, config: Dict[str, Any], config_file: str, commands: list) -> None:
1671
1891
  """
@@ -1695,6 +1915,12 @@ def validate_environment_for_commands(environment_id: str, config: Dict[str, Any
1695
1915
  if environment_id not in folder_envs:
1696
1916
  CLI.error(f'Environment "{environment_id}" not available for command "{cmd}". '
1697
1917
  f'Available environments (folders): {", ".join(sorted(folder_envs)) if folder_envs else "none"}')
1918
+ elif cmd in LOCAL_COMMANDS:
1919
+ # Local command needs no connection, a folder-based environment is enough
1920
+ if 'local' not in environment_id and environment_id not in connection_envs and environment_id not in folder_envs:
1921
+ available = ['local'] + connection_envs + folder_envs
1922
+ CLI.error(f'Environment "{environment_id}" not available for command "{cmd}". '
1923
+ f'Available environments: {", ".join(sorted(set(available)))}')
1698
1924
  else:
1699
1925
  # Other commands need connection or 'local'
1700
1926
  if 'local' not in environment_id and environment_id not in connection_envs:
@@ -1727,6 +1953,7 @@ def resolve_environment(environment_id: Optional[str], config: Dict[str, Any], c
1727
1953
  folder_envs = [d.name for d in env_path.iterdir() if d.is_dir()]
1728
1954
 
1729
1955
  # For secrets commands: use folder-based environments only
1956
+ # For local commands: use connections + folders + local
1730
1957
  # For other commands: use connections + local
1731
1958
  if command in SECRETS_COMMANDS:
1732
1959
  available_envs = folder_envs
@@ -1735,8 +1962,13 @@ def resolve_environment(environment_id: Optional[str], config: Dict[str, Any], c
1735
1962
  if 'local' in environment_id:
1736
1963
  return environment_id
1737
1964
 
1738
- connections = config.get('connections', {})
1739
- available_envs = ['local'] + list(connections.keys())
1965
+ connection_envs = list(config.get('connections', {}).keys())
1966
+
1967
+ if command in LOCAL_COMMANDS:
1968
+ # no connection needed, a folder-based environment is enough
1969
+ available_envs = list(dict.fromkeys(['local'] + connection_envs + folder_envs))
1970
+ else:
1971
+ available_envs = ['local'] + connection_envs
1740
1972
 
1741
1973
  # Check for exact match first
1742
1974
  if environment_id in available_envs:
@@ -1754,7 +1986,7 @@ def resolve_environment(environment_id: Optional[str], config: Dict[str, Any], c
1754
1986
  CLI.error(f'Environment "{environment_id}" not found. Available: {", ".join(sorted(available_envs))}')
1755
1987
 
1756
1988
 
1757
- def get_manager(environment_id: Optional[str], mode: str, dry_run: bool = False, commands: list = None) -> BaseManager:
1989
+ def get_manager(environment_id: Optional[str], mode: str, dry_run: bool = False, commands: list = None, use_tunnel: bool = True) -> BaseManager:
1758
1990
  # config file
1759
1991
  config_file = find_config(environment_id, commands=commands)
1760
1992
  config = load_config(config_file)
@@ -1783,7 +2015,7 @@ def get_manager(environment_id: Optional[str], mode: str, dry_run: bool = False,
1783
2015
  class MantisManager(*[manager_class] + extension_classes):
1784
2016
  pass
1785
2017
 
1786
- manager = MantisManager(config_file=config_file, environment_id=environment_id, mode=mode, dry_run=dry_run)
2018
+ manager = MantisManager(config_file=config_file, environment_id=environment_id, mode=mode, dry_run=dry_run, use_tunnel=use_tunnel)
1787
2019
 
1788
2020
  # set extensions data
1789
2021
  for extension, extension_params in extensions.items():
@@ -33,5 +33,10 @@
33
33
  "project_path": "~",
34
34
  "connection": null,
35
35
  "connections": {
36
+ },
37
+ "tunnel": {
38
+ "enabled": null,
39
+ "remote_socket": "/var/run/docker.sock",
40
+ "ssh_options": []
36
41
  }
37
42
  }