From c9c09a4b206cc7f818c5fb1050a8b8e00413b203 Mon Sep 17 00:00:00 2001 From: Maurice Faber Date: Tue, 26 Mar 2024 22:23:11 +0100 Subject: [PATCH] feat: ingress list per service --- .codiumai.toml | 4 +- db.yml.sample | 55 +++++++++++++------ lib/data.py | 29 ++++++++-- lib/data_test.py | 9 ++-- lib/models.py | 54 ++++++++++++------- lib/proxy.py | 47 ++++++++++------- lib/proxy_test.py | 20 +++---- lib/test_stubs.py | 51 +++++++++++++----- lib/upstream.py | 22 +++++--- lib/upstream_test.py | 93 +++++++++++++++++++++++++-------- proxy/docker-compose.yml | 3 +- proxy/tpl/config-in.yml.j2 | 15 +++--- proxy/tpl/docker-compose.yml.j2 | 5 +- proxy/tpl/routers-tcp.yml.j2 | 36 ++++++++----- proxy/tpl/routers-udp.yml.j2 | 16 ++++-- proxy/tpl/routers-web.yml.j2 | 40 +++++++------- tpl/docker-compose.yml.j2 | 31 ++++++----- 17 files changed, 353 insertions(+), 177 deletions(-) diff --git a/.codiumai.toml b/.codiumai.toml index 191c25c..288a396 100644 --- a/.codiumai.toml +++ b/.codiumai.toml @@ -58,13 +58,13 @@ example_test = """ @mock.patch("os.scandir") @mock.patch("lib.upstream.update_upstream") def test_update_upstreams(self, mock_update_upstream: Mock, mock_scandir: Mock): - mock_scandir.return_value = [DirEntry("upstream/my_project")] + mock_scandir.return_value = [DirEntry("upstream/my-project")] # Call the function under test update_upstreams() mock_update_upstream.assert_called_once_with( - "my_project", + "my-project", False, ) diff --git a/db.yml.sample b/db.yml.sample index 0cc3b0f..d0a040e 100644 --- a/db.yml.sample +++ b/db.yml.sample @@ -17,45 +17,66 @@ plugins: projects: - description: Home Assistant passthrough - domain: home.example.com + enabled: false name: home-assistant # keep this name as it also makes sure to forward port 80 for certbot services: - - name: 192.168.1.111 - passthrough: true - port: 443 + - ingress: + - domain: home.example.com + passthrough: true + port: 443 + name: 192.168.1.111 - description: itsUP API running on the host - domain: itsup.example.com name: itsUP services: - - name: host.docker.internal - port: 8888 + - ingress: + - domain: itsup.example.com + port: 8888 + name: host.docker.internal + - description: Minio service + enabled: false + name: minio + services: + - command: server --console-address ":9001" http://minio/data + env: + MINIO_ROOT_USER: root + MINIO_ROOT_PASSWORD: 83b01a6b8f210b5f5862943f3ebe257d + MINIO_DEFAULT_BUCKETS: ai-assistant + image: minio/minio:latest + ingress: + - domain: minio-api.example.com + port: 9000 + - domain: minio-ui.example.com + port: 9001 + name: app + volumes: + - /data - description: VPN server - domain: vpn.example.com - entrypoint: openvpn + enabled: false name: vpn services: - additional_properties: cap_add: - NET_ADMIN # change tag to x86_64 if not on ARM: - hostport: 1194 - hostport: 1194 image: nubacuk/docker-openvpn:aarch64 + ingress: + - domain: vpn.example.com + hostport: 1194 + port: 1194 + protocol: udp name: openvpn - port: 1194 - protocol: udp restart: always volumes: - /etc/openvpn - description: test project to demonstrate inter service connectivity - domain: hello.example.com name: test - entrypoint: master services: - env: TARGET: cost concerned people INFORMANT: http://test-informant:8080 image: otomi/nodejs-helloworld:v1.2.13 + ingress: + - domain: hello.example.com name: master volumes: - /data/bla @@ -67,9 +88,9 @@ projects: image: otomi/nodejs-helloworld:v1.2.13 name: informant - description: whoami service - domain: whoami.example.com - entrypoint: web name: whoami services: - image: traefik/whoami:latest + ingress: + - domain: whoami.example.com name: web diff --git a/lib/data.py b/lib/data.py index 708729e..35e78d2 100644 --- a/lib/data.py +++ b/lib/data.py @@ -2,11 +2,11 @@ import inspect from logging import debug, info from modulefinder import Module -from typing import Any, Callable, Dict, List, cast +from typing import Any, Callable, Dict, List, Union, cast import yaml -from lib.models import Env, Plugin, PluginRegistry, Project, Service +from lib.models import Env, Ingress, Plugin, PluginRegistry, Project, Service def get_db() -> Dict[str, List[Dict[str, Any]] | Dict[str, Any]]: @@ -69,7 +69,11 @@ def get_plugins(filter: Callable[[Plugin], bool] = None) -> List[Plugin]: return plugins -def get_projects(filter: Callable[[Project, Service], bool] = None) -> List[Project]: +def get_projects( + filter: Union[ + Callable[[Project, Service, Ingress], bool], Callable[[Project, Service], bool], Callable[[Project], bool] + ] = None, +) -> List[Project]: """Get all projects. Optionally filter the results.""" debug("Getting projects" + (f" with filter {filter}" if filter else "")) db = get_db() @@ -78,8 +82,23 @@ def get_projects(filter: Callable[[Project, Service], bool] = None) -> List[Proj for project in projects_raw: services = [] p = Project(**project) + if not filter: + ret.append(p) + continue for s in p.services.copy(): - if not filter or filter(p, s): + ingress = [] + for i in s.ingress.copy(): + if ( + ( + filter.__code__.co_argcount == 3 + and cast(Callable[[Project, Service, Ingress], bool], filter)(p, s, i) + ) + or (filter.__code__.co_argcount == 2 and cast(Callable[[Project, Service], bool], filter)(p, s)) + or (filter.__code__.co_argcount == 1 and cast(Callable[[Project], bool], filter)(p)) + ): + ingress.append(i) + if len(ingress) > 0: + s.ingress = ingress services.append(s) if len(services) > 0: p.services = services @@ -125,7 +144,7 @@ def upsert_project(project: Project) -> None: def get_services(project: str = None) -> List[Service]: """Get all services or just for a particular project.""" debug(f"Getting services for project {project}" if project else "Getting all services") - return [s for p in get_projects(lambda p, _: not bool(project) or p.name == project) for s in p.services] + return [s for p in get_projects(lambda p: not bool(project) or p.name == project) for s in p.services] def get_service(project: str | Project, service: str, throw: bool = True) -> Service: diff --git a/lib/data_test.py b/lib/data_test.py index 32c1aa6..e85ee72 100644 --- a/lib/data_test.py +++ b/lib/data_test.py @@ -57,7 +57,7 @@ def test_write_projects(self, mock_write_db: Mock) -> None: def test_get_projects_with_filter(self, _: Mock) -> None: # Call the function under test - result = get_projects(lambda p, s: p.name == "test" and p.entrypoint == s.name) + result = get_projects(lambda p, s: p.name == "test" and s.ingress) # Assert the result expected_result = [ @@ -67,7 +67,7 @@ def test_get_projects_with_filter(self, _: Mock) -> None: domain="hello.example.com", entrypoint="master", services=[ - test_projects[3].services[0], + test_projects[4].services[0], ], ), ] @@ -79,6 +79,7 @@ def test_get_projects_with_filter(self, _: Mock) -> None: return_value=test_db.copy(), ) def test_get_projects_no_filter(self, mock_get_db: Mock) -> None: + self.maxDiff = None # Call the function under test result = get_projects() @@ -125,8 +126,8 @@ def test_upsert_nonexistent_project_fixed(self, mock_write_projects: Mock, mock_ mock_write_projects.assert_called_once_with(test_projects + [new_project]) # Upsert a project's service' env - @mock.patch("lib.data.get_project", return_value=test_projects[3].model_copy()) - @mock.patch("lib.data.get_service", return_value=test_projects[3].services[1].model_copy()) + @mock.patch("lib.data.get_project", return_value=test_projects[4].model_copy()) + @mock.patch("lib.data.get_service", return_value=test_projects[4].services[1].model_copy()) @mock.patch("lib.data.upsert_service") def test_upsert_env(self, mock_upsert_service: Mock, mock_get_service: Mock, mock_get_project: Mock) -> None: diff --git a/lib/models.py b/lib/models.py index 9d46b88..5afeec4 100644 --- a/lib/models.py +++ b/lib/models.py @@ -1,4 +1,5 @@ from enum import Enum +from gc import enable from typing import Any, Dict, List from github_webhooks.schemas import WebhookCommonPayload @@ -45,6 +46,34 @@ class Protocol(str, Enum): udp = "udp" +class ProxyProtocol(str, Enum): + """ProxyProtocol enum""" + + v1 = 1 + v2 = 2 + + +class Ingress(BaseModel): + """Ingress model""" + + domain: str + """The domain to use for the service""" + hostport: int = None + """The port to expose on the host""" + passthrough: bool = False + """Wether or not traffic to this service is forwarded as-is (without terminating SSL)""" + path_prefix: str = None + """Should the service be exposed under a specific path?""" + path_remove: bool = False + """When set, the path prefix will be removed from the request before forwarding it to the service""" + port: int = 8080 + """The port to use for the service. If not defined will default to parent service port[idx]""" + protocol: Protocol = Protocol.tcp + """The protocol to use for the port""" + proxyprotocol: ProxyProtocol | None = ProxyProtocol.v2 + """When set, the service is expected to accept the given PROXY protocol version. Explicitly set to null to disable.""" + + class Service(BaseModel): """Service model""" @@ -54,25 +83,14 @@ class Service(BaseModel): """The command to run in the service""" env: Env = None """A dictionary of environment variables to pass to the service""" - hostport: int = None - """The port to expose on the host""" image: str = None - """The image name plus tag to use for the service""" + """The full container image uri of the service""" + ingress: List[Ingress] = [] + """Ingress configuration for the service. If a string is passed, it will be used as the domain.""" labels: List[str] = [] """Extra labels to add to the service. Should not interfere with generated traefik labels for ingress.""" name: str - passthrough: bool = False - """Wether or not traffic to this service is forwarded as-is (without terminating SSL)""" - path_prefix: str = None - """Should the service be exposed under a specific path?""" - path_remove: bool = False - """When set, the path prefix will be removed from the request before forwarding it to the service""" - port: int = 8080 - """When set, the service will be exposed on this domain.""" - proxyprotocol: bool = True - """When set, the service will be exposed using the PROXY protocol version 2""" - protocol: Protocol = Protocol.tcp - """The protocol to use for the service""" + """The name of the service""" restart: str = "unless-stopped" """The restart policy to use for the service""" volumes: List[str] = [] @@ -82,11 +100,9 @@ class Service(BaseModel): class Project(BaseModel): """Project model""" - name: str description: str = None - domain: str = None - entrypoint: str = None - """When "entrypoint" is set it should point to a service in the "services" list that has an image""" + enabled: bool = True + name: str services: List[Service] = [] diff --git a/lib/proxy.py b/lib/proxy.py index cab51d5..694516f 100644 --- a/lib/proxy.py +++ b/lib/proxy.py @@ -6,7 +6,7 @@ from jinja2 import Template from lib.data import get_plugin_registry, get_project, get_projects -from lib.models import Protocol +from lib.models import Protocol, ProxyProtocol from lib.utils import run_command load_dotenv() @@ -14,8 +14,13 @@ def get_domains(project: str = None) -> List[str]: """Get all domains in use""" - projects = get_projects(filter=lambda p, s: not s.passthrough and (not project or project == p.name)) - return [p.domain for p in projects if p.domain] + projects = get_projects(filter=lambda p, _, i: not i.passthrough and (not project or project == p.name)) + domains = [] + for p in projects: + for s in p.services: + for i in s.ingress: + domains.append(i.domain) + return domains def get_internal_map() -> Dict[str, str]: @@ -24,19 +29,24 @@ def get_internal_map() -> Dict[str, str]: def get_terminate_map() -> Dict[str, str]: - filtered = get_projects( - filter=lambda p, s: bool(p.domain) and not s.passthrough and (not bool(p.entrypoint) or p.entrypoint == s.name) - ) - return { - p.domain: (f"{p.name}-" if p.entrypoint == s.name else "") + f"{s.name}:{s.port}" - for p in filtered - for s in p.services - } + projects = get_projects(filter=lambda _, _2, i: not i.passthrough) + map = {} + for p in projects: + for s in p.services: + prefix = f"{p.name}-" if s.image else "" + for i in s.ingress: + map[i.domain] = f"{prefix}{s.name}:{i.port}" + return map def get_passthrough_map() -> Dict[str, str]: - filtered = get_projects(filter=lambda _, s: s.passthrough is True) - return {p.domain: f"{s.name}:{s.port}" for p in filtered for s in p.services} + projects = get_projects(filter=lambda _, _2, i: i.passthrough) + map = {} + for p in projects: + for s in p.services: + for i in s.ingress: + map[i.domain] = f"{s.name}:{i.port}" + return map def write_maps() -> None: @@ -78,9 +88,7 @@ def write_terminate() -> None: def write_routers() -> None: - projects_tcp = get_projects( - filter=lambda p, s: s.protocol == Protocol.tcp and (not bool(p.entrypoint) or p.entrypoint == s.name) - ) + projects_tcp = get_projects(filter=lambda _, _2, i: i.protocol == Protocol.tcp) with open("proxy/tpl/routers-web.yml.j2", encoding="utf-8") as f: t = f.read() tpl_routers_web = Template(t) @@ -97,10 +105,11 @@ def write_routers() -> None: with open("proxy/tpl/routers-tcp.yml.j2", encoding="utf-8") as f: t = f.read() tpl_routers_tcp = Template(t) + tpl_routers_tcp.globals["ProxyProtocol"] = ProxyProtocol routers_tcp = tpl_routers_tcp.render(projects=projects_tcp, traefik_rule=f"HostSNI(`{domain}`)") with open("proxy/traefik/routers-tcp.yml", "w", encoding="utf-8") as f: f.write(routers_tcp) - projects_udp = get_projects(filter=lambda _, s: s.protocol == Protocol.udp) + projects_udp = get_projects(filter=lambda _, _2, i: i.protocol == Protocol.udp) with open("proxy/tpl/routers-udp.yml.j2", encoding="utf-8") as f: t = f.read() tpl_routers_tcp = Template(t) @@ -115,7 +124,7 @@ def write_config() -> None: tpl_config_tcp = Template(t) tpl_config_tcp.globals["Protocol"] = Protocol trusted_ips_cidrs = os.environ.get("TRUSTED_IPS_CIDRS").split(",") - projects_hostport = get_projects(filter=lambda _, s: bool(s.hostport)) + projects_hostport = get_projects(filter=lambda _, _2, i: bool(i.hostport)) config_tcp = tpl_config_tcp.render(projects=projects_hostport, trusted_ips_cidrs=trusted_ips_cidrs) with open("proxy/traefik/config-in.yml", "w", encoding="utf-8") as f: f.write(config_tcp) @@ -141,7 +150,7 @@ def write_compose() -> None: t = f.read() tpl_compose = Template(t) tpl_compose.globals["Protocol"] = Protocol - projects_hostport = get_projects(filter=lambda _, s: bool(s.hostport)) + projects_hostport = get_projects(filter=lambda _, _2, i: bool(i.hostport)) compose = tpl_compose.render(projects=projects_hostport, plugin_registry=plugin_registry) with open("proxy/docker-compose.yml", "w", encoding="utf-8") as f: f.write(compose) diff --git a/lib/proxy_test.py b/lib/proxy_test.py index f31da02..a48002b 100644 --- a/lib/proxy_test.py +++ b/lib/proxy_test.py @@ -7,7 +7,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from lib.data import Service -from lib.models import Project +from lib.models import Ingress, Project from lib.proxy import ( get_internal_map, get_passthrough_map, @@ -50,6 +50,8 @@ def test_get_terminate_map(self, _: Mock) -> None: expected_map = { "itsup.example.com": "host.docker.internal:8888", "hello.example.com": "test-master:8080", + "minio-api.example.com": "minio-app:9000", + "minio-ui.example.com": "minio-app:9001", "vpn.example.com": "vpn-openvpn:1194", "whoami.example.com": "whoami-web:8080", } @@ -60,10 +62,10 @@ def test_get_terminate_map(self, _: Mock) -> None: return_value=[ Project( name="testp", - domain="example.com", - entrypoint="bla", services=[ - Service(name="my_service", port=8080, passthrough=True), + Service( + ingress=[Ingress(domain="some.example.com", port=8080, passthrough=True)], name="my-service" + ), ], ), ], @@ -75,7 +77,7 @@ def test_get_passthrough_map(self, _: Mock) -> None: # Assert the result expected_map = { - "example.com": "my_service:8080", + "some.example.com": "my-service:8080", } self.assertEqual(passthrough_map, expected_map) @@ -100,14 +102,14 @@ def test_write_maps( # Mock the get_passthrough_map function mock_get_passthrough_map.return_value = { - "example.com": "my_service:8080", - "example.org": "my_service:8080", + "example.com": "my-service:8080", + "example.org": "my-service:8080", } # Mock the get_terminate_map function mock_get_terminate_map.return_value = { - "example.com": "my_project-my_service:8080", - "example.org": "my_service:8080", + "example.com": "my-project-my-service:8080", + "example.org": "my-service:8080", } mock_template.return_value = {"render": Mock()} diff --git a/lib/test_stubs.py b/lib/test_stubs.py index 969e6c9..f1c1236 100644 --- a/lib/test_stubs.py +++ b/lib/test_stubs.py @@ -1,6 +1,6 @@ import yaml -from lib.models import Plugin, Project, Service +from lib.models import Ingress, Plugin, Project, Service with open("db.yml.sample", encoding="utf-8") as f: test_db = yaml.safe_load(f) @@ -30,33 +30,59 @@ test_projects = [ Project( description="Home Assistant passthrough", - domain="home.example.com", + enabled=False, name="home-assistant", services=[ - Service(name="192.168.1.111", passthrough=True, port=443), + Service(ingress=[Ingress(domain="home.example.com", passthrough=True, port=443)], name="192.168.1.111"), ], ), Project( description="itsUP API running on the host", - domain="itsup.example.com", name="itsUP", services=[ - Service(name="host.docker.internal", port=8888), + Service(ingress=[Ingress(domain="itsup.example.com", port=8888)], name="host.docker.internal"), + ], + ), + Project( + description="Minio service", + enabled=False, + name="minio", + services=[ + Service( + command='server --console-address ":9001" http://minio/data', + env={ + "MINIO_ROOT_USER": "root", + "MINIO_ROOT_PASSWORD": "83b01a6b8f210b5f5862943f3ebe257d", + "MINIO_DEFAULT_BUCKETS": "ai-assistant", + }, + image="minio/minio:latest", + ingress=[ + Ingress(domain="minio-api.example.com", port=9000), + Ingress(domain="minio-ui.example.com", port=9001), + ], + name="app", + volumes=["/data"], + ), ], ), Project( description="VPN server", - domain="vpn.example.com", - entrypoint="openvpn", + enabled=False, name="vpn", services=[ Service( additional_properties={"cap_add": ["NET_ADMIN"]}, hostport=1194, image="nubacuk/docker-openvpn:aarch64", + ingress=[ + Ingress( + domain="vpn.example.com", + hostport=1194, + port=1194, + protocol="udp", + ) + ], name="openvpn", - port=1194, - protocol="udp", restart="always", volumes=["/etc/openvpn"], ), @@ -64,13 +90,12 @@ ), Project( description="test project to demonstrate inter service connectivity", - domain="hello.example.com", - entrypoint="master", name="test", services=[ Service( env={"TARGET": "cost concerned people", "INFORMANT": "http://test-informant:8080"}, image="otomi/nodejs-helloworld:v1.2.13", + ingress=[Ingress(domain="hello.example.com")], name="master", volumes=["/data/bla", "/etc/dida"], ), @@ -84,11 +109,9 @@ ), Project( description="whoami service", - domain="whoami.example.com", - entrypoint="web", name="whoami", services=[ - Service(image="traefik/whoami:latest", name="web"), + Service(image="traefik/whoami:latest", ingress=[Ingress(domain="whoami.example.com")], name="web"), ], ), ] diff --git a/lib/upstream.py b/lib/upstream.py index 7466aa2..b672ca4 100644 --- a/lib/upstream.py +++ b/lib/upstream.py @@ -16,6 +16,8 @@ def write_upstream(project: Project) -> None: t = f.read() tpl = Template(t) tpl.globals["Protocol"] = Protocol + tpl.globals["isinstance"] = isinstance + tpl.globals["str"] = str if os.environ.get("PYTHON_ENV") != "production": content = tpl.render(project=project, domain=os.environ.get("TRAEFIK_DOMAIN"), env="development") else: @@ -31,8 +33,8 @@ def write_upstream_volume_folders(project: Project) -> None: def write_upstreams() -> None: - # iterate over projects that have an entrypoint; - for p in [project for project in get_projects() if project.entrypoint]: + projects = get_projects(filter=lambda p, s: p.enabled and s.image) + for p in projects: os.makedirs(f"upstream/{p.name}", exist_ok=True) write_upstream(p) write_upstream_volume_folders(p) @@ -50,22 +52,26 @@ def check_upstream(project: str, service: str = None) -> None: def update_upstream( - project: str, + project: Project | str, service: str = None, rollout: bool = False, ) -> None: """Reload service(s) in a docker compose config""" - info(f"Updating upstream for project {project}") - run_command(["docker", "compose", "pull"], cwd=f"upstream/{project}") - run_command(["docker", "compose", "up", "-d"], cwd=f"upstream/{project}") + project = get_project(project, throw=True) + info(f"Updating upstream for project {project.name}") + if project.enabled: + run_command(["docker", "compose", "pull"], cwd=f"upstream/{project.name}") + run_command(["docker", "compose", "up", "-d"], cwd=f"upstream/{project.name}") + else: + run_command(["docker", "compose", "down"], cwd=f"upstream/{project.name}") if not rollout: return # filter out the project by name and its services that should have an image - projects = get_projects(filter=lambda p, s: p.name == project and bool(s.image)) + projects = get_projects(filter=lambda p, s: p.enabled and p.name == project.name and bool(s.image)) for p in projects: for s in p.services: if not service or s.name == service: - rollout_service(project, s.name) + rollout_service(project.name, s.name) def update_upstreams(rollout: bool = False) -> None: diff --git a/lib/upstream_test.py b/lib/upstream_test.py index c1435ae..31d1001 100644 --- a/lib/upstream_test.py +++ b/lib/upstream_test.py @@ -9,6 +9,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from lib.data import Service +from lib.test_stubs import test_db from lib.upstream import update_upstream, update_upstreams, write_upstream @@ -60,6 +61,25 @@ def is_dir(self) -> bool: expose: - '8080'""" +_ret_projects = [ + Project( + name="my-project", + entrypoint="service1", + services=[ + Service(name="service1", port=8080), + Service(name="service2", port=8080), + ], + ), + Project( + enabled=False, + name="another-project", + entrypoint="service1", + services=[ + Service(name="service1", port=8080), + ], + ), +] + class TestUpdateUpstream(TestCase): @mock.patch("builtins.open", new_callable=mock.mock_open, read_data=_ret_tpl) @@ -92,27 +112,20 @@ def test_write_upstream( @mock.patch("lib.upstream.run_command") @mock.patch( "lib.upstream.get_projects", - return_value=[ - Project( - name="my_project", - entrypoint="service1", - services=[ - Service(name="service1", port=8080), - Service(name="service2", port=8080), - ], - ) - ], + return_value=[_ret_projects[0]], ) - def test_update_upstream( + @mock.patch("lib.upstream.get_project", return_value=_ret_projects[0]) + def test_update_upstream_enabled( self, _: Mock, + _2: Mock, mock_run_command: Mock, mock_rollout_service: Mock, ) -> None: # Call the function under test update_upstream( - "my_project", + "my-project", "service1", rollout=True, ) @@ -122,27 +135,64 @@ def test_update_upstream( [ call( ["docker", "compose", "pull"], - cwd="upstream/my_project", + cwd="upstream/my-project", ), call( ["docker", "compose", "up", "-d"], - cwd="upstream/my_project", + cwd="upstream/my-project", ), ] ) # Assert that the rollout_service function is called correctly # with the correct arguments - mock_rollout_service.assert_called_once_with("my_project", "service1") + mock_rollout_service.assert_called_once_with("my-project", "service1") + + @mock.patch("lib.upstream.rollout_service") + @mock.patch("lib.upstream.run_command") + @mock.patch( + "lib.upstream.get_projects", + return_value=[], + ) + @mock.patch("lib.upstream.get_project", return_value=_ret_projects[1]) + def test_update_upstream_disabled( + self, + _: Mock, + _2: Mock, + mock_run_command: Mock, + mock_rollout_service: Mock, + ) -> None: + + # Call the function under test + update_upstream( + "another-project", + "service1", + rollout=True, + ) + + # Assert that the subprocess.Popen was called with the correct arguments + mock_run_command.assert_has_calls( + [ + call( + ["docker", "compose", "down"], + cwd="upstream/another-project", + ), + ] + ) + + # Assert that the rollout_service function is called correctly + # with the correct arguments + mock_rollout_service.assert_not_called() @mock.patch("lib.upstream.rollout_service") @mock.patch("lib.upstream.run_command") - def test_update_upstream_no_rollout(self, mock_run_command: Mock, mock_rollout_service: Mock) -> None: + @mock.patch("lib.upstream.get_project", return_value=_ret_projects[0]) + def test_update_upstream_no_rollout(self, _: Mock, _2: Mock, mock_rollout_service: Mock) -> None: # Call the function under test update_upstream( - "my_project", - "my_service", + "my-project", + "my-service", rollout=False, ) @@ -152,14 +202,15 @@ def test_update_upstream_no_rollout(self, mock_run_command: Mock, mock_rollout_s @mock.patch("os.scandir") @mock.patch("lib.upstream.update_upstream") @mock.patch("lib.upstream.run_command") - def test_update_upstreams(self, _: Mock, mock_update_upstream: Mock, mock_scandir: Mock) -> None: - mock_scandir.return_value = [DirEntry("upstream/my_project")] + @mock.patch("lib.upstream.get_project", return_value=_ret_projects[0]) + def test_update_upstreams(self, _: Mock, _2: Mock, mock_update_upstream: Mock, mock_scandir: Mock) -> None: + mock_scandir.return_value = [DirEntry("upstream/my-project")] # Call the function under test update_upstreams() mock_update_upstream.assert_called_once_with( - "my_project", + "my-project", rollout=False, ) diff --git a/proxy/docker-compose.yml b/proxy/docker-compose.yml index 37f0b9d..82c4c6d 100755 --- a/proxy/docker-compose.yml +++ b/proxy/docker-compose.yml @@ -108,4 +108,5 @@ services: volumes: logs: - crowdsec-db: \ No newline at end of file + crowdsec-db: + shared: \ No newline at end of file diff --git a/proxy/tpl/config-in.yml.j2 b/proxy/tpl/config-in.yml.j2 index 1daf029..843658b 100644 --- a/proxy/tpl/config-in.yml.j2 +++ b/proxy/tpl/config-in.yml.j2 @@ -33,20 +33,23 @@ entryPoints: {%- endfor %} {%- for p in projects %} - {% for s in p.services %} - {{ Protocol(s.protocol).value }}-{{ s.hostport }}: - address: ':{{ s.hostport }}/{{ Protocol(s.protocol).value }}' + {%- for s in p.services %} + {%- for i in s.ingress %} + {%- set protocol = Protocol(i.protocol).value %} + {{ protocol }}-{{ i.hostport }}: + address: ':{{ i.hostport }}/{{ protocol }}' forwardedHeaders: trustedIPs: - 127.0.0.1/32 - {%- for ip in trusted_ips_cidrs %} + {%- for ip in trusted_ips_cidrs %} - {{ ip }} - {%- endfor %} + {%- endfor %} proxyProtocol: trustedIPs: - 127.0.0.0/32 - {%- for ip in trusted_ips_cidrs %} + {%- for ip in trusted_ips_cidrs %} - {{ ip }} + {%- endfor %} {%- endfor %} {%- endfor %} {%- endfor %} diff --git a/proxy/tpl/docker-compose.yml.j2 b/proxy/tpl/docker-compose.yml.j2 index e11bc48..0093824 100755 --- a/proxy/tpl/docker-compose.yml.j2 +++ b/proxy/tpl/docker-compose.yml.j2 @@ -61,7 +61,9 @@ services: - 8443:8443 {%- for p in projects %} {%- for s in p.services %} - - {{ s.hostport }}:{{ s.port }}/{{ Protocol(s.protocol).value }} + {%- for i in s.ingress %} + - {{ i.hostport }}:{{ i.port }}/{{ Protocol(i.protocol).value }} + {%- endfor %} {%- endfor %} {%- endfor %} volumes: @@ -118,3 +120,4 @@ services: volumes: logs: crowdsec-db: + shared: diff --git a/proxy/tpl/routers-tcp.yml.j2 b/proxy/tpl/routers-tcp.yml.j2 index cad9d0e..70f0532 100644 --- a/proxy/tpl/routers-tcp.yml.j2 +++ b/proxy/tpl/routers-tcp.yml.j2 @@ -1,16 +1,20 @@ tcp: routers: {%- for p in projects %} - {%- set s = p.services[0] %} - {%- if s.passthrough %} - {{ p.name }}: + {%- for s in p.services %} + {%- for i in s.ingress %} + {%- set name = p.name ~ '-' ~ s.name.replace('.', '-') ~ '-' ~ i.port %} + {%- if i.passthrough %} + {{ name }}: entryPoints: - tcp-secure - service: {{ p.name }} - rule: 'HostSNI(`{{ p.domain }}`)' + service: {{ name }} + rule: 'HostSNI(`{{ i.domain }}`){% if i.path_prefix %} && PathPrefix(`{{ i.path_prefix }}`){% endif %}' tls: passthrough: true - {%- endif %} + {%- endif %} + {%- endfor %} + {%- endfor %} {%- endfor %} http: entryPoints: @@ -26,17 +30,21 @@ tcp: passthrough: true services: {%- for p in projects %} - {%- set s = p.services[0] %} - {%- if s.passthrough %} - {{ p.name }}: + {%- for s in p.services %} + {%- for i in s.ingress %} + {%- set name = p.name ~ '-' ~ s.name.replace('.', '-') ~ '-' ~ i.port %} + {%- if i.passthrough %} + {{ name }}: loadBalancer: - {%- if s.proxyprotocol %} + {%- if i.proxyprotocol %} proxyProtocol: - version: 2 - {%- endif %} + version: {{ ProxyProtocol(i.proxyprotocol).value }} + {%- endif %} servers: - - address: {{ s.name }}:{{ s.port }} - {%- endif %} + - address: {{ s.name }}:{{ i.port }} + {%- endif %} + {%- endfor %} + {%- endfor %} {%- endfor %} http: loadBalancer: diff --git a/proxy/tpl/routers-udp.yml.j2 b/proxy/tpl/routers-udp.yml.j2 index 6d5c988..a593a18 100644 --- a/proxy/tpl/routers-udp.yml.j2 +++ b/proxy/tpl/routers-udp.yml.j2 @@ -3,19 +3,25 @@ udp: routers: {%- for p in projects %} {%- for s in p.services %} - {{ p.name }}: + {%- for i in s.ingress %} + {%- set name = p.name ~ '-' ~ s.name.replace('.', '-') ~ '-' ~ i.port %} + {{ name }}: entryPoints: - - udp-{{ s.hostport }} - service: {{ p.name }}-{{ s.name }} + - udp-{{ i.hostport }} + service: {{ name }} + {%- endfor %} {%- endfor %} {%- endfor %} services: {%- for p in projects %} {%- for s in p.services %} - {{ p.name }}-{{ s.name }}: + {%- for i in s.ingress %} + {%- set name = p.name ~ '-' ~ s.name.replace('.', '-') ~ '-' ~ i.port %} + {{ name }}: loadBalancer: servers: - - address: {{ p.name }}-{{ s.name }}:{{ s.port }} + - address: {{ p.name }}-{{ s.name }}:{{ i.port }} + {%- endfor %} {%- endfor %} {%- endfor %} {%- endif %} diff --git a/proxy/tpl/routers-web.yml.j2 b/proxy/tpl/routers-web.yml.j2 index e8c2e47..492e9fe 100644 --- a/proxy/tpl/routers-web.yml.j2 +++ b/proxy/tpl/routers-web.yml.j2 @@ -13,37 +13,41 @@ http: tls: certResolver: letsencrypt {%- for p in projects %} - {%- set s = p.services[0] %} - {%- if s.passthrough or s.name != p.entrypoint %} - {{ p.name}}: - service: {{ p.name }} + {%- for s in p.services %} + {%- for i in s.ingress %} + {%- set name = p.name ~ '-' ~ s.name.replace('.', '-') ~ '-' ~ i.port %} + {{ name}}: + service: {{ name }} entryPoints: - {%- if s.passthrough %} + {%- if i.passthrough %} - web - rule: 'Host(`{{ p.domain }}`) && PathPrefix(`/.well-known/acme-challenge/`)' - {%- else %} + rule: 'Host(`{{ i.domain }}`) && PathPrefix(`/.well-known/acme-challenge/`)' + {%- else %} - web-secure - rule: 'Host(`{{ p.domain }}`)' + rule: 'Host(`{{ i.domain }}`){% if i.path_prefix %} && PathPrefix(`{{ i.path_prefix }}`){% endif %}' tls: certResolver: letsencrypt - {%- endif %} - {%- endif %} + {%- endif %} + {%- endfor %} + {%- endfor %} {%- endfor %} services: {%- for p in projects %} - {%- set s = p.services[0] %} - {%- if s.passthrough or s.name != p.entrypoint %} - {{ p.name}}: + {%- for s in p.services %} + {%- for i in s.ingress %} + {%- set name = p.name ~ '-' ~ s.name.replace('.', '-') ~ '-' ~ i.port %} + {{ name}}: loadBalancer: servers: - {%- if s.passthrough %} + {%- if i.passthrough %} # just forwarding port 80 for doing own http challenge: - url: http://{{ s.name }}:80/ - {%- else %} + {%- else %} # routing to a service on the host: - - url: http://{{ s.name }}:{{ s.port }}/ - {%- endif %} - {%- endif %} + - url: http://{{ s.name }}:{{ i.port }}/ + {%- endif %} + {%- endfor %} + {%- endfor %} {%- endfor %} middlewares: removeServiceSelector: diff --git a/tpl/docker-compose.yml.j2 b/tpl/docker-compose.yml.j2 index 58b8be4..358b1a7 100755 --- a/tpl/docker-compose.yml.j2 +++ b/tpl/docker-compose.yml.j2 @@ -1,3 +1,4 @@ +{%- set p = project %} --- version: '3.8' @@ -8,7 +9,6 @@ networks: services: {%- for s in project.services %} - {%- set p = project %} {{ project.name }}-{{ s.name }}: {%- if s.command %} command: {{ s.command }} @@ -19,31 +19,34 @@ services: {{ k }}: "{{ v }}" {%- endfor %} {%- endif %} - {%- if p.entrypoint == s.name %} expose: - - '{{ s.port }}/{{ Protocol[s.protocol].value }}' - {%- endif %} + {%- for i in s.ingress %} + - '{{ i.port }}/{{ Protocol[i.protocol].value }}' + {%- endfor %} image: {{ s.image }} - {%- if p.entrypoint == s.name %} + {%- if s.ingress | length > 1 %} labels: - traefik.enable=true - traefik.docker.network=proxynet - - traefik.http.routers.{{ p.name }}.entrypoints=web-secure - - traefik.http.routers.{{ p.name }}.rule=Host(`{{ p.domain }}`){%- if s.path_prefix %} && PathPrefix(`{{ s.path_prefix }}`){%- endif %} - - traefik.http.routers.{{ p.name }}.tls.certresolver=letsencrypt - {%- if path_prefix and path_remove %} - - traefik.http.middlewares.removeServiceSelector.stripPrefix.prefixes={{ path_prefix }} - {%- endif %} - - traefik.http.services.{{ p.name }}.loadbalancer.server.port={{ s.port }} + {%- for i in s.ingress %} + {%- set name = project.name ~ '-' ~ s.name.replace('.', '-') ~ '-' ~ i.port %} + - traefik.http.routers.{{ name }}.entrypoints=web-secure + - traefik.http.routers.{{ name }}.rule=Host(`{{ i.domain }}`){%- if i.path_prefix %} && PathPrefix(`{{ i.path_prefix }}`){%- endif %} + - traefik.http.routers.{{ name }}.tls.certresolver=letsencrypt + {%- if i.path_prefix and i.path_remove %} + - traefik.http.middlewares.removeServiceSelector.stripPrefix.prefixes={{ i.path_prefix }} + {%- endif %} + - traefik.http.services.{{ name }}.loadbalancer.server.port={{ i.port }} + {%- endfor %} {%- for l in s.labels %} - {{ l }} {%- endfor %} - {%- endif %} + {%- endif %}0 networks: {%- if p.services | length > 1 %} - default {%- endif %} - {%- if p.entrypoint == s.name %} + {%- if s.ingress | length > 1 %} - proxynet {%- endif %} restart: {{ s.restart }}