mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
feat(web): add Open Website button and command for stacks with Traefik labels (#110)
* feat(web): add Open Website button and command for stacks with Traefik labels Parse traefik.http.routers.*.rule labels to extract Host() rules and display "Open Website" button(s) on stack pages. Also adds the command to the command palette. - Add extract_website_urls() function to compose.py - Determine scheme (http/https) from entrypoint (websecure/web) - Prefer HTTPS when same host has both protocols - Support environment variable interpolation - Add external_link icon from Lucide - Add comprehensive tests for URL extraction * refactor: move extract_website_urls to traefik.py and reuse existing parsing Instead of duplicating the Traefik label parsing logic in compose.py, reuse generate_traefik_config() with check_all=True to get the parsed router configuration, then extract Host() rules from it. - Move extract_website_urls from compose.py to traefik.py - Reuse generate_traefik_config for label parsing - Move tests from test_compose.py to test_traefik.py - Update import in pages.py * test: add comprehensive tests for extract_website_urls Cover real-world patterns found in stacks: - Multiple Host() in one rule with || operator - Host() combined with PathPrefix (e.g., && PathPrefix(`/api`)) - Multiple services in one stack (like arr stack) - Labels in list format (- key=value) - No entrypoints (defaults to http) - Multiple entrypoints including websecure
This commit is contained in:
@@ -8,6 +8,7 @@ use host-published ports for cross-host reachability.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -383,3 +384,53 @@ def render_traefik_config(dynamic: dict[str, Any]) -> str:
|
||||
"""Render Traefik dynamic config as YAML with a header comment."""
|
||||
body = yaml.safe_dump(dynamic, sort_keys=False)
|
||||
return _TRAEFIK_CONFIG_HEADER + body
|
||||
|
||||
|
||||
_HOST_RULE_PATTERN = re.compile(r"Host\(`([^`]+)`\)")
|
||||
|
||||
|
||||
def extract_website_urls(config: Config, stack: str) -> list[str]:
|
||||
"""Extract website URLs from Traefik labels in a stack's compose file.
|
||||
|
||||
Reuses generate_traefik_config to parse labels, then extracts Host() rules
|
||||
from router configurations.
|
||||
|
||||
Returns a list of unique URLs, preferring HTTPS over HTTP.
|
||||
"""
|
||||
try:
|
||||
dynamic, _ = generate_traefik_config(config, [stack], check_all=True)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
routers = dynamic.get("http", {}).get("routers", {})
|
||||
if not routers:
|
||||
return []
|
||||
|
||||
# Track URLs with their scheme preference (https > http)
|
||||
urls: dict[str, str] = {} # host -> scheme
|
||||
|
||||
for router_info in routers.values():
|
||||
if not isinstance(router_info, dict):
|
||||
continue
|
||||
|
||||
rule = router_info.get("rule", "")
|
||||
entrypoints = router_info.get("entrypoints", [])
|
||||
|
||||
# entrypoints can be a list or string
|
||||
if isinstance(entrypoints, list):
|
||||
entrypoints_str = ",".join(entrypoints)
|
||||
else:
|
||||
entrypoints_str = str(entrypoints)
|
||||
|
||||
# Determine scheme from entrypoint
|
||||
scheme = "https" if "websecure" in entrypoints_str else "http"
|
||||
|
||||
# Extract host(s) from rule
|
||||
for match in _HOST_RULE_PATTERN.finditer(str(rule)):
|
||||
host = match.group(1)
|
||||
# Prefer https over http
|
||||
if host not in urls or scheme == "https":
|
||||
urls[host] = scheme
|
||||
|
||||
# Build URL list, sorted for consistency
|
||||
return sorted(f"{scheme}://{host}" for host, scheme in urls.items())
|
||||
|
||||
@@ -17,6 +17,7 @@ from compose_farm.state import (
|
||||
group_running_stacks_by_host,
|
||||
load_state,
|
||||
)
|
||||
from compose_farm.traefik import extract_website_urls
|
||||
from compose_farm.web.deps import (
|
||||
extract_config_error,
|
||||
get_config,
|
||||
@@ -180,6 +181,9 @@ async def stack_detail(request: Request, name: str) -> HTMLResponse:
|
||||
for svc, svc_def in raw_services.items()
|
||||
}
|
||||
|
||||
# Extract website URLs from Traefik labels
|
||||
website_urls = extract_website_urls(config, name)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"stack.html",
|
||||
{
|
||||
@@ -193,6 +197,7 @@ async def stack_detail(request: Request, name: str) -> HTMLResponse:
|
||||
"env_path": str(env_path) if env_path else None,
|
||||
"services": services,
|
||||
"containers": containers,
|
||||
"website_urls": website_urls,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -597,6 +597,17 @@ function playFabIntro() {
|
||||
stackCmd('Logs', 'View logs for', 'logs', icons.file_text),
|
||||
);
|
||||
|
||||
// Add Open Website commands if website URLs are available
|
||||
const websiteUrlsAttr = document.querySelector('[data-website-urls]')?.getAttribute('data-website-urls');
|
||||
if (websiteUrlsAttr) {
|
||||
const websiteUrls = JSON.parse(websiteUrlsAttr);
|
||||
for (const url of websiteUrls) {
|
||||
const displayUrl = url.replace(/^https?:\/\//, '');
|
||||
const label = websiteUrls.length > 1 ? `Open: ${displayUrl}` : 'Open Website';
|
||||
actions.unshift(cmd('stack', label, `Open ${displayUrl} in browser`, openExternal(url), icons.external_link));
|
||||
}
|
||||
}
|
||||
|
||||
// Add service-specific commands from data-services and data-containers attributes
|
||||
// Grouped by action (all Logs together, all Pull together, etc.) with services sorted alphabetically
|
||||
const servicesAttr = document.querySelector('[data-services]')?.getAttribute('data-services');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% from "partials/icons.html" import search, play, square, rotate_cw, cloud_download, refresh_cw, file_text, file_code, check, home, terminal, box, palette, book_open %}
|
||||
{% from "partials/icons.html" import search, play, square, rotate_cw, cloud_download, refresh_cw, file_text, file_code, check, home, terminal, box, palette, book_open, external_link %}
|
||||
|
||||
<!-- Icons for command palette (referenced by JS) -->
|
||||
<template id="cmd-icons">
|
||||
@@ -15,6 +15,7 @@
|
||||
<span data-icon="palette">{{ palette() }}</span>
|
||||
<span data-icon="book_open">{{ book_open() }}</span>
|
||||
<span data-icon="file_code">{{ file_code() }}</span>
|
||||
<span data-icon="external_link">{{ external_link() }}</span>
|
||||
</template>
|
||||
<dialog id="cmd-palette" class="modal">
|
||||
<div class="modal-box max-w-lg p-0">
|
||||
|
||||
@@ -170,3 +170,9 @@
|
||||
<circle cx="13.5" cy="6.5" r="0.5" fill="currentColor"/><circle cx="17.5" cy="10.5" r="0.5" fill="currentColor"/><circle cx="8.5" cy="7.5" r="0.5" fill="currentColor"/><circle cx="6.5" cy="12.5" r="0.5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.555C21.965 6.012 17.461 2 12 2z"/>
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro external_link(size=16) %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "partials/components.html" import collapse, action_btn %}
|
||||
{% from "partials/icons.html" import play, square, rotate_cw, download, cloud_download, file_text, save, file_code, terminal, settings %}
|
||||
{% from "partials/icons.html" import play, square, rotate_cw, download, cloud_download, file_text, save, file_code, terminal, settings, external_link %}
|
||||
{% block title %}{{ name }} - Compose Farm{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-5xl" data-services="{{ services | join(',') }}" data-containers='{{ containers | tojson }}'>
|
||||
<div class="max-w-5xl" data-services="{{ services | join(',') }}" data-containers='{{ containers | tojson }}' data-website-urls='{{ website_urls | tojson }}'>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold rainbow-hover">{{ name }}</h1>
|
||||
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||
@@ -31,6 +31,19 @@
|
||||
{{ action_btn("Pull", "/api/stack/" ~ name ~ "/pull", "outline", "Pull latest images (no restart)", cloud_download()) }}
|
||||
{{ action_btn("Logs", "/api/stack/" ~ name ~ "/logs", "outline", "Show recent logs", file_text()) }}
|
||||
<div class="tooltip" data-tip="Save compose and .env files"><button id="save-btn" class="btn btn-outline">{{ save() }} Save All</button></div>
|
||||
|
||||
{% if website_urls %}
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
|
||||
<!-- Open Website -->
|
||||
{% for url in website_urls %}
|
||||
<div class="tooltip" data-tip="Open {{ url }}">
|
||||
<a href="{{ url }}" target="_blank" rel="noopener noreferrer" class="btn btn-outline">
|
||||
{{ external_link() }} {% if website_urls | length > 1 %}{{ url | replace('https://', '') | replace('http://', '') }}{% else %}Open Website{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% call collapse("Compose File", badge=compose_path, icon=file_code()) %}
|
||||
|
||||
@@ -6,7 +6,7 @@ import yaml
|
||||
|
||||
from compose_farm.compose import parse_external_networks
|
||||
from compose_farm.config import Config, Host
|
||||
from compose_farm.traefik import generate_traefik_config
|
||||
from compose_farm.traefik import extract_website_urls, generate_traefik_config
|
||||
|
||||
|
||||
def _write_compose(path: Path, data: dict[str, object]) -> None:
|
||||
@@ -336,3 +336,330 @@ def test_parse_external_networks_missing_compose(tmp_path: Path) -> None:
|
||||
|
||||
networks = parse_external_networks(cfg, "app")
|
||||
assert networks == []
|
||||
|
||||
|
||||
class TestExtractWebsiteUrls:
|
||||
"""Test extract_website_urls function."""
|
||||
|
||||
def _create_config(self, tmp_path: Path) -> Config:
|
||||
"""Create a test config."""
|
||||
return Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"nas": Host(address="192.168.1.10")},
|
||||
stacks={"mystack": "nas"},
|
||||
)
|
||||
|
||||
def test_extract_https_url(self, tmp_path: Path) -> None:
|
||||
"""Extracts HTTPS URL from websecure entrypoint."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.example.com`)",
|
||||
"traefik.http.routers.web.entrypoints": "websecure",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://app.example.com"]
|
||||
|
||||
def test_extract_http_url(self, tmp_path: Path) -> None:
|
||||
"""Extracts HTTP URL from web entrypoint."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.local`)",
|
||||
"traefik.http.routers.web.entrypoints": "web",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["http://app.local"]
|
||||
|
||||
def test_extract_multiple_urls(self, tmp_path: Path) -> None:
|
||||
"""Extracts multiple URLs from different routers."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.example.com`)",
|
||||
"traefik.http.routers.web.entrypoints": "websecure",
|
||||
"traefik.http.routers.web-local.rule": "Host(`app.local`)",
|
||||
"traefik.http.routers.web-local.entrypoints": "web",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["http://app.local", "https://app.example.com"]
|
||||
|
||||
def test_https_preferred_over_http(self, tmp_path: Path) -> None:
|
||||
"""HTTPS is preferred when same host has both."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
# Same host with different entrypoints
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web-http.rule": "Host(`app.example.com`)",
|
||||
"traefik.http.routers.web-http.entrypoints": "web",
|
||||
"traefik.http.routers.web-https.rule": "Host(`app.example.com`)",
|
||||
"traefik.http.routers.web-https.entrypoints": "websecure",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://app.example.com"]
|
||||
|
||||
def test_traefik_disabled(self, tmp_path: Path) -> None:
|
||||
"""Returns empty list when traefik.enable is false."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "false",
|
||||
"traefik.http.routers.web.rule": "Host(`app.example.com`)",
|
||||
"traefik.http.routers.web.entrypoints": "websecure",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == []
|
||||
|
||||
def test_no_traefik_labels(self, tmp_path: Path) -> None:
|
||||
"""Returns empty list when no traefik labels."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == []
|
||||
|
||||
def test_compose_file_not_exists(self, tmp_path: Path) -> None:
|
||||
"""Returns empty list when compose file doesn't exist."""
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == []
|
||||
|
||||
def test_env_variable_interpolation(self, tmp_path: Path) -> None:
|
||||
"""Interpolates environment variables in host rule."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
env_file = stack_dir / ".env"
|
||||
|
||||
env_file.write_text("DOMAIN=example.com\n")
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.${DOMAIN}`)",
|
||||
"traefik.http.routers.web.entrypoints": "websecure",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://app.example.com"]
|
||||
|
||||
def test_multiple_hosts_in_one_rule_with_or(self, tmp_path: Path) -> None:
|
||||
"""Extracts multiple hosts from a single rule with || operator."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.example.com`) || Host(`app.backup.com`)",
|
||||
"traefik.http.routers.web.entrypoints": "websecure",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://app.backup.com", "https://app.example.com"]
|
||||
|
||||
def test_host_with_path_prefix(self, tmp_path: Path) -> None:
|
||||
"""Extracts host from rule that includes PathPrefix."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.example.com`) && PathPrefix(`/api`)",
|
||||
"traefik.http.routers.web.entrypoints": "websecure",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://app.example.com"]
|
||||
|
||||
def test_multiple_services_in_stack(self, tmp_path: Path) -> None:
|
||||
"""Extracts URLs from multiple services in one stack (like arr stack)."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"radarr": {
|
||||
"image": "radarr",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.radarr.rule": "Host(`radarr.example.com`)",
|
||||
"traefik.http.routers.radarr.entrypoints": "websecure",
|
||||
},
|
||||
},
|
||||
"sonarr": {
|
||||
"image": "sonarr",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.sonarr.rule": "Host(`sonarr.example.com`)",
|
||||
"traefik.http.routers.sonarr.entrypoints": "websecure",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://radarr.example.com", "https://sonarr.example.com"]
|
||||
|
||||
def test_labels_in_list_format(self, tmp_path: Path) -> None:
|
||||
"""Handles labels in list format (- key=value)."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": [
|
||||
"traefik.enable=true",
|
||||
"traefik.http.routers.web.rule=Host(`app.example.com`)",
|
||||
"traefik.http.routers.web.entrypoints=websecure",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://app.example.com"]
|
||||
|
||||
def test_no_entrypoints_defaults_to_http(self, tmp_path: Path) -> None:
|
||||
"""When no entrypoints specified, defaults to http."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.example.com`)",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["http://app.example.com"]
|
||||
|
||||
def test_multiple_entrypoints_with_websecure(self, tmp_path: Path) -> None:
|
||||
"""When entrypoints includes websecure, use https."""
|
||||
stack_dir = tmp_path / "mystack"
|
||||
stack_dir.mkdir()
|
||||
compose_file = stack_dir / "compose.yaml"
|
||||
compose_data = {
|
||||
"services": {
|
||||
"web": {
|
||||
"image": "nginx",
|
||||
"labels": {
|
||||
"traefik.enable": "true",
|
||||
"traefik.http.routers.web.rule": "Host(`app.example.com`)",
|
||||
"traefik.http.routers.web.entrypoints": "web,websecure",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
compose_file.write_text(yaml.dump(compose_data))
|
||||
|
||||
config = self._create_config(tmp_path)
|
||||
urls = extract_website_urls(config, "mystack")
|
||||
assert urls == ["https://app.example.com"]
|
||||
|
||||
Reference in New Issue
Block a user