Skip to content

Commit 1eb58f3

Browse files
authored
feat: Add flagsmith healthcheck command (#60)
1 parent 64037d3 commit 1eb58f3

File tree

7 files changed

+374
-11
lines changed

7 files changed

+374
-11
lines changed

poetry.lock

Lines changed: 110 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
"gunicorn (>=19.1)",
1717
"prometheus-client (>=0.0.16)",
1818
"psycopg2-binary (>=2.9,<3)",
19+
"requests",
1920
"simplejson (>=3,<4)",
2021
]
2122
optional-dependencies = { test-tools = ["pyfakefs (>=5,<6)"] }
@@ -71,8 +72,8 @@ pytest-asyncio = "^0.25.3"
7172
pytest-cov = "^6.0.0"
7273
pytest-django = "^4.10.0"
7374
pytest-freezegun = "^0.4.2"
75+
pytest-httpserver = "^1.1.3"
7476
pytest-mock = "^3.14.0"
75-
requests = "^2.32.3"
7677
ruff = "*"
7778
setuptools = "^77.0.3"
7879
types-simplejson = "^3.20.0.20250326"

src/common/core/cli/__init__.py

Whitespace-only changes.

src/common/core/cli/healthcheck.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import argparse
2+
import socket
3+
import urllib.parse
4+
5+
import requests
6+
7+
DEFAULT_PORT = 8000
8+
DEFAULT_TIMEOUT_SECONDS = 1
9+
10+
11+
def get_args(
12+
argv: list[str],
13+
*,
14+
prog: str,
15+
) -> argparse.Namespace:
16+
parser = argparse.ArgumentParser(
17+
description=(
18+
"Perform health checks. "
19+
f"If ran without subcommand, defaults to a TCP check of port {DEFAULT_PORT}."
20+
),
21+
prog=prog,
22+
)
23+
subcommands = parser.add_subparsers(dest="subcommand")
24+
tcp_parser = subcommands.add_parser(
25+
"tcp",
26+
help="Check if the API is able to accept local TCP connections",
27+
)
28+
tcp_parser.add_argument(
29+
"--port",
30+
"-p",
31+
type=int,
32+
default=DEFAULT_PORT,
33+
help=f"Port to check the API on (default: {DEFAULT_PORT})",
34+
)
35+
tcp_parser.add_argument(
36+
"--timeout",
37+
"-t",
38+
type=int,
39+
default=DEFAULT_TIMEOUT_SECONDS,
40+
help=f"Socket timeout for the connection attempt in seconds (default: {DEFAULT_TIMEOUT_SECONDS})",
41+
)
42+
http_parser = subcommands.add_parser(
43+
"http", help="Check if the API is able to serve HTTP requests"
44+
)
45+
http_parser.add_argument(
46+
"--port",
47+
"-p",
48+
type=int,
49+
default=DEFAULT_PORT,
50+
help=f"Port to check the API on (default: {DEFAULT_PORT})",
51+
)
52+
http_parser.add_argument(
53+
"--timeout",
54+
"-t",
55+
type=int,
56+
default=DEFAULT_TIMEOUT_SECONDS,
57+
help=f"Request timeout in seconds (default: {DEFAULT_TIMEOUT_SECONDS})",
58+
)
59+
http_parser.add_argument(
60+
"path",
61+
nargs="?",
62+
type=str,
63+
default="/health/liveness",
64+
help="Request path (default: /health/liveness)",
65+
)
66+
return parser.parse_args(argv)
67+
68+
69+
def check_tcp_connection(
70+
port: int,
71+
timeout_seconds: int,
72+
) -> None:
73+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
74+
sock.settimeout(timeout_seconds)
75+
try:
76+
sock.connect(("127.0.0.1", port))
77+
except socket.error as e:
78+
print(f"Failed: {e} {port=}")
79+
exit(1)
80+
else:
81+
exit(0)
82+
finally:
83+
sock.close()
84+
85+
86+
def check_http_response(
87+
port: int,
88+
timeout_seconds: int,
89+
path: str,
90+
) -> None:
91+
url = urllib.parse.urljoin(f"http://127.0.0.1:{port}", path)
92+
requests.get(
93+
url,
94+
timeout=timeout_seconds,
95+
).raise_for_status()
96+
97+
98+
def main(
99+
argv: list[str],
100+
*,
101+
prog: str,
102+
) -> None:
103+
args = get_args(argv, prog=prog)
104+
match args.subcommand:
105+
case None:
106+
check_tcp_connection(
107+
port=DEFAULT_PORT,
108+
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
109+
)
110+
case "tcp":
111+
check_tcp_connection(
112+
port=args.port,
113+
timeout_seconds=args.timeout,
114+
)
115+
case "http":
116+
check_http_response(
117+
port=args.port,
118+
timeout_seconds=args.timeout,
119+
path=args.path,
120+
)

src/common/core/main.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
import tempfile
66
import typing
77

8-
from django.core.management import execute_from_command_line
8+
from django.core.management import (
9+
execute_from_command_line as django_execute_from_command_line,
10+
)
11+
12+
from common.core.cli import healthcheck
913

1014
logger = logging.getLogger(__name__)
1115

@@ -56,7 +60,25 @@ def ensure_cli_env() -> typing.Generator[None, None, None]:
5660
yield
5761

5862

59-
def main() -> None:
63+
def execute_from_command_line(argv: list[str]) -> None:
64+
try:
65+
subcommand = argv[1]
66+
subcommand_main = {
67+
"healthcheck": healthcheck.main,
68+
# Backwards compatibility for task-processor health checks
69+
# See https://github.com/Flagsmith/flagsmith-task-processor/issues/24
70+
"checktaskprocessorthreadhealth": healthcheck.main,
71+
}[subcommand]
72+
except (IndexError, KeyError):
73+
django_execute_from_command_line(argv)
74+
else:
75+
subcommand_main(
76+
argv[2:],
77+
prog=f"{os.path.basename(argv[0])} {subcommand}",
78+
)
79+
80+
81+
def main(argv: list[str] = sys.argv) -> None:
6082
"""
6183
The main entry point to the Flagsmith application.
6284
@@ -72,5 +94,5 @@ def main() -> None:
7294
`flagsmith <command> [options]`
7395
"""
7496
with ensure_cli_env():
75-
# Run Django
76-
execute_from_command_line(sys.argv)
97+
# Run own commands and Django
98+
execute_from_command_line(argv)

tests/integration/core/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from typing import Generator
2+
3+
import pytest
4+
from pytest_httpserver import HTTPServer
5+
6+
7+
@pytest.fixture()
8+
def http_server(unused_tcp_port: int) -> Generator[HTTPServer, None, None]:
9+
with HTTPServer(port=unused_tcp_port) as server:
10+
yield server

0 commit comments

Comments
 (0)