mantis-cli 22.1.0__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.
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/PKG-INFO +70 -1
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/README.md +69 -0
- mantis_cli-22.2.0/mantis/__init__.py +1 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/app.py +2 -1
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/command_line.py +5 -1
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/commands/connection.py +10 -1
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/managers.py +235 -5
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/mantis.tpl +5 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/schema.py +17 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis_cli.egg-info/PKG-INFO +70 -1
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis_cli.egg-info/SOURCES.txt +2 -1
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/tests/test_managers.py +89 -0
- mantis_cli-22.2.0/tests/test_tunnel.py +379 -0
- mantis_cli-22.1.0/mantis/__init__.py +0 -1
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/LICENSE +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/MANIFEST.in +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/__main__.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/commands/__init__.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/commands/compose.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/commands/configuration.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/commands/containers.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/commands/core.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/commands/django.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/commands/images.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/commands/nginx.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/commands/postgres.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/commands/secrets.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/commands/services.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/commands/volumes.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/config.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/cryptography.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/environment.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/extensions/__init__.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/extensions/django.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/extensions/nginx.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/extensions/postgres.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis/helpers.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis_cli.egg-info/dependency_links.txt +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis_cli.egg-info/entry_points.txt +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis_cli.egg-info/requires.txt +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/mantis_cli.egg-info/top_level.txt +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/setup.cfg +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/setup.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/tests/__init__.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/tests/test_build_config.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/tests/test_command_line.py +0 -0
- {mantis_cli-22.1.0 → mantis_cli-22.2.0}/tests/test_config.py +0 -0
- {mantis_cli-22.1.0 → 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.
|
|
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,6 +69,10 @@ 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
|
|
@@ -144,6 +148,70 @@ Environment files are looked up directly in the `environment.folder` instead of
|
|
|
144
148
|
|
|
145
149
|
**Note:** You cannot define both `connection` and `connections` in the same config file.
|
|
146
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
|
+
|
|
147
215
|
### Encryption
|
|
148
216
|
|
|
149
217
|
If you plan to use encryption and decryption of your environment files, you need to create encryption key.
|
|
@@ -323,6 +391,7 @@ Run `mantis --help` to see all available commands with their descriptions.
|
|
|
323
391
|
| contexts | Prints all docker contexts |
|
|
324
392
|
| create-context | Creates docker context |
|
|
325
393
|
| ssh | Connects to remote host via SSH |
|
|
394
|
+
| check-tunnel | Checks if the docker socket can be tunnelled over SSH |
|
|
326
395
|
|
|
327
396
|
**Django extension:**
|
|
328
397
|
|
|
@@ -47,6 +47,10 @@ 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
|
|
@@ -122,6 +126,70 @@ Environment files are looked up directly in the `environment.folder` instead of
|
|
|
122
126
|
|
|
123
127
|
**Note:** You cannot define both `connection` and `connections` in the same config file.
|
|
124
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
|
+
|
|
125
193
|
### Encryption
|
|
126
194
|
|
|
127
195
|
If you plan to use encryption and decryption of your environment files, you need to create encryption key.
|
|
@@ -301,6 +369,7 @@ Run `mantis --help` to see all available commands with their descriptions.
|
|
|
301
369
|
| contexts | Prints all docker contexts |
|
|
302
370
|
| create-context | Creates docker context |
|
|
303
371
|
| ssh | Connects to remote host via SSH |
|
|
372
|
+
| check-tunnel | Checks if the docker socket can be tunnelled over SSH |
|
|
304
373
|
|
|
305
374
|
**Django extension:**
|
|
306
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://', '')
|
|
@@ -1668,6 +1881,11 @@ def get_extension_classes(extensions: List[str]) -> List[type]:
|
|
|
1668
1881
|
|
|
1669
1882
|
SECRETS_COMMANDS = {'show-env', 'encrypt-env', 'decrypt-env', 'check-env'}
|
|
1670
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
|
+
|
|
1671
1889
|
|
|
1672
1890
|
def validate_environment_for_commands(environment_id: str, config: Dict[str, Any], config_file: str, commands: list) -> None:
|
|
1673
1891
|
"""
|
|
@@ -1697,6 +1915,12 @@ def validate_environment_for_commands(environment_id: str, config: Dict[str, Any
|
|
|
1697
1915
|
if environment_id not in folder_envs:
|
|
1698
1916
|
CLI.error(f'Environment "{environment_id}" not available for command "{cmd}". '
|
|
1699
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)))}')
|
|
1700
1924
|
else:
|
|
1701
1925
|
# Other commands need connection or 'local'
|
|
1702
1926
|
if 'local' not in environment_id and environment_id not in connection_envs:
|
|
@@ -1729,6 +1953,7 @@ def resolve_environment(environment_id: Optional[str], config: Dict[str, Any], c
|
|
|
1729
1953
|
folder_envs = [d.name for d in env_path.iterdir() if d.is_dir()]
|
|
1730
1954
|
|
|
1731
1955
|
# For secrets commands: use folder-based environments only
|
|
1956
|
+
# For local commands: use connections + folders + local
|
|
1732
1957
|
# For other commands: use connections + local
|
|
1733
1958
|
if command in SECRETS_COMMANDS:
|
|
1734
1959
|
available_envs = folder_envs
|
|
@@ -1737,8 +1962,13 @@ def resolve_environment(environment_id: Optional[str], config: Dict[str, Any], c
|
|
|
1737
1962
|
if 'local' in environment_id:
|
|
1738
1963
|
return environment_id
|
|
1739
1964
|
|
|
1740
|
-
|
|
1741
|
-
|
|
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
|
|
1742
1972
|
|
|
1743
1973
|
# Check for exact match first
|
|
1744
1974
|
if environment_id in available_envs:
|
|
@@ -1756,7 +1986,7 @@ def resolve_environment(environment_id: Optional[str], config: Dict[str, Any], c
|
|
|
1756
1986
|
CLI.error(f'Environment "{environment_id}" not found. Available: {", ".join(sorted(available_envs))}')
|
|
1757
1987
|
|
|
1758
1988
|
|
|
1759
|
-
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:
|
|
1760
1990
|
# config file
|
|
1761
1991
|
config_file = find_config(environment_id, commands=commands)
|
|
1762
1992
|
config = load_config(config_file)
|
|
@@ -1785,7 +2015,7 @@ def get_manager(environment_id: Optional[str], mode: str, dry_run: bool = False,
|
|
|
1785
2015
|
class MantisManager(*[manager_class] + extension_classes):
|
|
1786
2016
|
pass
|
|
1787
2017
|
|
|
1788
|
-
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)
|
|
1789
2019
|
|
|
1790
2020
|
# set extensions data
|
|
1791
2021
|
for extension, extension_params in extensions.items():
|
|
@@ -38,6 +38,22 @@ class EnvironmentConfig(BaseModel):
|
|
|
38
38
|
file_prefix: str = ""
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
class TunnelConfig(BaseModel):
|
|
42
|
+
"""
|
|
43
|
+
SSH tunnel forwarding the remote docker socket to a local one.
|
|
44
|
+
|
|
45
|
+
Without it, every docker/compose call opens its own SSH connection, which bursts
|
|
46
|
+
past sshd's MaxStartups (and multiplies ProxyCommand processes). With it, the whole
|
|
47
|
+
mantis run shares a single SSH connection.
|
|
48
|
+
|
|
49
|
+
Leave "enabled" unset to detect availability per run, set it to true to skip that
|
|
50
|
+
detection on a server known to support it, or to false to never tunnel at all.
|
|
51
|
+
"""
|
|
52
|
+
enabled: Optional[bool] = None
|
|
53
|
+
remote_socket: str = "/var/run/docker.sock"
|
|
54
|
+
ssh_options: List[str] = Field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
|
|
41
57
|
class MantisConfig(BaseModel):
|
|
42
58
|
"""Main mantis configuration schema."""
|
|
43
59
|
# Extensions
|
|
@@ -57,6 +73,7 @@ class MantisConfig(BaseModel):
|
|
|
57
73
|
# Connections (mutually exclusive)
|
|
58
74
|
connection: Optional[str] = None
|
|
59
75
|
connections: Dict[str, str] = Field(default_factory=dict)
|
|
76
|
+
tunnel: TunnelConfig = Field(default_factory=TunnelConfig)
|
|
60
77
|
|
|
61
78
|
# Custom manager class
|
|
62
79
|
manager_class: str = "mantis.managers.BaseManager"
|