Skip to content

Commit 62a7de6

Browse files
authored
SS 1254 Check app status using curl (#14)
* Add integration tests * Move unit tests into a subdirectory named unit * Git actions should only run unit tests, not integration tests * Add a class for curl-like probing of user apps and associated tests. * Add app probe method to status queue. * Add structured well-defined types for status records, probe results and POST payload and refactor the main classes to use these types. * Add more type hints. Remove deprecated code. * Add app URL resolving for shiny proxy apps * Introduce URL probing feature. Modify the status queue process logic to adhere to new rules for prelim running and deleted statuses. * Add unit test for status queue probing logic * Complete new logic including URL probing in the status queue process method. Correct timeouts used for k8s list_namespaced_pod when watch is False. * Add CLI options for running the program in two additional modes: diagnostics and probetest. * Display configuration of APP_PROBE_STATUSES at startup. * Fix Dockerfile for base image vs curl and jq package versions. * Rename all uses of new-status to status as this is now the new convention in StatusRecord. Rename to new-status just before sending the payload to the API as expected. * Fix a timing issue in the status queue process method * Add support for out of cluster URL resolution for local development. Introduce new env var PROBE_PF. * Add a new env var TLS_SSL_VERIFICATION to control TLS or SSL verification, used by both the main program and integration tests.
1 parent c51f75d commit 62a7de6

36 files changed

Lines changed: 2161 additions & 299 deletions

.github/workflows/ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ jobs:
7272
pip install -r requirements.txt
7373
7474
- name: Run tests
75-
run: python -m unittest discover -s tests
75+
run: python -m unittest discover -s tests/unit
7676

7777

7878
build:

.github/workflows/publish.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ jobs:
4444
pip install -r requirements.txt
4545
4646
- name: Run tests
47-
run: python -m unittest discover -s tests
47+
run: python -m unittest discover -s tests/unit
4848

4949
publish:
5050
name: Publish to ghcr.io

Dockerfile

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,28 @@
11
FROM python:3.12-alpine3.20
22
LABEL maintainer="serve@scilifelab.se"
33

4+
# create non-root user
45
RUN addgroup -S serve && adduser -S -G serve serve
56

67
WORKDIR /app
78

89
COPY requirements.txt /app/requirements.txt
910

10-
# Install runtime deps
11-
RUN apk add --no-cache \
12-
curl=8.12.1-r0 \
13-
jq=1.7.1-r0 \
14-
&& pip install --no-cache-dir --upgrade "pip~=25.2" \
15-
&& pip install --no-cache-dir -r requirements.txt \
16-
&& rm requirements.txt
11+
# install runtime deps (unversioned to avoid Alpine repo rotation breakage)
12+
# hadolint ignore=DL3018
13+
RUN apk add --no-cache curl jq \
14+
&& pip install --no-cache-dir --upgrade "pip~=25.2" \
15+
&& pip install --no-cache-dir -r requirements.txt \
16+
&& rm requirements.txt
1717

18-
# Copy source, owned by non-root user
18+
# copy source with correct ownership, owned by non-root user
1919
COPY --chown=serve:serve serve_event_listener/ /app/serve_event_listener/
2020

21-
# Make sure the package root is importable
2221
ENV PYTHONPATH=/app
2322

24-
# Drop privileges for runtime
2523
USER serve
2624

27-
# Run the package module so imports resolve properly
25+
# run the package module so imports resolve properly
2826
ENTRYPOINT ["python3", "-m", "serve_event_listener.main"]
29-
30-
# Sensible defaults; can override container.args
27+
# sensible defaults; can override container.args
3128
CMD ["--namespace", "default", "--label-selector", "type=app"]

README.md

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,33 @@ export USERNAME=<admin username (Serve)>
5050
export PASSWORD=<admin password (Serve)>
5151
```
5252
53-
To retrieve additional log messages, set:
53+
Modify the `TLS_SSL_VERIFICATION` environment variable if needed to disable SSL verification or point to a self-signed cert. Options are:
54+
```bash
55+
- "1"/"true" = verify (default)
56+
- "0"/"false" = do not verify (dev/self-signed)
57+
- "/path.pem" = verify using provided CA/cert bundle
58+
```
59+
60+
#### URL probing
61+
URL probing can complement the k8s event stream to detect app status. Note that during development, if running the service outside of a cluster such as using docker compose, then URL probing may require port fowarding and extra hosts defined.
62+
63+
To enable URL probing, set the environment variable APP_PROBE_STATUSES to the status codes for which the probing should run.
64+
This is currently only available for the Running and Deleted statuses.
65+
66+
```bash
67+
export APP_PROBE_STATUSES=Running,Deleted
68+
```
69+
70+
An environment variable APP_PROBE_APPS controls the app types that the URL probing controls. Currently only available for shiny and shiny-proxy (default).
71+
72+
```bash
73+
export APP_PROBE_APPS=shiny,shiny-proxy
74+
```
75+
76+
There are additional environment variables with sensible defaults that control URL resolution. See app_urls.py for more information.
77+
78+
#### Debug output
79+
To retrieve additional log messages during development, set:
5480
5581
```bash
5682
export DEBUG=True
@@ -64,10 +90,22 @@ Navigate to the project directory and execute the following command to run the s
6490
python3 -m serve_event_listener.main --namespace <some-namespace> --label-selector <some label selector>
6591
```
6692
67-
### Running the unit tests
93+
### Running the service in other modes
94+
95+
The program can also be run at the command line in two other modes, diagnostics and probetest.
96+
97+
diagnostics: The dignostics mode simply prints the effective settings and exits
98+
```bash
99+
python3 -m serve_event_listener.main --mode diagnostics
100+
```
101+
102+
probetest: The probe test mode performs a single-shot URL probing test against a specified URL.
68103
104+
Arguments:
105+
- Required argument: --probe-url
106+
- Optional arguments: --probe-insecure, --probe-connect-timeout, --probe-read-timeout
69107
```bash
70-
python -m unittest discover -s tests
108+
python3 -m serve_event_listener.main --mode probetest --probe-url <url-to-probe>
71109
```
72110
73111
## Docker Container Setup
@@ -98,3 +136,41 @@ The following are the main function arguments that can be passed to run the prog
98136
99137
- `--namespace`: Kubernetes namespace to watch (default: `default`).
100138
- `--label-selector`: Label selector for filtering pods (default: `type=app`).
139+
140+
## Testing
141+
142+
This project contains both unit tests and integration tests.
143+
144+
### Running the unit tests
145+
146+
```bash
147+
python -m unittest discover -s tests/unit/
148+
```
149+
150+
### Running the integration tests
151+
152+
To run the integration tests, the variable RUN_INTEGRATION_TESTS must be set to 1.
153+
154+
1. Start the target service (as defined by BASE_URL). Ensure that KUBECONFIG is set or can be resolved.
155+
156+
2. Then run the integration tests:
157+
```bash
158+
RUN_INTEGRATION_TESTS=1 python -m unittest discover -v -s tests/integration/
159+
```
160+
161+
If you have an existing app available for testing in your target k8s environment, then you can use it to run additional tests using env var PROBE_RELEASE, like so:
162+
163+
```bash
164+
RUN_INTEGRATION_TESTS=1 PROBE_RELEASE=<app-release> NAMESPACE_UNDER_TEST=default python -m unittest discover -v -s tests/integration/
165+
```
166+
167+
### Pytest
168+
169+
If you instead prefer to use Pytest for nicer output etc:
170+
171+
```bash
172+
pip install pytest
173+
pytest tests/unit -v
174+
RUN_INTEGRATION_TESTS=1 pytest tests/integration -v
175+
RUN_INTEGRATION_TESTS=1 PROBE_RELEASE=<app-release> pytest tests/integration -v
176+
```

serve_event_listener/app_urls.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import Optional
5+
from urllib.parse import urlunparse
6+
7+
from serve_event_listener.el_types import StatusRecord
8+
9+
# for non-cluster, development / testing
10+
_HOST_GATEWAY = "host.docker.internal"
11+
_PF = os.getenv("PROBE_PF")
12+
13+
14+
def _host_for(service: str, namespace: str) -> str:
15+
"""Build host according to DNS mode env settings."""
16+
mode = (os.getenv("APP_URL_DNS_MODE", "short") or "short").lower()
17+
suffix = os.getenv("APP_URL_DNS_SUFFIX")
18+
if mode == "fqdn":
19+
return f"{service}.{namespace}.svc.cluster.local"
20+
if suffix:
21+
return f"{service}.{namespace}.{suffix}"
22+
# default short form: service.namespace
23+
return f"{service}.{namespace}"
24+
25+
26+
def _port() -> str:
27+
return os.getenv("APP_URL_PORT", "80")
28+
29+
30+
def _scheme() -> str:
31+
return os.getenv("APP_URL_SCHEME", "http")
32+
33+
34+
def _pf_port_for_release(release: str) -> Optional[str]:
35+
if not _PF:
36+
return None
37+
s = _PF.strip()
38+
if s.isdigit():
39+
return s
40+
# parse mapping: rel:port,rel2:port2
41+
for part in s.split(","):
42+
if ":" in part:
43+
rel, port = part.split(":", 1)
44+
rel, port = rel.strip(), port.strip()
45+
if rel == release and port.isdigit():
46+
return port
47+
return None
48+
49+
50+
def resolve_app_url(
51+
rec: StatusRecord, *, fallback_namespace: Optional[str] = None
52+
) -> Optional[str]:
53+
"""
54+
Return a cluster-internal HTTP URL for the given StatusRecord, or None if unknown.
55+
56+
Currently supports:
57+
- app-type == 'shiny-proxy':
58+
service: <release>-<SHINYPROXY_SERVICE_SUFFIX>
59+
host: per DNS mode (short/fqdn/custom suffix)
60+
path: <SHINYPROXY_PATH_PREFIX>/<release>/
61+
"""
62+
app_type = (rec.get("app-type") or "").lower()
63+
assert app_type is not None, "app_type should be set"
64+
if not app_type:
65+
return None
66+
67+
release = rec.get("release")
68+
assert release is not None, "release should be set"
69+
if not release:
70+
return None
71+
72+
namespace = rec.get("namespace") or fallback_namespace or "default"
73+
74+
if app_type == "shiny-proxy":
75+
pf_port = _pf_port_for_release(release)
76+
if pf_port:
77+
# bypass proxy: talk to the port-forward on the host
78+
return f"http://{_HOST_GATEWAY}:{pf_port}/app/{release}/"
79+
80+
# apply the normal ingress/cluster logic
81+
suffix = os.getenv("SHINYPROXY_SERVICE_SUFFIX", "shinyproxyapp")
82+
path_prefix = os.getenv("SHINYPROXY_PATH_PREFIX", "/app").rstrip("/")
83+
service = f"{release}-{suffix}"
84+
host = _host_for(service, namespace)
85+
path = f"{path_prefix}/{release}/"
86+
netloc = f"{host}:{_port()}"
87+
return urlunparse((_scheme(), netloc, path, "", "", ""))
88+
89+
# Other app types are not yet supported
90+
return None

serve_event_listener/el_types.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Defines common data types"""
2+
3+
from typing import Any, Literal, Mapping, NotRequired, Optional, TypedDict
4+
5+
# Canonical status/app types
6+
Status = Literal["Running", "Pending", "Deleted", "Failed", "Unknown", "Succeeded"]
7+
AppType = Literal["shiny", "shiny-proxy"]
8+
ProbeStatus = Literal["Running", "Unknown", "NotFound"]
9+
10+
# Result block produced by the AppAvailabilityProbe (optional on payload)
11+
ProbeBlockDict = TypedDict(
12+
"ProbeBlockDict",
13+
{
14+
"status": ProbeStatus, # e.g., "Running" | "Unknown" | "NotFound"
15+
"port80_status": Optional[int], # HTTP status code (if any)
16+
"note": str, # short diagnostic
17+
"url": str, # probed URL
18+
},
19+
total=False,
20+
)
21+
22+
# Internal, per-release record kept by StatusData (snake-ish keys ok here)
23+
StatusRecord = TypedDict(
24+
"StatusRecord",
25+
{
26+
"release": str,
27+
"status": Status,
28+
"event-ts": str, # ISO8601 "YYYY-mm-ddTHH:MM:SS.sssZ"
29+
"namespace": NotRequired[str],
30+
"resource_version": NotRequired[str],
31+
"pod": NotRequired[str], # current pod name (if relevant)
32+
"labels": NotRequired[Mapping[str, str]],
33+
"reason": NotRequired[str],
34+
"message": NotRequired[str],
35+
"app-type": NotRequired[AppType],
36+
"app-url": NotRequired[str],
37+
"curl-probe": NotRequired[ProbeBlockDict],
38+
},
39+
total=False,
40+
)
41+
42+
# External payload for POST-ing
43+
EventMsg = TypedDict(
44+
"EventMsg",
45+
{
46+
"pod-msg": Optional[str],
47+
"container-msg": Optional[str],
48+
},
49+
total=True,
50+
)
51+
52+
PostPayload = TypedDict(
53+
"PostPayload",
54+
{
55+
"release": str,
56+
"new-status": Status,
57+
"event-msg": EventMsg,
58+
"event-ts": Optional[str], # ISO8601 or None
59+
"token": str,
60+
},
61+
total=False,
62+
)
63+
64+
65+
def validate_status_record(rec: Mapping[str, Any]) -> None:
66+
"""Lightweight runtime guard for required fields (raises ValueError)."""
67+
missing = [k for k in ("release", "status", "event-ts") if k not in rec]
68+
if missing:
69+
raise ValueError(f"StatusRecord missing required fields: {missing}")

0 commit comments

Comments
 (0)