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 CHANGED
@@ -1,2 +1,2 @@
1
1
  COMMAND = 'stoobly-agent'
2
- VERSION = '1.6.7'
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
- if not config.hostname:
21
- continue
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
- gateway_base['networks'] = {
54
- 'app.egress': {},
55
- 'app.ingress': {
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
- def __get_hosts_file_path(self) -> str:
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.__get_hosts_file_path()
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.__get_hosts_file_path()
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.__get_hosts_file_path()
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
- raise ScaffoldValidateException(f"Connection failed to hostname: {hostname}, port: {port}")
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
- raise ScaffoldValidateException(f"Missing hosts mapping for {hostname}")
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: {container.name}")
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
- service_proxy_container = self.docker_client.containers.get(self.service_docker_compose.proxy_container_name)
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
- raise ScaffoldValidateException(f"Local service is not defined in Docker Compose file: {destination_path}")
237
-
238
- service_container = self.docker_client.containers.get(self.service_docker_compose.container_name)
239
- if service_container.status == 'exited':
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
 
@@ -0,0 +1,8 @@
1
+ import os
2
+ import pathlib
3
+
4
+ def __path():
5
+ return os.path.join(pathlib.Path(__file__).parent.resolve())
6
+
7
+ def run_template_path():
8
+ return os.path.join(__path(), 'run')
@@ -1,4 +1,4 @@
1
- FROM stoobly/agent:1.6
1
+ FROM stoobly/agent:1.7
2
2
 
3
3
  ARG USER_ID
4
4
 
@@ -2,7 +2,7 @@ services:
2
2
  gateway_base:
3
3
  environment:
4
4
  TRUST_DOWNSTREAM_PROXY: true
5
- image: nginxproxy/nginx-proxy:1.5
5
+ image: nginxproxy/nginx-proxy:1.7
6
6
  profiles:
7
7
  - gateway_base
8
8
  volumes: