stoobly-agent 1.6.7__py3-none-any.whl → 1.7.1__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.
- stoobly_agent/__init__.py +1 -1
- stoobly_agent/app/cli/scaffold/docker/constants.py +1 -0
- stoobly_agent/app/cli/scaffold/docker/service/configure_gateway.py +69 -28
- stoobly_agent/app/cli/scaffold/hosts_file_manager.py +10 -10
- stoobly_agent/app/cli/scaffold/service_workflow_validate_command.py +48 -12
- stoobly_agent/app/cli/scaffold/templates/__init__.py +8 -0
- stoobly_agent/app/cli/scaffold/templates/app/.Dockerfile.context +1 -1
- stoobly_agent/app/cli/scaffold/templates/app/gateway/.docker-compose.base.template.yml +1 -1
- stoobly_agent/app/cli/scaffold/templates/run/nginx.tmpl +1060 -0
- stoobly_agent/app/cli/scaffold/validate_command.py +31 -9
- stoobly_agent/app/cli/scaffold/workflow_validate_command.py +58 -33
- stoobly_agent/app/cli/scaffold_cli.py +7 -2
- stoobly_agent/config/data_dir.py +5 -4
- stoobly_agent/test/app/cli/scaffold/e2e_test.py +3 -3
- stoobly_agent/test/app/cli/scaffold/hosts_file_manager_test.py +1 -1
- stoobly_agent/test/app/models/schemas/.stoobly/db/VERSION +1 -1
- {stoobly_agent-1.6.7.dist-info → stoobly_agent-1.7.1.dist-info}/METADATA +1 -1
- {stoobly_agent-1.6.7.dist-info → stoobly_agent-1.7.1.dist-info}/RECORD +21 -20
- {stoobly_agent-1.6.7.dist-info → stoobly_agent-1.7.1.dist-info}/LICENSE +0 -0
- {stoobly_agent-1.6.7.dist-info → stoobly_agent-1.7.1.dist-info}/WHEEL +0 -0
- {stoobly_agent-1.6.7.dist-info → stoobly_agent-1.7.1.dist-info}/entry_points.txt +0 -0
stoobly_agent/__init__.py
CHANGED
@@ -1,2 +1,2 @@
|
|
1
1
|
COMMAND = 'stoobly-agent'
|
2
|
-
VERSION = '1.
|
2
|
+
VERSION = '1.7.1'
|
@@ -10,6 +10,7 @@ DOCKER_COMPOSE_CUSTOM = 'docker-compose.yml'
|
|
10
10
|
DOCKER_COMPOSE_NETWORKS = '.docker-compose.networks.yml'
|
11
11
|
DOCKERFILE_CONTEXT = '.Dockerfile.context'
|
12
12
|
DOCKERFILE_SERVICE = 'Dockerfile.source'
|
13
|
+
GATEWAY_NGINX_TEMPLATE = 'nginx.tmpl'
|
13
14
|
|
14
15
|
# TODO: add scaffold container name templates here
|
15
16
|
|
@@ -1,38 +1,25 @@
|
|
1
1
|
import os
|
2
2
|
import pdb
|
3
|
+
import shutil
|
3
4
|
import yaml
|
4
5
|
|
5
6
|
from typing import List
|
6
7
|
|
8
|
+
from stoobly_agent.config.data_dir import DATA_DIR_NAME, TMP_DIR_NAME
|
9
|
+
from stoobly_agent.app.cli.scaffold.constants import APP_DIR
|
7
10
|
from stoobly_agent.app.cli.scaffold.service_config import ServiceConfig
|
8
|
-
from stoobly_agent.app.cli.scaffold.docker.constants import DOCKER_COMPOSE_BASE, DOCKER_COMPOSE_BASE_TEMPLATE
|
11
|
+
from stoobly_agent.app.cli.scaffold.docker.constants import APP_INGRESS_NETWORK_NAME, APP_EGRESS_NETWORK_NAME, DOCKER_COMPOSE_BASE, DOCKER_COMPOSE_BASE_TEMPLATE, GATEWAY_NGINX_TEMPLATE
|
9
12
|
from stoobly_agent.app.cli.scaffold.templates.constants import CORE_GATEWAY_SERVICE_NAME
|
13
|
+
from stoobly_agent.app.cli.scaffold.templates import run_template_path
|
10
14
|
|
11
15
|
def configure_gateway(service_paths: List[str], no_publish = False):
|
12
16
|
if len(service_paths) == 0:
|
13
17
|
return
|
14
18
|
|
15
|
-
ports =
|
16
|
-
hostnames = []
|
17
|
-
for path in service_paths:
|
18
|
-
config = ServiceConfig(path)
|
19
|
+
hostnames, ports = __find_hosts(service_paths)
|
19
20
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
try:
|
24
|
-
port = int(config.port)
|
25
|
-
except Exception:
|
26
|
-
continue
|
27
|
-
|
28
|
-
port_mapping = f"{port}:{443 if config.scheme == 'https' else 80}"
|
29
|
-
if port > 0 and port <= 65535 and port_mapping not in ports:
|
30
|
-
ports.append(port_mapping)
|
31
|
-
|
32
|
-
hostnames.append(config.hostname)
|
33
|
-
|
34
|
-
app_dir_path = os.path.dirname(service_paths[0])
|
35
|
-
gateway_service_path = os.path.join(app_dir_path, CORE_GATEWAY_SERVICE_NAME)
|
21
|
+
service_dir_path = os.path.dirname(service_paths[0])
|
22
|
+
gateway_service_path = os.path.join(service_dir_path, CORE_GATEWAY_SERVICE_NAME)
|
36
23
|
docker_compose_src_path = os.path.join(gateway_service_path, DOCKER_COMPOSE_BASE_TEMPLATE)
|
37
24
|
docker_compose_dest_path = os.path.join(gateway_service_path, DOCKER_COMPOSE_BASE)
|
38
25
|
|
@@ -50,12 +37,66 @@ def configure_gateway(service_paths: List[str], no_publish = False):
|
|
50
37
|
if not no_publish:
|
51
38
|
gateway_base['ports'] = ports
|
52
39
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
'aliases': hostnames
|
57
|
-
}
|
58
|
-
}
|
40
|
+
app_dir_path = os.path.dirname(os.path.dirname(service_dir_path))
|
41
|
+
__with_no_publish(gateway_base, app_dir_path)
|
42
|
+
__with_networks(gateway_base, hostnames)
|
59
43
|
|
60
44
|
with open(docker_compose_dest_path, 'w') as fp:
|
61
|
-
yaml.dump(compose, fp)
|
45
|
+
yaml.dump(compose, fp)
|
46
|
+
|
47
|
+
def __with_networks(config: dict, hostnames: List[str]):
|
48
|
+
networks = {}
|
49
|
+
config['networks'] = networks
|
50
|
+
networks[APP_EGRESS_NETWORK_NAME] = {}
|
51
|
+
networks[APP_INGRESS_NETWORK_NAME] = {
|
52
|
+
'aliases': hostnames
|
53
|
+
}
|
54
|
+
|
55
|
+
def __with_no_publish(config: dict, app_dir_path: str):
|
56
|
+
if not config['volumes']:
|
57
|
+
config['volumes'] = []
|
58
|
+
|
59
|
+
# Copy nginx.tmpl to .stoobly/tmp
|
60
|
+
nginx_template_src_path = os.path.join(run_template_path(), GATEWAY_NGINX_TEMPLATE)
|
61
|
+
nginx_template_relative_path = os.path.join(DATA_DIR_NAME, TMP_DIR_NAME, GATEWAY_NGINX_TEMPLATE)
|
62
|
+
nginx_template_dest_path = os.path.join(app_dir_path, nginx_template_relative_path)
|
63
|
+
|
64
|
+
if not os.path.exists(os.path.dirname(nginx_template_dest_path)):
|
65
|
+
os.makedirs(os.path.dirname(nginx_template_dest_path), exist_ok=True)
|
66
|
+
|
67
|
+
shutil.copy(nginx_template_src_path, nginx_template_dest_path)
|
68
|
+
|
69
|
+
config['volumes'].append(
|
70
|
+
f"{os.path.join(APP_DIR, nginx_template_relative_path)}:/app/nginx.tmpl:ro"
|
71
|
+
)
|
72
|
+
|
73
|
+
environment = {}
|
74
|
+
if not config['environment']:
|
75
|
+
config['environment'] = environment
|
76
|
+
else:
|
77
|
+
environment = config['environment']
|
78
|
+
|
79
|
+
environment['HTTPS_METHOD'] = 'noredirect'
|
80
|
+
|
81
|
+
def __find_hosts(service_paths):
|
82
|
+
hostnames = []
|
83
|
+
ports = []
|
84
|
+
for path in service_paths:
|
85
|
+
config = ServiceConfig(path)
|
86
|
+
|
87
|
+
if not config.hostname:
|
88
|
+
continue
|
89
|
+
|
90
|
+
try:
|
91
|
+
port = int(config.port)
|
92
|
+
except Exception:
|
93
|
+
continue
|
94
|
+
|
95
|
+
port_mapping = f"{port}:{port}"
|
96
|
+
if port > 0 and port <= 65535 and port_mapping not in ports:
|
97
|
+
ports.append(port_mapping)
|
98
|
+
|
99
|
+
if config.hostname not in hostnames:
|
100
|
+
hostnames.append(config.hostname)
|
101
|
+
|
102
|
+
return hostnames, ports
|
@@ -15,21 +15,21 @@ class HostsFileManager():
|
|
15
15
|
ip_address: str
|
16
16
|
hostnames: list[str]
|
17
17
|
|
18
|
-
|
18
|
+
# Split IP address and hostnames. Don't include inline comments
|
19
|
+
def __split_hosts_line(self, line: str) -> list[str]:
|
20
|
+
ip_addr_hosts_split = line.split('#')[0].split()
|
21
|
+
return ip_addr_hosts_split
|
22
|
+
|
23
|
+
def get_hosts_file_path(self) -> str:
|
19
24
|
file_path = '/etc/hosts'
|
20
25
|
if not os.path.exists(file_path):
|
21
26
|
print(f"Error: File {file_path} not found.", file=sys.stderr)
|
22
27
|
sys.exit(1)
|
23
28
|
return file_path
|
24
29
|
|
25
|
-
# Split IP address and hostnames. Don't include inline comments
|
26
|
-
def __split_hosts_line(self, line: str) -> list[str]:
|
27
|
-
ip_addr_hosts_split = line.split('#')[0].split()
|
28
|
-
return ip_addr_hosts_split
|
29
|
-
|
30
30
|
# Parses hosts file and returns a mapping of IP address to hostnames in a list.
|
31
31
|
def get_hosts(self) -> list[IpAddressToHostnames]:
|
32
|
-
hosts_file_path = self.
|
32
|
+
hosts_file_path = self.get_hosts_file_path()
|
33
33
|
|
34
34
|
if not hosts_file_path:
|
35
35
|
return []
|
@@ -64,14 +64,14 @@ class HostsFileManager():
|
|
64
64
|
return None
|
65
65
|
|
66
66
|
def install_hostnames(self, hostnames: list[str]) -> None:
|
67
|
-
hosts_file_path = self.
|
67
|
+
hosts_file_path = self.get_hosts_file_path()
|
68
68
|
|
69
69
|
self.__add_lines_between_markers(
|
70
70
|
hosts_file_path, SCAFFOLD_HOSTS_DELIMITTER_BEGIN, SCAFFOLD_HOSTS_DELIMITTER_END, hostnames
|
71
71
|
)
|
72
72
|
|
73
73
|
def uninstall_hostnames(self, hostnames: list[str] = []) -> None:
|
74
|
-
hosts_file_path = self.
|
74
|
+
hosts_file_path = self.get_hosts_file_path()
|
75
75
|
|
76
76
|
self.__remove_lines_between_markers(
|
77
77
|
hosts_file_path, SCAFFOLD_HOSTS_DELIMITTER_BEGIN, SCAFFOLD_HOSTS_DELIMITTER_END, hostnames
|
@@ -182,4 +182,4 @@ class HostsFileManager():
|
|
182
182
|
filtered_lines.append(line)
|
183
183
|
|
184
184
|
with open(file_path, "w") as file:
|
185
|
-
file.writelines(filtered_lines)
|
185
|
+
file.writelines(filtered_lines)
|
@@ -4,6 +4,7 @@ import socket
|
|
4
4
|
import time
|
5
5
|
|
6
6
|
from collections import Counter
|
7
|
+
from docker import errors as docker_errors
|
7
8
|
from pathlib import Path
|
8
9
|
|
9
10
|
import yaml
|
@@ -25,6 +26,7 @@ from stoobly_agent.app.cli.scaffold.service_docker_compose import ServiceDockerC
|
|
25
26
|
from stoobly_agent.app.cli.scaffold.validate_command import ValidateCommand
|
26
27
|
from stoobly_agent.app.cli.scaffold.validate_exceptions import ScaffoldValidateException
|
27
28
|
from stoobly_agent.config.data_dir import DATA_DIR_NAME
|
29
|
+
from stoobly_agent.lib.logger import bcolors
|
28
30
|
|
29
31
|
from .app import App
|
30
32
|
|
@@ -84,7 +86,10 @@ class ServiceWorkflowValidateCommand(ServiceCommand, ValidateCommand):
|
|
84
86
|
if attempt < retries - 1:
|
85
87
|
time.sleep(delay)
|
86
88
|
|
87
|
-
|
89
|
+
hostname_not_reachable_message = f"{bcolors.FAIL}Connection failed to hostname: {hostname}, port: {port}, num_retries: {retries}{bcolors.ENDC}"
|
90
|
+
suggestion_message = f"{bcolors.BOLD}Try confirming that {hostname} is reachable from your machine or environment.{bcolors.ENDC}"
|
91
|
+
error_message = f"{hostname_not_reachable_message}\n\n{suggestion_message}"
|
92
|
+
raise ScaffoldValidateException(error_message)
|
88
93
|
|
89
94
|
# Check if hostname is defined in hosts file
|
90
95
|
def hostname_exists(self, hostname: str) -> bool:
|
@@ -96,7 +101,12 @@ class ServiceWorkflowValidateCommand(ServiceCommand, ValidateCommand):
|
|
96
101
|
print(f"Correct hosts mapping found for {hostname}")
|
97
102
|
return True
|
98
103
|
|
99
|
-
|
104
|
+
hosts_file_path = hosts_file_manager.get_hosts_file_path()
|
105
|
+
|
106
|
+
missing_host_message = f"{bcolors.FAIL}Missing hosts mapping for {hostname}{bcolors.ENDC}"
|
107
|
+
suggestion_message = f"{bcolors.BOLD}Confirm hostname '{hostname}' is in your hosts file at '{hosts_file_path}'. If not there, please add it{bcolors.ENDC}"
|
108
|
+
error_message = f"{missing_host_message}\n\n{suggestion_message}"
|
109
|
+
raise ScaffoldValidateException(error_message)
|
100
110
|
|
101
111
|
def validate_hostname(self, hostname: str, port: int) -> None:
|
102
112
|
print(f"Validating hostname: {hostname}")
|
@@ -188,8 +198,13 @@ class ServiceWorkflowValidateCommand(ServiceCommand, ValidateCommand):
|
|
188
198
|
|
189
199
|
def validate_proxy_container(self, service_proxy_container: Container):
|
190
200
|
print(f"Validating proxy container: {service_proxy_container.name}")
|
201
|
+
|
202
|
+
if service_proxy_container.status == 'exited' or service_proxy_container.attrs['State']['ExitCode'] != 0:
|
203
|
+
raise ScaffoldValidateException(f"Proxy container is exited: {service_proxy_container.name}")
|
204
|
+
if service_proxy_container.status != 'running':
|
205
|
+
raise ScaffoldValidateException(f"Proxy container is not running: {service_proxy_container.name}")
|
191
206
|
if not service_proxy_container.attrs:
|
192
|
-
raise ScaffoldValidateException(f"Container attributes are missing for: {
|
207
|
+
raise ScaffoldValidateException(f"Container attributes are missing for: {service_proxy_container.name}")
|
193
208
|
|
194
209
|
if not self.service_config.detached:
|
195
210
|
self.validate_public_folder(service_proxy_container)
|
@@ -219,32 +234,53 @@ class ServiceWorkflowValidateCommand(ServiceCommand, ValidateCommand):
|
|
219
234
|
self.validate_public_folder(init_container)
|
220
235
|
|
221
236
|
if self.service_config.hostname:
|
222
|
-
|
237
|
+
try:
|
238
|
+
service_proxy_container = self.docker_client.containers.get(self.service_docker_compose.proxy_container_name)
|
239
|
+
except docker_errors.NotFound:
|
240
|
+
error_message = self._ValidateCommand__generate_container_not_found_error(self.service_docker_compose.proxy_container_name)
|
241
|
+
raise ScaffoldValidateException(error_message)
|
242
|
+
|
223
243
|
self.validate_proxy_container(service_proxy_container)
|
224
244
|
|
245
|
+
# External services won't have a container to check
|
246
|
+
if self.is_local() or self.service_config.detached:
|
247
|
+
container_name = self.service_docker_compose.container_name
|
248
|
+
try:
|
249
|
+
service_container = self.docker_client.containers.get(container_name)
|
250
|
+
except docker_errors.NotFound:
|
251
|
+
error_message = self._ValidateCommand__generate_container_not_found_error(container_name)
|
252
|
+
raise ScaffoldValidateException(error_message)
|
253
|
+
if service_container.status == 'exited' or service_container.attrs['State']['ExitCode'] != 0:
|
254
|
+
raise ScaffoldValidateException(f"Custom container is exited: {service_container.name}")
|
255
|
+
if service_container.status != 'running':
|
256
|
+
raise ScaffoldValidateException(f"Custom container is not running: {service_container.name}")
|
257
|
+
|
225
258
|
if self.is_local():
|
226
259
|
print(f"Validating local user defined service: {self.service_name}")
|
227
260
|
# Validate docker-compose path exists
|
228
261
|
docker_compose_path = f"{self.app_dir_path}/{DATA_DIR_NAME}/docker/{self.service_docker_compose.service_name}/{self.workflow_name}/docker-compose.yml"
|
229
262
|
destination_path = Path(docker_compose_path)
|
263
|
+
|
264
|
+
if not destination_path.exists():
|
265
|
+
message = f"{bcolors.FAIL}Docker Compose file does not exist: {destination_path}{bcolors.ENDC}"
|
266
|
+
suggestion_message = f"{bcolors.BOLD}A missing Docker Compose file often means it got deleted by accident. Please restore from backup or rerun the scaffold service create command.{bcolors.ENDC}"
|
267
|
+
error_message = f"{message}\n\n{suggestion_message}"
|
268
|
+
raise ScaffoldValidateException(error_message)
|
230
269
|
if not destination_path.is_file():
|
231
270
|
raise ScaffoldValidateException(f"Docker compose path is not a file: {destination_path}")
|
232
271
|
|
233
272
|
# Validate docker-compose.yml file has the service defined
|
234
273
|
with open(destination_path) as f:
|
235
274
|
if self.service_name not in f.read():
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
return False
|
275
|
+
message = f"{bcolors.FAIL}Custom container service is not defined in Docker Compose file: {destination_path}{bcolors.ENDC}"
|
276
|
+
suggestion_message = f"{bcolors.BOLD}Please add your service definition. See https://docs.docker.com/reference/compose-file/services {bcolors.ENDC}"
|
277
|
+
error_message = f"{message}\n\n{suggestion_message}"
|
278
|
+
raise ScaffoldValidateException(error_message)
|
241
279
|
|
242
280
|
if self.service_config.detached:
|
243
|
-
service_container = self.docker_client.containers.get(self.service_docker_compose.container_name)
|
244
281
|
self.validate_detached(service_container)
|
245
282
|
|
246
|
-
print(f"Done validating service: {self.service_name}, success!")
|
247
|
-
print()
|
283
|
+
print(f"{bcolors.OKGREEN}✔ Done validating service: {self.service_name}, success!{bcolors.ENDC}\n")
|
248
284
|
|
249
285
|
return True
|
250
286
|
|