Skip to content

Commit 377a95d

Browse files
authored
Merge pull request #51 from feat/local-first-swarm-mode
Support health-check, add local first mode in swarm
2 parents 97c3b66 + abda739 commit 377a95d

48 files changed

Lines changed: 2836 additions & 597 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/docker-image.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,16 @@ jobs:
1515
contents: read
1616
steps:
1717
- name: Checkout
18-
uses: actions/checkout@v3
18+
uses: actions/checkout@v6
1919

2020
- name: Set up QEMU
21-
uses: docker/setup-qemu-action@v2
21+
uses: docker/setup-qemu-action@v4
2222

2323
- name: Set up Docker Buildx
24-
uses: docker/setup-buildx-action@v2
24+
uses: docker/setup-buildx-action@v4
2525

2626
- name: Login to DockerHub
27-
uses: docker/login-action@v2
27+
uses: docker/login-action@v4
2828
with:
2929
username: mesudip
3030
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -51,7 +51,7 @@ jobs:
5151
5252
- name: Build and push for tags
5353
if: startsWith(github.ref, 'refs/tags/')
54-
uses: docker/build-push-action@v5
54+
uses: docker/build-push-action@v7
5555
with:
5656
file: Dockerfile
5757
context: .
@@ -63,7 +63,7 @@ jobs:
6363

6464
- name: Build and push for main branch
6565
if: github.ref == 'refs/heads/master'
66-
uses: docker/build-push-action@v5
66+
uses: docker/build-push-action@v7
6767
with:
6868
file: Dockerfile
6969
context: .

.github/workflows/publish-python-package.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ jobs:
1515
runs-on: ubuntu-latest
1616
steps:
1717
- name: Checkout code
18-
uses: actions/checkout@v4
18+
uses: actions/checkout@v6
1919

2020
- name: Set up Python
21-
uses: actions/setup-python@v5
21+
uses: actions/setup-python@v6
2222
with:
2323
python-version: "3.12"
2424

.github/workflows/run-tests.yml

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,25 @@ on:
1515
jobs:
1616
test:
1717
runs-on: ubuntu-latest
18+
concurrency:
19+
group: test-${{ github.event.pull_request.head.sha || github.sha }}
20+
cancel-in-progress: true
1821
permissions:
1922
contents: read
2023
steps:
2124
- name: Checkout code
22-
uses: actions/checkout@v3
25+
uses: actions/checkout@v6
2326

2427
- name: Set up Python
25-
uses: actions/setup-python@v4
28+
uses: actions/setup-python@v6
2629
with:
2730
python-version: '3.12'
2831

2932
- name: Set up QEMU
30-
uses: docker/setup-qemu-action@v2
33+
uses: docker/setup-qemu-action@v4
3134

3235
- name: Set up Docker Buildx
33-
uses: docker/setup-buildx-action@v2
36+
uses: docker/setup-buildx-action@v4
3437

3538
- name: Install dependencies
3639
run: |
@@ -41,16 +44,113 @@ jobs:
4144
4245
- name: Run tests
4346
run: |
44-
pytest --cov --cov-branch --junitxml=junit.xml -o junit_family=legacy
47+
pytest --cov --cov-branch --cov-report=xml:coverage.xml --junitxml=junit.xml -o junit_family=legacy
48+
49+
- name: Write test summary
50+
if: ${{ always() }}
51+
run: |
52+
python - <<'PY'
53+
import os
54+
import sys
55+
import xml.etree.ElementTree as ET
56+
from pathlib import Path
57+
58+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
59+
if not summary_path:
60+
sys.exit(0)
61+
62+
junit_path = Path("junit.xml")
63+
64+
def md_escape(value):
65+
return str(value).replace("|", "\\|").replace("\n", "<br>")
66+
67+
lines = ["## Test summary", ""]
68+
69+
if not junit_path.exists():
70+
lines.extend([
71+
"No `junit.xml` test report was generated.",
72+
"",
73+
])
74+
else:
75+
root = ET.parse(junit_path).getroot()
76+
suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite"))
77+
if not suites:
78+
suites = [root]
79+
80+
totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0, "time": 0.0}
81+
for suite in suites:
82+
totals["tests"] += int(suite.attrib.get("tests", 0))
83+
totals["failures"] += int(suite.attrib.get("failures", 0))
84+
totals["errors"] += int(suite.attrib.get("errors", 0))
85+
totals["skipped"] += int(suite.attrib.get("skipped", 0))
86+
totals["time"] += float(suite.attrib.get("time", 0.0))
87+
88+
passed = totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"]
89+
lines.extend([
90+
"| Total | Passed | Failed | Errors | Skipped | Duration |",
91+
"| ---: | ---: | ---: | ---: | ---: | ---: |",
92+
f"| {totals['tests']} | {passed} | {totals['failures']} | {totals['errors']} | {totals['skipped']} | {totals['time']:.2f}s |",
93+
"",
94+
"### Metadata",
95+
"",
96+
"| Key | Value |",
97+
"| --- | --- |",
98+
f"| Python | {md_escape(sys.version.split()[0])} |",
99+
f"| Runner OS | {md_escape(os.environ.get('RUNNER_OS', 'unknown'))} |",
100+
f"| Event | {md_escape(os.environ.get('GITHUB_EVENT_NAME', 'unknown'))} |",
101+
f"| Ref | {md_escape(os.environ.get('GITHUB_REF_NAME', 'unknown'))} |",
102+
f"| SHA | `{md_escape(os.environ.get('GITHUB_SHA', 'unknown'))}` |",
103+
f"| Actor | {md_escape(os.environ.get('GITHUB_ACTOR', 'unknown'))} |",
104+
"",
105+
])
106+
107+
failed_cases = []
108+
for case in root.iter("testcase"):
109+
issue = case.find("failure")
110+
if issue is None:
111+
issue = case.find("error")
112+
if issue is not None:
113+
failed_cases.append((case.attrib, issue))
114+
115+
if failed_cases:
116+
lines.extend([
117+
"### Failed tests",
118+
"",
119+
"| Test | Type | Message |",
120+
"| --- | --- | --- |",
121+
])
122+
for attrs, issue in failed_cases[:25]:
123+
classname = attrs.get("classname", "")
124+
name = attrs.get("name", "unknown")
125+
test_name = f"{classname}.{name}" if classname else name
126+
message = issue.attrib.get("message", "").strip() or (issue.text or "").strip().splitlines()[0:1]
127+
if isinstance(message, list):
128+
message = message[0] if message else ""
129+
lines.append(f"| `{md_escape(test_name)}` | {md_escape(issue.tag)} | {md_escape(message[:300])} |")
130+
if len(failed_cases) > 25:
131+
lines.append(f"| ... | ... | {len(failed_cases) - 25} more failing tests omitted from summary |")
132+
lines.append("")
133+
134+
with open(summary_path, "a", encoding="utf-8") as summary:
135+
summary.write("\n".join(lines))
136+
summary.write("\n")
137+
PY
45138
46139
- name: Upload coverage reports to Codecov
140+
if: ${{ !cancelled() }}
47141
uses: codecov/codecov-action@v5
48142
with:
49143
token: ${{ secrets.CODECOV_TOKEN }}
144+
files: coverage.xml
145+
disable_search: true
50146
fail_ci_if_error: false
51147

52148
- name: Upload test results to Codecov
53149
if: ${{ !cancelled() }}
54-
uses: codecov/test-results-action@v1
150+
uses: codecov/codecov-action@v5
55151
with:
56152
token: ${{ secrets.CODECOV_TOKEN }}
153+
files: junit.xml
154+
disable_search: true
155+
report_type: test_results
156+
fail_ci_if_error: false

Dockerfile

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
# mesudip/python-nginx:alpine is merge of official python and nginx images.
2-
FROM mesudip/python-nginx
1+
# This provides nginx and python together in a container
2+
FROM ghcr.io/mesudip/python-nginx:py3.13.13-nginx1.30.1-alpine3.23
33

44
RUN pip install --upgrade pip
55

@@ -11,20 +11,19 @@ RUN apk --no-cache add openssl && \
1111
gcc libc-dev openssl-dev linux-headers libffi-dev && \
1212
pip install --no-cache-dir -r /requirements.txt && \
1313
rm -f /requirements.txt && apk del .build-deps && \
14-
ln -s /app/getssl /bin/getssl && ln -s /app/verify /bin/verify && \
14+
ln -s /app/getssl /bin/getssl && ln -s /app/verify /bin/verify && ln -s /app/reload /bin/reload && \
1515
mv /docker-entrypoint.sh /nginx-entrypoint.sh && \
1616
ln -s /app/docker-entrypoint.sh /docker-entrypoint.sh
1717
RUN rm -rf /var/log/nginx/* && chown nginx:nginx /var/log/nginx && truncate -s 0 /etc/nginx/conf.d/default.conf
1818
COPY ./vhosts_template/nginx.conf /etc/nginx/nginx.conf
1919
ARG LETSENCRYPT_API="https://acme-v02.api.letsencrypt.org/directory"
2020
ENV LETSENCRYPT_API=${LETSENCRYPT_API} \
2121
CHALLENGE_DIR=/etc/nginx/challenges/ \
22-
DHPARAM_SIZE=2048 \
2322
CLIENT_MAX_BODY_SIZE=1m \
2423
NGINX_WORKER_PROCESSES=auto \
2524
NGINX_WORKER_CONNECTIONS=65535 \
2625
SSL_DIR=/etc/nginx/ssl \
2726
DEFAULT_HOST=true \
2827
VHOSTS_TEMPLATE_DIR=/app/vhosts_template
2928
WORKDIR /app
30-
COPY . /app/
29+
COPY . /app/

README.md

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,13 @@ Control the default behavior of `nginx-proxy`:
8585
| `NGINX_WORKER_CONNECTIONS` | `65535` | Max connections per worker. |
8686
| `CERT_RENEW_THRESHOLD_DAYS` | `30` | By default certificates are renewed when they have <=30 days remaining. |
8787
| `ENABLE_IPV6` | `false` | Enable IPv6 support on nginx. |
88-
| `DOCKER_SWARM` | `ignore` | Treats every container like local by defeault. Set `enable` for Swarm support, `strict` for Swarm-only or`exclude` to not include swarm containers |
88+
| `DOCKER_SWARM` | `ignore` | Controls Docker Swarm discovery. Supported values are `ignore`, `exclude`, `enable`, `prefer-local`, and `strict`; see [Docker Swarm Support](#docker-swarm-support-preview). |
8989
| `SWARM_DOCKER_HOST` | - | URL of the Swarm manager socket (e.g., `tcp://manager:2375`). |
9090
| `CERTAPI_URL` | - | External Certificate API URL. |
9191
| `CERTAPI_BATCH_DOMAINS` | `true` | When using `CERTAPI_URL`, request safe domain batching (`batch_domains=true`) to avoid recursive domain-order errors. |
9292
| `CHALLENGE_DIR` | `/etc/nginx/challenges/` | Base directory for acme challenge store, when requesting certificates with acme. `.well-known/acme-challenge` folder lives inside this.|
9393
| `CLOUDFLARE_API_KEY_KEY*` | - | Cloudflare api keys to issue DNS certificates.|
94+
| `BACKEND_START_GRACE_SECONDS` | `10` | Delay registering containers without a Docker healthcheck so crashing backends dont' result reload|
9495

9596

9697
## Virtual Hosts
@@ -138,10 +139,39 @@ Format: `STATIC_VIRTUAL_HOST=domain.com->http://192.168.0.1:8080`.
138139
**Note** Be aware that if domain as target, nginx will crash if DNS resolution fails.
139140

140141
## Docker Swarm Support [Preview]
141-
Enable swarm mode by setting `DOCKER_SWARM` to `enable` (local & swarm) or `strict` (swarm only).
142-
If current node is not manager, set `SWARM_DOCKER_HOST=tcp://manager:2375`.
143142

144-
**Warning** : Automatic exposed port detection will not work when swarm support is enabled. You must explicitly set port on the `VIRTUAL_HOST` or set `VIRTUAL_PORT` on the container.
143+
**Warning** : Automatic exposed port detection will not work when swarm support is enabled. You must explicitly set port on the `VIRTUAL_HOST`.
144+
145+
146+
Docker Swarm discovery is controlled by the `DOCKER_SWARM` environment variable on the `nginx-proxy` container.
147+
148+
| `DOCKER_SWARM` value | Local containers | Swarm services | Use case |
149+
| :--- | :--- | :--- | :--- |
150+
| `ignore` | Included | Not discovered | Default Docker-only behavior. Swarm task containers are treated like standalone containers if they are visible on the local Docker socket. |
151+
| `exclude` | Included | Not discovered | Docker-only discovery while explicitly ignoring containers that belong to Swarm services. |
152+
| `enable` | Included | Included | Mixed mode. Use this when `nginx-proxy` should route both standalone containers and Swarm services. |
153+
| `prefer-local` | Included | Included | Mixed Swarm mode that prefers healthy local task containers and keeps the service VIP as a fallback. |
154+
| `strict` | Excluded | Included | Swarm-only mode. Use this when `nginx-proxy` should route only Swarm services. |
155+
156+
`ignore` is the default and does not require the Docker node to be in Swarm mode. In this mode, `nginx-proxy` only reads the normal Docker container API. If a Swarm task container is visible on the local Docker socket, it can be registered as if it were a regular container.
157+
158+
`exclude` still uses only the local Docker container API, but skips containers that have Swarm service labels. This is useful when the same Docker host runs standalone containers and Swarm services, but this proxy instance should only manage standalone containers.
159+
160+
`enable` reads both local containers and Swarm services. Standalone containers are discovered from the local Docker socket. Swarm services are discovered from the Swarm manager API, and task containers are skipped so each service is registered once.
161+
162+
`prefer-local` reads both local containers and Swarm services, but local Swarm task containers are also discovered from the local Docker socket. When a route has local containers and the Swarm service VIP, nginx sends normal traffic to the local containers and marks the service VIP as a `backup` upstream server. If no local container is available, the service VIP is used normally. Existing container healthcheck and `BACKEND_START_GRACE_SECONDS` behavior still applies before local containers are registered.
163+
164+
`strict` reads only Swarm services. Local standalone containers are ignored, and Swarm task containers are also ignored. This is the mode to use when this proxy instance is dedicated to Swarm routing.
165+
166+
For `enable`, `prefer-local`, and `strict`, the Swarm API client must be connected to a manager node because Docker only allows managers to list services. If `nginx-proxy` is running on a worker node, set `SWARM_DOCKER_HOST` to a reachable manager Docker API endpoint:
167+
168+
```bash
169+
-e DOCKER_SWARM=enable \
170+
-e SWARM_DOCKER_HOST=tcp://manager:2375
171+
```
172+
173+
If `SWARM_DOCKER_HOST` is not set, the local Docker socket is used for both local containers and Swarm services. When `SWARM_DOCKER_HOST` is set, `nginx-proxy` uses the local Docker socket for standalone containers and the remote manager socket for Swarm services. If the local Docker socket cannot be reached but `SWARM_DOCKER_HOST` is set, `nginx-proxy` switches to `strict` mode and uses only the remote Swarm manager.
174+
145175

146176
## Advanced Features
147177
### Redirection
@@ -220,6 +250,8 @@ docker exec nginx-proxy verify www.example.com ## check if request routes back t
220250
221251
docker exec nginx-proxy getssl www.example.com example.com www2.example.com # issue certificate
222252
253+
docker exec nginx-proxy reload # rescan Docker state and reload nginx config
254+
223255
```
224256

225257
## 🚀 Roadmap

dev-requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
pytest==8.2.2
1+
pytest==9.0.3
22
pytest-cov
33
websocket-client
44
python-dotenv

docker/entry-point.sh

Lines changed: 0 additions & 2 deletions
This file was deleted.

docker/nginx.conf

Lines changed: 0 additions & 76 deletions
This file was deleted.

0 commit comments

Comments
 (0)