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.
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/PKG-INFO +98 -1
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/README.md +97 -0
- mantis_cli-22.2.0/mantis/__init__.py +1 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/app.py +2 -1
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/command_line.py +5 -1
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/connection.py +10 -1
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/managers.py +238 -6
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/mantis.tpl +5 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/schema.py +17 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis_cli.egg-info/PKG-INFO +98 -1
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis_cli.egg-info/SOURCES.txt +3 -1
- mantis_cli-22.2.0/tests/test_build_config.py +143 -0
- {mantis_cli-22.0.3 → 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.0.3/mantis/__init__.py +0 -1
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/LICENSE +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/MANIFEST.in +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/__main__.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/__init__.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/compose.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/configuration.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/containers.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/core.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/django.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/images.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/nginx.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/postgres.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/secrets.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/services.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/commands/volumes.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/config.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/cryptography.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/environment.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/extensions/__init__.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/extensions/django.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/extensions/nginx.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/extensions/postgres.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis/helpers.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis_cli.egg-info/dependency_links.txt +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis_cli.egg-info/entry_points.txt +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis_cli.egg-info/requires.txt +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/mantis_cli.egg-info/top_level.txt +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/setup.cfg +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/setup.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/tests/__init__.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/tests/test_command_line.py +0 -0
- {mantis_cli-22.0.3 → mantis_cli-22.2.0}/tests/test_config.py +0 -0
- {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
|
+
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
|
-
|
|
1739
|
-
|
|
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():
|