seto 1.0.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,216 @@
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('--stack', required=True, help='Stack name')
78
+ parser.add_argument(
79
+ '--manager',
80
+ type=manager_type,
81
+ required=True,
82
+ help=f'Storage manager URI to use. Can be {DRIVER_EXAMPLES_STR}',
83
+ )
84
+
85
+ parser.add_argument('--key', default=os.path.expanduser('~/.ssh/id_rsa'), help='Path to the SSH private key file')
86
+
87
+ parser.add_argument(
88
+ '--debug',
89
+ action='store_true',
90
+ help='Enable debug mode',
91
+ )
92
+
93
+ #
94
+ # Setup command
95
+ setup_parser = subparsers.add_parser('setup', description='Setup manager and replica nodes')
96
+ setup_parser.set_defaults(func=execute_setup_command)
97
+
98
+ setup_parser.add_argument(
99
+ '--replica',
100
+ type=replica_type,
101
+ nargs='+',
102
+ required=True,
103
+ help='Replica nodes to setup',
104
+ )
105
+
106
+ setup_parser.add_argument(
107
+ '--clients',
108
+ type=nodes_type,
109
+ nargs='+',
110
+ required=True,
111
+ help='Client nodes to setup',
112
+ )
113
+
114
+ setup_parser.add_argument(
115
+ '--force',
116
+ action='store_true',
117
+ help='Force setup tasks',
118
+ )
119
+
120
+ #
121
+ # Create Volumes command
122
+ create_volumes_parser = subparsers.add_parser('create-volumes', description='Create and sync shared volumes')
123
+ create_volumes_parser.set_defaults(func=execute_create_volumes_command)
124
+ create_volumes_parser.add_argument(
125
+ '--replica',
126
+ type=replica_type,
127
+ nargs='+',
128
+ required=True,
129
+ help='Nodes where volumes will be created',
130
+ )
131
+
132
+ create_volumes_parser.add_argument(
133
+ '--force',
134
+ action='store_true',
135
+ help='Force volumes data sync',
136
+ )
137
+
138
+ #
139
+ # Mount Volumes command
140
+ mount_volumes_parser = subparsers.add_parser('mount-volumes', description='Mount shared volumes')
141
+ mount_volumes_parser.set_defaults(func=execute_mount_volumes_command)
142
+ mount_volumes_parser.add_argument(
143
+ '--clients',
144
+ type=nodes_type,
145
+ nargs='+',
146
+ required=True,
147
+ help='Client nodes on which volumes will be mounted',
148
+ )
149
+
150
+ #
151
+ # Unmount Volumes command
152
+ umount_volumes_parser = subparsers.add_parser('unmount-volumes', description='Unmount shared volumes')
153
+ umount_volumes_parser.set_defaults(func=execute_umount_volumes_command)
154
+ umount_volumes_parser.add_argument(
155
+ '--clients',
156
+ type=nodes_type,
157
+ nargs='+',
158
+ required=True,
159
+ help='Client nodes on which volumes will be unmounted',
160
+ )
161
+
162
+ #
163
+ # Config command
164
+ config_parser = subparsers.add_parser('config', description='Parse, resolve and render compose file in canonical format')
165
+ config_parser.set_defaults(func=execute_config_command)
166
+
167
+ config_parser.add_argument(
168
+ '--compose',
169
+ action='store_true',
170
+ help='Resolve for Docker Compose',
171
+ )
172
+
173
+ #
174
+ # Deploy command
175
+ deploy_parser = subparsers.add_parser('deploy', description='Deploy a new stack or update an existing stack')
176
+ deploy_parser.set_defaults(func=execute_deploy_command)
177
+
178
+ #
179
+ # Down command
180
+ down_parser = subparsers.add_parser('down', description='Stop and remove containers, networks')
181
+ down_parser.set_defaults(func=execute_down_command)
182
+
183
+ #
184
+ # Parsing
185
+ try:
186
+ args = parser.parse_args()
187
+ except (Exception, argparse.ArgumentError) as exception:
188
+ print(exception)
189
+ sys.exit(40)
190
+
191
+ driver_name, connection_string = args.manager
192
+ setting = Setting.from_connection_string(connection_string)
193
+ create_shell = LocalShell if setting.local else RemoteShell
194
+ create_driver = GlusterDriver if driver_name == 'gluster' else NFSDriver
195
+ shell = create_shell(setting, args.key)
196
+ driver = create_driver(args.stack, shell)
197
+
198
+ # Call the function associated with the selected subcommand
199
+ if hasattr(args, 'func'):
200
+ if args.debug:
201
+ args.func(args, driver)
202
+ else:
203
+ try:
204
+ args.func(args, driver)
205
+ except AuthenticationException as exception:
206
+ print(f'\nError: {exception}')
207
+ print_ssh_copy_id_commands(args)
208
+ sys.exit(41)
209
+ except Exception as exception:
210
+ print(exception)
211
+ sys.exit(50)
212
+ else:
213
+ parser.print_help()
214
+
215
+
216
+ main()
@@ -0,0 +1,102 @@
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
+ from collections.abc import Callable
16
+
17
+ import yaml
18
+
19
+ from ..core.driver import Driver
20
+ from ..core.parser import parse_services
21
+ from ..core.parser import resolve_networks
22
+ from ..core.shell import Shell
23
+ from ..core.volume import Volume
24
+
25
+
26
+ def resolve(
27
+ args,
28
+ driver: Driver,
29
+ *,
30
+ inject: bool = False,
31
+ execute: Callable[[dict, str], None] | None = None,
32
+ ) -> dict:
33
+ config_networks = resolve_networks(args.stack)
34
+
35
+ config_networks['cloud-public'] = {
36
+ 'name': 'seto-cloud-public',
37
+ 'driver': 'overlay',
38
+ 'attachable': True,
39
+ }
40
+
41
+ config_networks['cloud-edge'] = {
42
+ 'name': 'seto-cloud-edge',
43
+ 'driver': 'overlay',
44
+ 'attachable': True,
45
+ }
46
+
47
+ if args.compose:
48
+ external_networks_keys = config_networks.keys()
49
+ config_networks = resolve_networks(args.stack, external_networks_keys)
50
+
51
+ compose = {
52
+ 'x-placement': None,
53
+ 'configs': {},
54
+ 'networks': config_networks,
55
+ 'volumes': {},
56
+ 'secrets': {},
57
+ 'services': {},
58
+ }
59
+
60
+ def parse(resolved_compose_data: dict, volumes: list[Volume]):
61
+ placement = resolved_compose_data.get('x-placement', None)
62
+ networks_ = resolved_compose_data.get('networks', {})
63
+ services = resolved_compose_data.get('services', {})
64
+ volumes = resolved_compose_data.get('volumes', {})
65
+ configs = resolved_compose_data.get('configs', {})
66
+ secrets = resolved_compose_data.get('secrets', {})
67
+
68
+ resolved_compose_data['networks'] = {*config_networks, *networks_}
69
+ compose['x-placement'] = placement
70
+ compose['networks'].update(networks_)
71
+ compose['services'].update(services)
72
+ compose['volumes'].update(volumes)
73
+ compose['configs'].update(configs)
74
+ compose['secrets'].update(secrets)
75
+
76
+ if args.compose and not placement:
77
+ raise ValueError('Missing required x-placement field')
78
+
79
+ if execute:
80
+ execute(resolved_compose_data, placement)
81
+
82
+ parse_services(
83
+ driver=driver,
84
+ stack=args.stack,
85
+ execute=parse,
86
+ inject=inject,
87
+ mode=['compose'] if args.compose else ['swarm'],
88
+ )
89
+
90
+ return compose
91
+
92
+
93
+ def execute_config_command(args, driver: Driver) -> None:
94
+ compose = resolve(args, driver)
95
+ compose_output = yaml.dump(compose)
96
+
97
+ if args.compose:
98
+ command = f'docker compose -p {args.stack} -f - config'
99
+ else:
100
+ command = 'docker stack config -c -'
101
+
102
+ Shell.pipe_exec(command, pipe_input=compose_output)
@@ -0,0 +1,238 @@
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 json
16
+ import os
17
+ import re
18
+ from typing import Any
19
+
20
+ from docker import DockerClient
21
+
22
+ from ..core.dns import resolve_hostname
23
+ from ..core.docker import DockerCompose
24
+ from ..core.docker import DockerSwarm
25
+ from ..core.driver import Driver
26
+ from .config import resolve
27
+
28
+ # Define the regular expression pattern to match {{ .Node.Hostname }} with optional spaces
29
+ NODE_HOSTNAME_RE = r'\{\{\s*\.Node\.Hostname\s*\}\}'
30
+
31
+
32
+ def parse_service_vars(entries: dict[str, Any], hostname: str) -> None:
33
+ for key, value in entries.items():
34
+ if isinstance(value, str):
35
+ entries[key] = re.sub(NODE_HOSTNAME_RE, hostname, value)
36
+
37
+
38
+ def get_label_value(labels: dict[str, Any], name: str) -> Any | None:
39
+ for label, value in labels.items():
40
+ if label.endswith(name):
41
+ return value
42
+ return None
43
+
44
+
45
+ def parse_compose_config(
46
+ args,
47
+ driver: Driver,
48
+ client: DockerClient,
49
+ networks_list: list[str],
50
+ swarm_config: dict,
51
+ *,
52
+ compose_config: dict,
53
+ placement: str,
54
+ composes: list[DockerCompose],
55
+ traefik_http_provider_routers: dict,
56
+ traefik_http_provider_services: dict,
57
+ ) -> None:
58
+ if compose_config['services']:
59
+ compose = DockerCompose(
60
+ stack=args.stack,
61
+ client=client,
62
+ driver=driver,
63
+ config=compose_config,
64
+ )
65
+
66
+ composes.append(compose)
67
+
68
+ for service_name, service in compose_config['services'].items():
69
+ service_bridge_name = f'{service_name}_bridge'
70
+ service_environment = service.get('environment', {})
71
+ service_labels = service.get('labels', {})
72
+ service_networks = service.get('networks', [])
73
+ service_ports = service.get('ports', [])
74
+ service_deploy = {
75
+ 'labels': [
76
+ 'traefik.discovery.enable=false',
77
+ ],
78
+ 'placement': {
79
+ 'constraints': [
80
+ f'node.labels.{placement}',
81
+ ],
82
+ },
83
+ }
84
+
85
+ # Use a shadow service to make sure all networks are created on all node
86
+ swarm_config['services'][service_bridge_name] = {
87
+ 'image': 'traefik/whoami',
88
+ 'networks': service_networks,
89
+ 'deploy': service_deploy,
90
+ }
91
+
92
+ parse_service_vars(service_labels, compose.node_hostname)
93
+ parse_service_vars(service_environment, compose.node_hostname)
94
+
95
+ if service_ports:
96
+ service_traefik_rule = get_label_value(service_labels, '.rule')
97
+ service_traefik_middlewares = get_label_value(service_labels, '.middlewares')
98
+ service_traefik_port = get_label_value(service_labels, '.loadbalancer.server.port')
99
+ service_traefik_entryPoints = get_label_value(service_labels, '.entryPoints')
100
+ service_traefik_tls_certresolver = get_label_value(service_labels, '.tls.certresolver')
101
+ service_traefik_service = get_label_value(service_labels, '.service')
102
+
103
+ if not service_traefik_port:
104
+ raise ValueError(f'Service "{service_name}" error: port is missing')
105
+
106
+ published_port = -1
107
+
108
+ for entry in service_ports:
109
+ source_port, target_port = entry.split(':')
110
+
111
+ if int(target_port) == service_traefik_port:
112
+ published_port = source_port
113
+
114
+ if published_port == -1:
115
+ raise ValueError(f'No exposed port for {service_name}:{service_traefik_port}')
116
+
117
+ traefik_http_provider_routers[service_name] = {
118
+ 'entryPoints': [service_traefik_entryPoints],
119
+ 'service': service_traefik_service or service_name,
120
+ 'rule': service_traefik_rule,
121
+ 'middlewares': service_traefik_middlewares,
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_name] = {
129
+ 'loadBalancer': {
130
+ 'servers': [
131
+ {
132
+ 'url': f'http://{node_ip}:{published_port}',
133
+ },
134
+ ],
135
+ },
136
+ }
137
+
138
+
139
+ def execute_deploy_command(args, driver: Driver) -> None:
140
+ client = DockerClient.from_env()
141
+
142
+ # Docker Swarm
143
+ print('Resolving services...')
144
+ setattr(args, 'compose', False)
145
+ swarm_config = resolve(args, driver)
146
+ swarm = DockerSwarm(
147
+ stack=args.stack,
148
+ client=client,
149
+ driver=driver,
150
+ config=swarm_config,
151
+ )
152
+
153
+ # Docker Compose
154
+ setattr(args, 'compose', True)
155
+ networks_list = list(swarm_config['networks'].keys())
156
+ composes_items: list[DockerCompose] = []
157
+
158
+ bridges_path = 'bridges'
159
+ traefik_http_provider_name = 'traefik-http-provider'
160
+ traefik_http_provider_filename = os.path.join(bridges_path, 'traefik-http-provider.json')
161
+ traefik_http_provider_routers = {}
162
+ traefik_http_provider_services = {}
163
+ traefik_http_provider = {
164
+ 'http': {
165
+ 'routers': traefik_http_provider_routers,
166
+ 'services': traefik_http_provider_services,
167
+ },
168
+ }
169
+
170
+ swarm_config['configs'][traefik_http_provider_name] = {
171
+ 'file': traefik_http_provider_filename,
172
+ }
173
+
174
+ swarm_config['services']['compose-provider'] = {
175
+ 'image': 'httpd:alpine',
176
+ 'networks': networks_list,
177
+ 'configs': [
178
+ {
179
+ 'source': traefik_http_provider_name,
180
+ 'target': '/usr/local/apache2/htdocs/bridge.json',
181
+ },
182
+ ],
183
+ 'deploy': {
184
+ 'labels': [
185
+ 'traefik.discovery.enable=false',
186
+ ],
187
+ },
188
+ }
189
+
190
+ resolve(
191
+ args,
192
+ driver,
193
+ inject=True,
194
+ execute=lambda config, placement: parse_compose_config(
195
+ args,
196
+ driver,
197
+ client,
198
+ networks_list,
199
+ swarm_config,
200
+ compose_config=config,
201
+ placement=placement,
202
+ composes=composes_items,
203
+ traefik_http_provider_routers=traefik_http_provider_routers,
204
+ traefik_http_provider_services=traefik_http_provider_services,
205
+ ),
206
+ )
207
+
208
+ if not os.path.exists(bridges_path):
209
+ os.mkdir(bridges_path)
210
+
211
+ with open(traefik_http_provider_filename, 'w', encoding='utf-8') as file:
212
+ file.write(json.dumps(traefik_http_provider, indent=' '))
213
+
214
+ print('Building swarm images...')
215
+ # swarm.info()
216
+ swarm.build()
217
+ swarm.push()
218
+
219
+ print('Deploying swarm environment...')
220
+ swarm.deploy()
221
+ swarm.ps()
222
+
223
+ if composes_items:
224
+ print('Building compose images...')
225
+ for compose in composes_items:
226
+ # compose.info()
227
+ compose.build()
228
+ compose.push()
229
+
230
+ print('Pulling compose images...')
231
+ for compose in composes_items:
232
+ compose.pull()
233
+
234
+ print('Deploying compose environment...')
235
+ for compose in composes_items:
236
+ compose.deploy()
237
+ compose.ps()
238
+ compose.logs()
seto/commands/down.py ADDED
@@ -0,0 +1,51 @@
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
+ from docker import DockerClient
16
+
17
+ from ..core.docker import DockerCompose
18
+ from ..core.docker import DockerSwarm
19
+ from ..core.driver import Driver
20
+ from .config import resolve
21
+
22
+
23
+ def execute_down_command(args, driver: Driver) -> None:
24
+ client = DockerClient.from_env()
25
+
26
+ # Docker Swarm
27
+ print('Stoping environment...')
28
+ setattr(args, 'compose', False)
29
+
30
+ swarm = DockerSwarm(
31
+ stack=args.stack,
32
+ client=client,
33
+ driver=driver,
34
+ config=resolve(args, driver),
35
+ )
36
+
37
+ swarm.down()
38
+
39
+ # Docker Compose
40
+ setattr(args, 'compose', True)
41
+ config = resolve(args, driver, inject=True)
42
+
43
+ if config['services']:
44
+ compose = DockerCompose(
45
+ stack=args.stack,
46
+ client=client,
47
+ driver=driver,
48
+ config=config,
49
+ )
50
+
51
+ compose.down()
seto/commands/mount.py ADDED
@@ -0,0 +1,24 @@
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
+ from ..core.command import parse_volumes_args
16
+ from ..core.driver import Driver
17
+ from ..core.shell import Setting
18
+
19
+
20
+ def execute_mount_volumes_command(args, driver: Driver) -> None:
21
+ clients: list[Setting] = [items[0] for items in args.clients]
22
+ all_volumes = parse_volumes_args(args, driver)
23
+
24
+ driver.mount_volumes(clients=clients, volumes=all_volumes)