seto 2.5.0__py3-none-any.whl

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.
seto/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ # Copyright 2024 Sébastien Demanou. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ # ==============================================================================
seto/__main__.py ADDED
@@ -0,0 +1,224 @@
1
+ # Copyright 2024 Sébastien Demanou. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ # ==============================================================================
15
+ import argparse
16
+ import os
17
+ import sys
18
+
19
+ from paramiko.ssh_exception import AuthenticationException
20
+
21
+ from .commands.config import execute_config_command
22
+ from .commands.deploy import execute_deploy_command
23
+ from .commands.down import execute_down_command
24
+ from .commands.mount import execute_mount_volumes_command
25
+ from .commands.setup import execute_setup_command
26
+ from .commands.setup import print_ssh_copy_id_commands
27
+ from .commands.umount import execute_umount_volumes_command
28
+ from .commands.volumes import execute_create_volumes_command
29
+ from .core.shell import Setting
30
+ from .drivers.gluster import GlusterDriver
31
+ from .drivers.nfs import NFSDriver
32
+ from .shells.local import LocalShell
33
+ from .shells.remote import RemoteShell
34
+
35
+ DriverType = type[str]
36
+ ShellConnectionString = type[str]
37
+
38
+
39
+ driver_shemes = ['nfs://', 'glusterfs://']
40
+ driver_examples = [f'{sheme}username:password@hostname' for sheme in driver_shemes]
41
+ DRIVER_EXAMPLES_STR = ' or '.join(driver_examples)
42
+
43
+
44
+ def manager_type(value: str) -> tuple[DriverType, ShellConnectionString]:
45
+ if not any(value.startswith(scheme) for scheme in driver_shemes):
46
+ raise argparse.ArgumentError('manager', f'{value} is not a valid manager ({DRIVER_EXAMPLES_STR})')
47
+
48
+ return value.split('://')
49
+
50
+
51
+ def replica_type(value: str) -> list[Setting]:
52
+ connection_strings = value.split(' ')
53
+
54
+ try:
55
+ return [
56
+ Setting.from_connection_string(item.strip()) for item in connection_strings
57
+ ]
58
+ except Exception as exception:
59
+ raise argparse.ArgumentError('replica', f'{value} is not a replica value (username:password@hostname)') from exception
60
+
61
+
62
+ def nodes_type(value: str) -> list[Setting]:
63
+ connection_strings = value.split(' ')
64
+
65
+ try:
66
+ return [
67
+ Setting.from_connection_string(item.strip()) for item in connection_strings
68
+ ]
69
+ except Exception as exception:
70
+ raise argparse.ArgumentError('nodes', f'{value} is not a nodes value (username:password@hostname)') from exception
71
+
72
+
73
+ def main() -> None:
74
+ parser = argparse.ArgumentParser('seto', description='')
75
+ subparsers = parser.add_subparsers()
76
+
77
+ parser.add_argument('--project', required=True, help='Project name')
78
+ parser.add_argument('--stack', help='Stack name')
79
+
80
+ parser.add_argument(
81
+ '--manager',
82
+ type=manager_type,
83
+ required=True,
84
+ help=f'Storage manager URI to use. Can be {DRIVER_EXAMPLES_STR}',
85
+ )
86
+
87
+ parser.add_argument(
88
+ '--key',
89
+ default=os.path.expanduser('~/.ssh/id_rsa'),
90
+ help='Path to the SSH private key file',
91
+ )
92
+
93
+ parser.add_argument(
94
+ '--debug',
95
+ action='store_true',
96
+ help='Enable debug mode',
97
+ )
98
+
99
+ #
100
+ # Setup command
101
+ setup_parser = subparsers.add_parser('setup', description='Setup manager and replica nodes')
102
+ setup_parser.set_defaults(func=execute_setup_command)
103
+
104
+ setup_parser.add_argument(
105
+ '--replica',
106
+ type=replica_type,
107
+ nargs='+',
108
+ required=True,
109
+ help='Replica nodes to setup',
110
+ )
111
+
112
+ setup_parser.add_argument(
113
+ '--clients',
114
+ type=nodes_type,
115
+ nargs='+',
116
+ required=True,
117
+ help='Client nodes to setup',
118
+ )
119
+
120
+ setup_parser.add_argument(
121
+ '--force',
122
+ action='store_true',
123
+ help='Force setup tasks',
124
+ )
125
+
126
+ #
127
+ # Create Volumes command
128
+ create_volumes_parser = subparsers.add_parser('create-volumes', description='Create and sync shared volumes')
129
+ create_volumes_parser.set_defaults(func=execute_create_volumes_command)
130
+ create_volumes_parser.add_argument(
131
+ '--replica',
132
+ type=replica_type,
133
+ nargs='+',
134
+ required=True,
135
+ help='Nodes where volumes will be created',
136
+ )
137
+
138
+ create_volumes_parser.add_argument(
139
+ '--force',
140
+ action='store_true',
141
+ help='Force volumes data sync',
142
+ )
143
+
144
+ #
145
+ # Mount Volumes command
146
+ mount_volumes_parser = subparsers.add_parser('mount-volumes', description='Mount shared volumes')
147
+ mount_volumes_parser.set_defaults(func=execute_mount_volumes_command)
148
+ mount_volumes_parser.add_argument(
149
+ '--clients',
150
+ type=nodes_type,
151
+ nargs='+',
152
+ required=True,
153
+ help='Client nodes on which volumes will be mounted',
154
+ )
155
+
156
+ #
157
+ # Unmount Volumes command
158
+ umount_volumes_parser = subparsers.add_parser('unmount-volumes', description='Unmount shared volumes')
159
+ umount_volumes_parser.set_defaults(func=execute_umount_volumes_command)
160
+ umount_volumes_parser.add_argument(
161
+ '--clients',
162
+ type=nodes_type,
163
+ nargs='+',
164
+ required=True,
165
+ help='Client nodes on which volumes will be unmounted',
166
+ )
167
+
168
+ #
169
+ # Config command
170
+ config_parser = subparsers.add_parser('config', description='Parse, resolve and render compose file in canonical format')
171
+ config_parser.set_defaults(func=execute_config_command)
172
+
173
+ config_parser.add_argument(
174
+ '--compose',
175
+ action='store_true',
176
+ help='Resolve for Docker Compose',
177
+ )
178
+
179
+ #
180
+ # Deploy command
181
+ deploy_parser = subparsers.add_parser('deploy', description='Deploy a new stack or update an existing stack')
182
+ deploy_parser.set_defaults(func=execute_deploy_command)
183
+
184
+ #
185
+ # Down command
186
+ down_parser = subparsers.add_parser('down', description='Stop and remove containers, networks')
187
+ down_parser.set_defaults(func=execute_down_command)
188
+
189
+ #
190
+ # Parsing
191
+ try:
192
+ args = parser.parse_args()
193
+ except (Exception, argparse.ArgumentError) as exception:
194
+ print(exception)
195
+ sys.exit(40)
196
+
197
+ driver_name, connection_string = args.manager
198
+ setting = Setting.from_connection_string(connection_string)
199
+ create_shell = LocalShell if setting.local else RemoteShell
200
+ create_driver = GlusterDriver if driver_name == 'gluster' else NFSDriver
201
+ shell = create_shell(setting, args.key)
202
+ driver = create_driver(args.stack, project=args.project, shell=shell)
203
+
204
+ # Call the function associated with the selected subcommand
205
+ if hasattr(args, 'func'):
206
+ if args.debug:
207
+ args.func(args, driver)
208
+ else:
209
+ try:
210
+ args.func(args, driver)
211
+ except AuthenticationException as exception:
212
+ print(f'\nError: {exception}')
213
+ print_ssh_copy_id_commands(args)
214
+ sys.exit(41)
215
+ except Exception as exception:
216
+ print(exception)
217
+ sys.exit(50)
218
+ else:
219
+ parser.print_help()
220
+
221
+ sys.exit(0)
222
+
223
+
224
+ main()
@@ -0,0 +1,97 @@
1
+ # Copyright 2024-2025 Sébastien Demanou. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ # ==============================================================================
15
+ from collections.abc import Callable
16
+
17
+ import yaml
18
+
19
+ from ..core.driver import Driver
20
+ from ..core.network import get_global_external_networks
21
+ from ..core.network import resolve_networks
22
+ from ..core.parser import parse_services
23
+ from ..core.shell import Shell
24
+ from ..core.volume import Volume
25
+
26
+
27
+ def resolve(
28
+ args,
29
+ driver: Driver,
30
+ *,
31
+ inject: bool = False,
32
+ execute: Callable[[dict, str], None] | None = None,
33
+ ) -> dict:
34
+ config_networks = get_global_external_networks()
35
+ global_networks = resolve_networks(args.project)
36
+
37
+ for network_name, network_definition in global_networks.items():
38
+ config_networks[network_name] = {
39
+ 'name': network_definition['name'],
40
+ 'external': True,
41
+ }
42
+
43
+ compose = {
44
+ 'x-placement': None,
45
+ 'x-placement-hostname': None,
46
+ 'configs': {},
47
+ 'networks': config_networks,
48
+ 'volumes': {},
49
+ 'secrets': {},
50
+ 'services': {},
51
+ }
52
+
53
+ def parse(resolved_compose_data: dict, volumes: list[Volume]):
54
+ placement_hostname = resolved_compose_data.get('x-placement-hostname', None)
55
+ placement = resolved_compose_data.get('x-placement', None)
56
+ networks_ = resolved_compose_data.get('networks', {})
57
+ services = resolved_compose_data.get('services', {})
58
+ volumes = resolved_compose_data.get('volumes', {})
59
+ configs = resolved_compose_data.get('configs', {})
60
+ secrets = resolved_compose_data.get('secrets', {})
61
+
62
+ resolved_compose_data['networks'] = {**config_networks, **networks_}
63
+ compose['x-placement-hostname'] = placement_hostname
64
+ compose['x-placement'] = placement
65
+ compose['networks'].update(networks_)
66
+ compose['services'].update(services)
67
+ compose['volumes'].update(volumes)
68
+ compose['configs'].update(configs)
69
+ compose['secrets'].update(secrets)
70
+
71
+ if args.compose and not placement and not placement_hostname:
72
+ raise ValueError('Missing required x-placement or x-placement-hostname field')
73
+
74
+ if execute:
75
+ execute(resolved_compose_data, placement_hostname or placement)
76
+
77
+ parse_services(
78
+ driver=driver,
79
+ stack=args.stack or args.project,
80
+ execute=parse,
81
+ inject=inject,
82
+ mode=['compose'] if args.compose else ['swarm'],
83
+ )
84
+
85
+ return compose
86
+
87
+
88
+ def execute_config_command(args, driver: Driver) -> None:
89
+ compose = resolve(args, driver)
90
+ compose_output = yaml.dump(compose)
91
+
92
+ if args.compose:
93
+ command = f'docker compose -p {driver.stack_id} -f - config'
94
+ else:
95
+ command = 'docker stack config -c -'
96
+
97
+ Shell.pipe_exec(command, pipe_input=compose_output)
@@ -0,0 +1,332 @@
1
+ # Copyright 2024-2025 Sébastien Demanou. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ # ==============================================================================
15
+ import json
16
+ import os
17
+ import random
18
+ import re
19
+ from typing import Any
20
+
21
+ from docker import DockerClient
22
+
23
+ from ..core.dns import resolve_hostname
24
+ from ..core.docker import DockerCompose
25
+ from ..core.docker import DockerSwarm
26
+ from ..core.driver import Driver
27
+ from ..core.network import GLOBAL_NETWORKS
28
+ from ..core.network import resolve_networks
29
+ from ..core.parser import resolve_compose_file
30
+ from ..core.shell import Setting
31
+ from ..core.traefik import convert_middlewares_to_dict
32
+ from .config import resolve
33
+
34
+
35
+ # Define the regular expression pattern to match {{ .Node.Hostname }} with optional spaces
36
+ NODE_HOSTNAME_RE = r'\{\{\s*\.Node\.Hostname\s*\}\}'
37
+ HTTP_PROVIDER_SERVICENAME = 'seto-http-provider'
38
+
39
+
40
+ def parse_service_vars(entries: dict[str, Any], hostname: str) -> None:
41
+ for key, value in entries.items():
42
+ if isinstance(value, str):
43
+ entries[key] = re.sub(NODE_HOSTNAME_RE, hostname, value)
44
+
45
+
46
+ def pick_label_value(labels: dict[str, Any], name: str) -> Any | str:
47
+ for label, value in labels.items():
48
+ if label.endswith(name):
49
+ del labels[label]
50
+ return value
51
+ return ''
52
+
53
+
54
+ def parse_compose_config(
55
+ args,
56
+ driver: Driver,
57
+ client: DockerClient,
58
+ networks_list: list[str],
59
+ swarm_config: dict,
60
+ *,
61
+ compose_config: dict,
62
+ placement: str,
63
+ composes: list[DockerCompose],
64
+ traefik_http_provider_routers: dict,
65
+ traefik_http_provider_services: dict,
66
+ traefik_http_provider_middlewares: dict,
67
+ ) -> None:
68
+ if compose_config['services']:
69
+ compose = DockerCompose(
70
+ client=client,
71
+ driver=driver,
72
+ config=compose_config,
73
+ )
74
+
75
+ composes.append(compose)
76
+
77
+ for service_name, service in compose_config['services'].items():
78
+ service_bridge_name = f'{service_name}_bridge'
79
+ service_environment = service.get('environment', {})
80
+ service_labels = service.get('labels', {})
81
+ service_networks = service.get('networks', [])
82
+ service_ports = service.get('ports', [])
83
+ service_deploy = {
84
+ 'placement': {
85
+ 'constraints': [
86
+ f'node.labels.{placement}' if '=' in placement else f'node.hostname == {placement}',
87
+ ],
88
+ },
89
+ }
90
+
91
+ # Use a shadow service to make sure all networks are created on all node
92
+ swarm_config['services'][service_bridge_name] = {
93
+ 'image': 'traefik/whoami',
94
+ 'networks': service_networks,
95
+ 'deploy': service_deploy,
96
+ }
97
+
98
+ if compose.node_hostname:
99
+ parse_service_vars(service_labels, compose.node_hostname)
100
+ parse_service_vars(service_environment, compose.node_hostname)
101
+
102
+ service['ports'] = service_ports
103
+ service_traefik_rule = pick_label_value(service_labels, '.rule')
104
+ service_traefik_middlewares = pick_label_value(service_labels, '.middlewares')
105
+ service_traefik_port = pick_label_value(service_labels, '.loadbalancer.server.port')
106
+ service_traefik_entryPoints = pick_label_value(service_labels, '.entryPoints')
107
+ service_traefik_tls_certresolver = pick_label_value(service_labels, '.tls.certresolver')
108
+ service_traefik_service = pick_label_value(service_labels, '.service') or service_name
109
+ published_port = random.randint(53100, 64200)
110
+
111
+ if not service_traefik_port:
112
+ print(f'WARN: Service "{service_name}" has no defined port. Skipped from Traefik HTTP Provider')
113
+ continue
114
+
115
+ service_ports.append(f'{published_port}:{service_traefik_port}')
116
+
117
+ traefik_http_provider_routers[service_traefik_service] = {
118
+ 'entryPoints': [service_traefik_entryPoints],
119
+ 'service': service_traefik_service,
120
+ 'rule': service_traefik_rule,
121
+ 'middlewares': [item for item in service_traefik_middlewares.split(',') if item],
122
+ 'tls': {
123
+ 'certresolver': service_traefik_tls_certresolver,
124
+ },
125
+ }
126
+
127
+ node_ip = resolve_hostname(compose.node_hostname)
128
+ traefik_http_provider_services[service_traefik_service] = {
129
+ 'loadBalancer': {
130
+ 'servers': [
131
+ {
132
+ 'url': f'http://{node_ip}:{published_port}',
133
+ },
134
+ ],
135
+ },
136
+ }
137
+
138
+ traefik_http_provider_middlewares.update(
139
+ convert_middlewares_to_dict(service_labels),
140
+ )
141
+
142
+
143
+ def deploy_seto_stack(args, driver: Driver, replica: list[Setting]) -> None:
144
+ # Temporary rewrite driver config
145
+ driver.project = 'seto'
146
+ driver.stack = None
147
+
148
+ # Building seto config
149
+ client = DockerClient.from_env()
150
+ config_networks = resolve_networks(args.project)
151
+
152
+ config_networks.update(GLOBAL_NETWORKS)
153
+
154
+ print('Configuring seto-http-provider...')
155
+ traefik_http_provider_data_path = '/data'
156
+ internal_stack = {
157
+ 'networks': config_networks,
158
+ 'services': {
159
+ HTTP_PROVIDER_SERVICENAME: {
160
+ 'image': 'demsking/traefik-http-provider',
161
+ 'networks': list(config_networks.keys()),
162
+ 'environment': [
163
+ 'WORKER=1',
164
+ 'EXPIRATION_MINUTES=10',
165
+ f'DATA_PATH={traefik_http_provider_data_path}',
166
+ ],
167
+ 'volumes-nfs': {
168
+ f'data:{traefik_http_provider_data_path}',
169
+ },
170
+ 'deploy': {
171
+ 'mode': 'global',
172
+ 'labels': {
173
+ 'traefik.discovery.enable': True,
174
+ },
175
+ },
176
+ },
177
+ },
178
+ }
179
+
180
+ # Resolving compose local volumes
181
+ resolved_compose_data, volumes = resolve_compose_file(
182
+ driver=driver,
183
+ compose_data=internal_stack,
184
+ inject=True,
185
+ )
186
+
187
+ print('Creating seto volumes...')
188
+ driver.create_volumes(replica=replica, volumes=volumes, force=args.force)
189
+
190
+ print('Deploying seto services...')
191
+ swarm = DockerSwarm(
192
+ client=client,
193
+ driver=driver,
194
+ config=resolved_compose_data,
195
+ )
196
+
197
+ swarm.info()
198
+ swarm.deploy()
199
+
200
+ # Restore initial driver config
201
+ driver.project = args.project
202
+ driver.stack = args.stack
203
+
204
+
205
+ def execute_deploy_command(args, driver: Driver) -> None:
206
+ client = DockerClient.from_env()
207
+
208
+ # Docker Swarm
209
+ print(f'Resolving {driver.stack_id} services...')
210
+ setattr(args, 'compose', False)
211
+ swarm_config = resolve(args, driver)
212
+
213
+ swarm = DockerSwarm(
214
+ client=client,
215
+ driver=driver,
216
+ config=swarm_config,
217
+ )
218
+
219
+ # Docker Compose
220
+ setattr(args, 'compose', True)
221
+ networks_list = list(swarm_config['networks'].keys())
222
+ composes_items: list[DockerCompose] = []
223
+
224
+ bridges_path = 'bridges'
225
+ traefik_http_provider_filename = os.path.join(bridges_path, 'traefik-http-provider.json')
226
+ traefik_http_provider_target = '/traefik/config.json'
227
+ traefik_http_provider_routers = {}
228
+ traefik_http_provider_services = {}
229
+ traefik_http_provider_middlewares = {}
230
+ traefik_http_provider = {
231
+ 'http': {
232
+ 'routers': traefik_http_provider_routers,
233
+ 'services': traefik_http_provider_services,
234
+ 'middlewares': traefik_http_provider_middlewares,
235
+ },
236
+ }
237
+
238
+ register_command = ' && '.join([
239
+ f'echo "Registering service {driver.stack_id}..."',
240
+ f'curl -s -X POST http://{HTTP_PROVIDER_SERVICENAME}:6116/api/config/{driver.stack_id} -H "Content-Type: application/json" -d @{traefik_http_provider_target} > /dev/nul',
241
+ ])
242
+
243
+ entrypoint = ' && '.join([
244
+ # Log the start of the initial endpoint call
245
+ 'echo "Starting initial call to the provider endpoint..."',
246
+
247
+ # Call the POST endpoint at startup
248
+ register_command,
249
+
250
+ # Log the end of the initial endpoint call
251
+ 'echo "Initial call to the provider endpoint completed. Running cron job..."',
252
+
253
+ # Set up the cron job
254
+ f'echo "*/1 * * * * {register_command}" | crontab -',
255
+
256
+ # Start the cron service
257
+ 'crond -f',
258
+ ])
259
+
260
+ seto_agent_compose_data, _ = resolve_compose_file(
261
+ driver=driver,
262
+ compose_data={
263
+ 'services': {
264
+ 'seto_agent': {
265
+ 'image': 'curlimages/curl:latest',
266
+ 'user': 'root', # Ensure it runs as root to avoid issue `crontab: must be suid to work properly`
267
+ 'networks': networks_list,
268
+ 'entrypoint': f"sh -c '{entrypoint}'",
269
+ 'volumes-image': [
270
+ f'{traefik_http_provider_filename}:{traefik_http_provider_target}',
271
+ ],
272
+ },
273
+ },
274
+ },
275
+ )
276
+
277
+ swarm_config['services'].update(seto_agent_compose_data['services'])
278
+
279
+ resolve(
280
+ args,
281
+ driver,
282
+ inject=True,
283
+ execute=lambda config, placement: parse_compose_config(
284
+ args,
285
+ driver,
286
+ client,
287
+ networks_list,
288
+ swarm_config,
289
+ compose_config=config,
290
+ placement=placement,
291
+ composes=composes_items,
292
+ traefik_http_provider_routers=traefik_http_provider_routers,
293
+ traefik_http_provider_services=traefik_http_provider_services,
294
+ traefik_http_provider_middlewares=traefik_http_provider_middlewares,
295
+ ),
296
+ )
297
+
298
+ if not os.path.exists(bridges_path):
299
+ os.mkdir(bridges_path)
300
+
301
+ with open(traefik_http_provider_filename, 'w', encoding='utf-8') as file:
302
+ file.write(json.dumps(traefik_http_provider, indent=' '))
303
+
304
+ print(f'Building {driver.stack_id} swarm images...')
305
+ # swarm.info()
306
+ swarm.build()
307
+
308
+ print(f'Pushing {driver.stack_id} images...')
309
+ swarm.push()
310
+
311
+ # print(f'Creating {driver.stack_id} volumes...')
312
+ # driver.create_volumes(replica=replica, volumes=volumes, force=args.force)
313
+
314
+ print(f'Deploying {driver.stack_id} swarm environment...')
315
+ swarm.deploy()
316
+ swarm.ps()
317
+
318
+ if composes_items:
319
+ print(f'Building {driver.stack_id} compose images...')
320
+ for compose in composes_items:
321
+ # compose.info()
322
+ compose.build()
323
+
324
+ print(f'Pulling {driver.stack_id} compose images...')
325
+ for compose in composes_items:
326
+ compose.pull()
327
+
328
+ print(f'Deploying {driver.stack_id} compose environment...')
329
+ for compose in composes_items:
330
+ compose.deploy()
331
+ compose.ps()
332
+ compose.logs()