svcforge: reference implementation
ci / lint (push) Successful in 1m19s
ci / unit (push) Failing after 1m2s
ci / integration (push) Has been skipped
ci / types (push) Successful in 1m37s
ci / security (push) Failing after 38s
ci / dockerfile (push) Successful in 14s
ci / image (api) (push) Has been skipped
ci / image (reconciler) (push) Has been skipped
ci / image (worker) (push) Has been skipped
ci / bump (push) Has been skipped
ci / lint (push) Successful in 1m19s
ci / unit (push) Failing after 1m2s
ci / integration (push) Has been skipped
ci / types (push) Successful in 1m37s
ci / security (push) Failing after 38s
ci / dockerfile (push) Successful in 14s
ci / image (api) (push) Has been skipped
ci / image (reconciler) (push) Has been skipped
ci / image (worker) (push) Has been skipped
ci / bump (push) Has been skipped
Complete working build of the system learn-python/ teaches. 164 tests, mypy --strict clean, domain coverage 99%.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""Unit tests for retry backoff math."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from svcforge_core.domain.backoff import next_attempt_at
|
||||
|
||||
T = datetime(2026, 7, 17, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_attempt_zero_full_jitter_is_base() -> None:
|
||||
assert next_attempt_at(0, now=T, rand=lambda: 1.0) == T + timedelta(seconds=2)
|
||||
|
||||
|
||||
def test_zero_jitter_returns_now() -> None:
|
||||
assert next_attempt_at(0, now=T, rand=lambda: 0.0) == T
|
||||
|
||||
|
||||
def test_negative_attempt_raises_value_error() -> None:
|
||||
with pytest.raises(ValueError, match="attempt"):
|
||||
next_attempt_at(-1, now=T)
|
||||
|
||||
|
||||
@given(attempt=st.integers(min_value=0, max_value=64), r=st.floats(min_value=0.0, max_value=1.0))
|
||||
def test_delay_is_always_within_zero_and_cap(attempt: int, r: float) -> None:
|
||||
cap_s = 300.0
|
||||
got = next_attempt_at(attempt, now=T, cap_s=cap_s, rand=lambda: r)
|
||||
delay = (got - T).total_seconds()
|
||||
assert 0.0 <= delay <= cap_s
|
||||
|
||||
|
||||
@given(attempt=st.integers(min_value=0, max_value=63))
|
||||
def test_delay_is_non_decreasing_in_attempt(attempt: int) -> None:
|
||||
def ceiling_of(a: int) -> float:
|
||||
return (next_attempt_at(a, now=T, rand=lambda: 1.0) - T).total_seconds()
|
||||
|
||||
assert ceiling_of(attempt) <= ceiling_of(attempt + 1)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Unit tests for catalog loading and validation."""
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from svcforge_core.domain.catalog import CatalogError, load_catalog
|
||||
from svcforge_core.domain.models import CatalogEntry
|
||||
|
||||
VALID_YAML = textwrap.dedent("""
|
||||
services:
|
||||
redis:
|
||||
chart: bitnamilegacy/redis
|
||||
chart_version: 20.6.2
|
||||
sizes:
|
||||
small:
|
||||
replicas: 1
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 256Mi}
|
||||
medium:
|
||||
replicas: 3
|
||||
resources:
|
||||
requests: {cpu: 500m, memory: 1Gi}
|
||||
postgres:
|
||||
chart: bitnamilegacy/postgresql
|
||||
chart_version: 16.4.5
|
||||
sizes:
|
||||
small:
|
||||
replicas: 1
|
||||
resources:
|
||||
requests: {cpu: 250m, memory: 512Mi}
|
||||
""")
|
||||
|
||||
MISSING_CHART_VERSION_YAML = textwrap.dedent("""
|
||||
services:
|
||||
redis:
|
||||
chart: bitnamilegacy/redis
|
||||
sizes:
|
||||
small:
|
||||
replicas: 1
|
||||
resources: {}
|
||||
""")
|
||||
|
||||
ZERO_REPLICAS_YAML = textwrap.dedent("""
|
||||
services:
|
||||
redis:
|
||||
chart: bitnamilegacy/redis
|
||||
chart_version: 20.6.2
|
||||
sizes:
|
||||
small:
|
||||
replicas: 0
|
||||
resources: {}
|
||||
""")
|
||||
|
||||
|
||||
def _write(tmp_path: Path, body: str) -> Path:
|
||||
path = tmp_path / "catalog.yaml"
|
||||
path.write_text(body)
|
||||
return path
|
||||
|
||||
|
||||
def test_valid_yaml_loads_to_catalog_entries(tmp_path: Path) -> None:
|
||||
catalog = load_catalog(_write(tmp_path, VALID_YAML))
|
||||
|
||||
assert set(catalog) == {"redis", "postgres"}
|
||||
assert all(isinstance(entry, CatalogEntry) for entry in catalog.values())
|
||||
redis = catalog["redis"]
|
||||
assert redis.service_type == "redis"
|
||||
assert redis.chart_version == "20.6.2"
|
||||
assert set(redis.sizes) == {"small", "medium"}
|
||||
assert redis.sizes["medium"].replicas == 3
|
||||
|
||||
|
||||
def test_missing_chart_version_raises_catalog_error(tmp_path: Path) -> None:
|
||||
with pytest.raises(CatalogError) as excinfo:
|
||||
load_catalog(_write(tmp_path, MISSING_CHART_VERSION_YAML))
|
||||
|
||||
assert excinfo.value.key == "redis"
|
||||
assert "redis" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_zero_replicas_raises_catalog_error(tmp_path: Path) -> None:
|
||||
with pytest.raises(CatalogError) as excinfo:
|
||||
load_catalog(_write(tmp_path, ZERO_REPLICAS_YAML))
|
||||
|
||||
assert excinfo.value.key == "redis"
|
||||
|
||||
|
||||
def test_missing_file_raises_catalog_error(tmp_path: Path) -> None:
|
||||
with pytest.raises(CatalogError, match="cannot read catalog"):
|
||||
load_catalog(tmp_path / "nope.yaml")
|
||||
|
||||
|
||||
def test_unparseable_yaml_raises_catalog_error(tmp_path: Path) -> None:
|
||||
with pytest.raises(CatalogError, match="not valid YAML"):
|
||||
load_catalog(_write(tmp_path, "services: [unclosed\n"))
|
||||
|
||||
|
||||
def test_scalar_root_raises_catalog_error(tmp_path: Path) -> None:
|
||||
with pytest.raises(CatalogError, match="must be a mapping"):
|
||||
load_catalog(_write(tmp_path, "just-a-string\n"))
|
||||
|
||||
|
||||
def test_non_mapping_services_raises_catalog_error(tmp_path: Path) -> None:
|
||||
with pytest.raises(CatalogError, match="'services' must be a mapping"):
|
||||
load_catalog(_write(tmp_path, "services:\n - redis\n"))
|
||||
|
||||
|
||||
def test_non_mapping_entry_raises_catalog_error_naming_the_key(tmp_path: Path) -> None:
|
||||
with pytest.raises(CatalogError) as excinfo:
|
||||
load_catalog(_write(tmp_path, "services:\n redis: just-a-string\n"))
|
||||
|
||||
assert excinfo.value.key == "redis"
|
||||
assert "must be a mapping" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_non_string_field_key_raises_catalog_error_naming_the_key(tmp_path: Path) -> None:
|
||||
"""A non-string YAML key inside an entry breaks `**body`; it must surface as CatalogError."""
|
||||
body = textwrap.dedent("""
|
||||
services:
|
||||
redis:
|
||||
1: oops
|
||||
chart: bitnamilegacy/redis
|
||||
chart_version: 20.6.2
|
||||
sizes: {}
|
||||
""")
|
||||
with pytest.raises(CatalogError) as excinfo:
|
||||
load_catalog(_write(tmp_path, body))
|
||||
|
||||
assert excinfo.value.key == "redis"
|
||||
|
||||
|
||||
def test_bare_mapping_without_services_key_is_accepted(tmp_path: Path) -> None:
|
||||
"""The top-level `services:` wrapper is optional; a bare service_type mapping also loads."""
|
||||
body = textwrap.dedent("""
|
||||
redis:
|
||||
chart: bitnamilegacy/redis
|
||||
chart_version: 20.6.2
|
||||
sizes:
|
||||
small:
|
||||
replicas: 1
|
||||
resources: {}
|
||||
""")
|
||||
catalog = load_catalog(_write(tmp_path, body))
|
||||
|
||||
assert set(catalog) == {"redis"}
|
||||
|
||||
|
||||
def test_repo_catalog_yaml_is_valid() -> None:
|
||||
catalog = load_catalog(Path(__file__).parents[2] / "catalog.yaml")
|
||||
|
||||
assert set(catalog) == {"elasticsearch", "redis", "postgres"}
|
||||
for entry in catalog.values():
|
||||
assert set(entry.sizes) == {"small", "medium"}
|
||||
@@ -0,0 +1,272 @@
|
||||
"""obs.py: the three things that are wrong by default.
|
||||
|
||||
Not tested here: that structlog logs, that prometheus counts, that OTEL traces. Those are
|
||||
the libraries' tests. What is tested is every place where the default is a bug — the
|
||||
histogram buckets, the context that does not cross a queue, and the contextvars that leak
|
||||
between tasks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import structlog
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from prometheus_client import REGISTRY
|
||||
|
||||
from svcforge_core import obs
|
||||
from svcforge_core.settings import Settings
|
||||
|
||||
# W3C traceparent: version-traceid-spanid-flags.
|
||||
#
|
||||
# The flags byte is matched loosely and the sampled bit checked separately, on purpose.
|
||||
# Module 7's acceptance line says `00-<32 hex>-<16 hex>-01`, and current SDKs emit `-03`:
|
||||
# bit 0x01 is `sampled`, and bit 0x02 is `random-trace-id` from trace-context level 2. An
|
||||
# assertion pinned to `-01` fails on a spec revision that changed nothing we care about.
|
||||
# What we care about is the trace being sampled, which is bit 0x01 and nothing else.
|
||||
TRACEPARENT_RE = re.compile(
|
||||
r"^00-(?P<trace_id>[0-9a-f]{32})-(?P<span_id>[0-9a-f]{16})-(?P<flags>[0-9a-f]{2})$"
|
||||
)
|
||||
SAMPLED_BIT = 0x01
|
||||
|
||||
# A provision takes minutes. prometheus_client's default buckets end at 10 seconds.
|
||||
DEFAULT_TOP_BUCKET = 10.0
|
||||
|
||||
|
||||
def _settings(**over: object) -> Settings:
|
||||
return Settings(pg_dsn="postgresql://u:p@localhost:5432/db", **over) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logs() -> Iterator[io.StringIO]:
|
||||
"""setup() against a captured stream, restoring the process-wide config afterwards.
|
||||
|
||||
setup() is deliberately global and deliberately once-only, which makes it deliberately
|
||||
awkward to test. Reaching into the module flag is the honest price of that; the
|
||||
alternative is a seam that exists only for tests.
|
||||
"""
|
||||
stream = io.StringIO()
|
||||
saved_handlers = logging.getLogger().handlers[:]
|
||||
saved_level = logging.getLogger().level
|
||||
saved_config = structlog.get_config()
|
||||
|
||||
obs._configured = False
|
||||
obs.setup("test-service", _settings())
|
||||
|
||||
# Re-point the handler setup() installed at our buffer; everything else it configured —
|
||||
# processors, formatter, the JSON renderer last — is exactly what production gets.
|
||||
handler = logging.getLogger().handlers[0]
|
||||
assert isinstance(handler, logging.StreamHandler)
|
||||
handler.setStream(stream)
|
||||
|
||||
try:
|
||||
yield stream
|
||||
finally:
|
||||
structlog.contextvars.clear_contextvars()
|
||||
structlog.configure(**saved_config)
|
||||
logging.getLogger().handlers = saved_handlers
|
||||
logging.getLogger().setLevel(saved_level)
|
||||
obs._configured = False
|
||||
|
||||
|
||||
def _lines(stream: io.StringIO) -> list[dict[str, Any]]:
|
||||
return [json.loads(line) for line in stream.getvalue().splitlines() if line.strip()]
|
||||
|
||||
|
||||
# --- Buckets ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_provision_histogram_has_a_bucket_for_a_thirty_minute_provision() -> None:
|
||||
"""The acceptance check, as a unit test: `le="1800"` exists.
|
||||
|
||||
With the library defaults the top finite bucket is 10s, every provision lands in +Inf,
|
||||
and histogram_quantile interpolates across a bucket spanning 10s to infinity. The p95
|
||||
it returns is not slow or fast, it is meaningless — and it is meaningless silently,
|
||||
which is why this is asserted rather than eyeballed on a dashboard.
|
||||
"""
|
||||
bounds = obs.PROVISION_TIME._upper_bounds
|
||||
assert 1800.0 in bounds
|
||||
assert bounds[-1] == float("inf")
|
||||
assert max(b for b in bounds if b != float("inf")) > DEFAULT_TOP_BUCKET
|
||||
|
||||
|
||||
def test_provision_histogram_exports_the_1800_bucket() -> None:
|
||||
"""Same claim, checked through the exposition format the scrape actually reads."""
|
||||
obs.PROVISION_TIME.observe(42.0)
|
||||
bucket = REGISTRY.get_sample_value("svcforge_provision_duration_seconds_bucket", {"le": "1800.0"})
|
||||
assert bucket is not None
|
||||
|
||||
|
||||
def test_metric_names_are_the_ones_the_alerts_query() -> None:
|
||||
"""The four alerts are PromQL strings in values.yaml; nothing type-checks them.
|
||||
|
||||
A rename here is a silent alert that never fires again. This test is the link between
|
||||
the chart's rules and the code.
|
||||
"""
|
||||
for name in (
|
||||
"svcforge_tasks_claimed_total",
|
||||
"svcforge_tasks_failed_total",
|
||||
"svcforge_provision_duration_seconds",
|
||||
"svcforge_queue_depth",
|
||||
"svcforge_instances",
|
||||
"svcforge_reconciler_last_tick_timestamp_seconds",
|
||||
):
|
||||
assert REGISTRY._names_to_collectors.get(name) is not None, f"{name} is queried by an alert"
|
||||
|
||||
|
||||
# --- Trace context across the queue -------------------------------------------------------
|
||||
|
||||
|
||||
def test_traceparent_round_trips_through_a_string() -> None:
|
||||
"""inject -> a W3C string -> extract -> the same trace. This is the queue crossing."""
|
||||
provider = TracerProvider()
|
||||
tracer = provider.get_tracer("test")
|
||||
|
||||
with tracer.start_as_current_span("api.post") as span:
|
||||
traceparent = obs.inject_traceparent()
|
||||
api_trace_id = span.get_span_context().trace_id
|
||||
|
||||
assert traceparent is not None
|
||||
match = TRACEPARENT_RE.match(traceparent)
|
||||
assert match is not None, traceparent
|
||||
assert match["trace_id"] == format(api_trace_id, "032x")
|
||||
assert int(match["flags"], 16) & SAMPLED_BIT, "unsampled: the worker's span would be dropped"
|
||||
|
||||
# The worker's side: a fresh context, minutes later, in another process.
|
||||
ctx = obs.context_from_traceparent(traceparent)
|
||||
with tracer.start_as_current_span("worker.claim", context=ctx) as worker_span:
|
||||
assert worker_span.get_span_context().trace_id == api_trace_id
|
||||
|
||||
|
||||
def test_inject_returns_none_without_a_span() -> None:
|
||||
"""A task the reconciler enqueued has no inbound request. Null column, not an error."""
|
||||
assert obs.inject_traceparent() is None
|
||||
|
||||
|
||||
def test_context_from_traceparent_survives_none_and_garbage() -> None:
|
||||
"""A malformed traceparent must start a new trace, never fail a provision.
|
||||
|
||||
Extract does not raise on bad input — it returns a context with no span. Asserted here
|
||||
because the alternative would be a tenant's provision failing over a telemetry header.
|
||||
"""
|
||||
for bad in (None, "", "not-a-traceparent", "00-tooshort-01"):
|
||||
ctx = obs.context_from_traceparent(bad)
|
||||
assert not trace.get_current_span(ctx).get_span_context().is_valid
|
||||
|
||||
|
||||
# --- Contextvars --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_line_carries_instance_id_task_id_and_team(logs: io.StringIO) -> None:
|
||||
"""The acceptance check: bind once at claim, and the keys ride on every line after."""
|
||||
instance_id = uuid4()
|
||||
obs.bind_task_context(instance_id, 42, "platform")
|
||||
|
||||
obs.get_logger("t").info("provision.started")
|
||||
obs.get_logger("t").warning("helm.slow")
|
||||
|
||||
for line in _lines(logs):
|
||||
assert line["instance_id"] == str(instance_id)
|
||||
assert line["task_id"] == 42
|
||||
assert line["team"] == "platform"
|
||||
|
||||
|
||||
def test_foreign_stdlib_logs_are_json_and_carry_the_context(logs: io.StringIO) -> None:
|
||||
"""psycopg and uvicorn log through stdlib `logging`, and their lines must parse too.
|
||||
|
||||
Without the ProcessorFormatter bridge these arrive as bare text on the same stdout, and
|
||||
every one of them is a parse failure in the collector.
|
||||
"""
|
||||
obs.bind_task_context(uuid4(), 7, "payments")
|
||||
logging.getLogger("some.library").warning("connection reset")
|
||||
|
||||
line = _lines(logs)[-1]
|
||||
assert line["event"] == "connection reset"
|
||||
assert line["task_id"] == 7
|
||||
assert line["team"] == "payments"
|
||||
|
||||
|
||||
def test_bind_task_context_clears_the_previous_task(logs: io.StringIO) -> None:
|
||||
"""The bug this prevents: task 2's log line naming task 1's tenant.
|
||||
|
||||
A worker coroutine reuses its context across claim-loop iterations. Bind without
|
||||
clearing and the stale instance_id survives into the next task, which means the log for
|
||||
the incident you are debugging points at the wrong customer.
|
||||
"""
|
||||
obs.bind_task_context(uuid4(), 1, "team-a")
|
||||
second = uuid4()
|
||||
obs.bind_task_context(second, 2, "team-b")
|
||||
|
||||
obs.get_logger("t").info("claimed")
|
||||
|
||||
line = _lines(logs)[-1]
|
||||
assert line["instance_id"] == str(second)
|
||||
assert line["task_id"] == 2
|
||||
assert line["team"] == "team-b"
|
||||
|
||||
|
||||
def test_json_renderer_is_last_and_output_is_one_object_per_line(logs: io.StringIO) -> None:
|
||||
"""JSONRenderer last in the chain, and nothing after it.
|
||||
|
||||
A processor appended after the renderer receives a `str` where it expects a dict and
|
||||
raises. The symptom is not a crash — structlog's default is to fail the log call, so
|
||||
the line simply never appears.
|
||||
"""
|
||||
obs.get_logger("t").info("hello", extra_key="value")
|
||||
|
||||
lines = _lines(logs)
|
||||
assert len(lines) == 1
|
||||
assert lines[0]["event"] == "hello"
|
||||
assert lines[0]["extra_key"] == "value"
|
||||
assert lines[0]["level"] == "info"
|
||||
assert lines[0]["service"] == "test-service"
|
||||
assert "timestamp" in lines[0]
|
||||
|
||||
|
||||
def test_setup_is_idempotent(logs: io.StringIO) -> None:
|
||||
"""Called twice, one handler, one line. Not a nicety: two handlers is two of every line.
|
||||
|
||||
Each service calls setup() from its entrypoint, and an entrypoint that imports another
|
||||
entrypoint (the CLI shelling into the reconciler) calls it twice.
|
||||
"""
|
||||
obs.setup("test-service", _settings())
|
||||
obs.setup("test-service", _settings())
|
||||
|
||||
# Count ours, not pytest's — its capture handler is on the root logger too.
|
||||
ours = [
|
||||
h
|
||||
for h in logging.getLogger().handlers
|
||||
if isinstance(h.formatter, structlog.stdlib.ProcessorFormatter)
|
||||
]
|
||||
assert len(ours) == 1
|
||||
|
||||
obs.get_logger("t").info("once")
|
||||
assert len(_lines(logs)) == 1
|
||||
|
||||
|
||||
def test_metrics_are_single_process_in_memory() -> None:
|
||||
"""One process per pod, scale with replicas. Multiprocess mode is not in use.
|
||||
|
||||
`ValueClass` is prometheus_client's fork in the road, chosen at import from
|
||||
PROMETHEUS_MULTIPROC_DIR: MutexValue keeps counters in memory, MultiProcessValue mmaps
|
||||
them into a shared directory. This repo takes the other fix for `uvicorn --workers 4`
|
||||
corrupting counters — one process per pod — so MutexValue is the correct answer, and a
|
||||
stray PROMETHEUS_MULTIPROC_DIR in a Deployment's env would silently change it to the
|
||||
other one along with the meaning of every gauge.
|
||||
"""
|
||||
from prometheus_client import values
|
||||
|
||||
assert values.ValueClass is values.MutexValue
|
||||
|
||||
# And the registry the services expose is the default in-memory one, not a
|
||||
# MultiProcessCollector reading files off disk.
|
||||
obs.QUEUE_DEPTH.set(3)
|
||||
assert REGISTRY.get_sample_value("svcforge_queue_depth") == 3.0
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Every Protocol, with both of its implementations, checked by mypy.
|
||||
|
||||
These functions have no assertions and cannot fail at runtime — the check happens under
|
||||
`mypy --strict`. If FakeProvisioner drifts from Provisioner (a renamed parameter, a changed
|
||||
return type), the type-check fails here rather than the fake quietly diverging from the real
|
||||
adapter and the worker's fast tests proving nothing about production.
|
||||
|
||||
The test bodies exist so pytest runs the imports too: a Protocol satisfied statically but
|
||||
broken at import time is still broken.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from svcforge_core.adapters.clock import Clock, SystemClock
|
||||
from svcforge_core.adapters.helm import HelmProvisioner, Provisioner
|
||||
from svcforge_core.adapters.notify import LogNotifier, Notifier, WebhookNotifier
|
||||
from svcforge_core.adapters.redis import (
|
||||
IdempotencyStore,
|
||||
IdempotencyStoreProto,
|
||||
InstanceCache,
|
||||
InstanceCacheProto,
|
||||
RateLimiter,
|
||||
RateLimiterProto,
|
||||
make_redis,
|
||||
)
|
||||
from svcforge_core.settings import Settings
|
||||
from tests.fakes import (
|
||||
FakeClock,
|
||||
FakeIdempotencyStore,
|
||||
FakeInstanceCache,
|
||||
FakeNotifier,
|
||||
FakeProvisioner,
|
||||
FakeRateLimiter,
|
||||
)
|
||||
|
||||
|
||||
def take(p: Provisioner) -> None:
|
||||
"""Accepts anything structurally a Provisioner. The whole assertion is the annotation."""
|
||||
|
||||
|
||||
def take_clock(c: Clock) -> None: ...
|
||||
|
||||
|
||||
def take_notifier(n: Notifier) -> None: ...
|
||||
|
||||
|
||||
def test_provisioner_implementations_conform() -> None:
|
||||
take(HelmProvisioner(kubeconfig=Path("/dev/null")))
|
||||
take(FakeProvisioner())
|
||||
print("ok")
|
||||
|
||||
|
||||
def test_clock_implementations_conform() -> None:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
take_clock(SystemClock())
|
||||
take_clock(FakeClock(start=datetime(2026, 1, 1, tzinfo=UTC)))
|
||||
print("ok")
|
||||
|
||||
|
||||
def test_notifier_implementations_conform() -> None:
|
||||
take_notifier(LogNotifier())
|
||||
take_notifier(WebhookNotifier(url="https://example.invalid/hook"))
|
||||
take_notifier(FakeNotifier())
|
||||
print("ok")
|
||||
|
||||
|
||||
def take_limiter(rl: RateLimiterProto) -> None: ...
|
||||
|
||||
|
||||
def take_idempotency(store: IdempotencyStoreProto) -> None: ...
|
||||
|
||||
|
||||
def take_cache(c: InstanceCacheProto) -> None: ...
|
||||
|
||||
|
||||
def test_redis_implementations_conform() -> None:
|
||||
"""A client at a closed port is still a Redis. Nothing here connects — no commands billed.
|
||||
|
||||
That is not a trick to keep the test fast; it is the module's thesis restated as a
|
||||
fixture. Every one of these classes has to be constructible and callable with Redis
|
||||
unreachable, because that is the state they are designed for.
|
||||
"""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
settings = Settings(
|
||||
# pydantic parses these strings into PostgresDsn/RedisDsn at runtime; the
|
||||
# annotation names the parsed type, so the ignores sit on the arguments.
|
||||
pg_dsn="postgresql://unused:unused@127.0.0.1:5432/unused", # type: ignore[arg-type]
|
||||
redis_dsn="redis://127.0.0.1:1/0", # type: ignore[arg-type]
|
||||
)
|
||||
r = make_redis(settings)
|
||||
assert r is not None
|
||||
|
||||
take_limiter(RateLimiter(r, limit=10, window_s=60))
|
||||
take_limiter(FakeRateLimiter(10, 60, FakeClock(start=datetime(2026, 1, 1, tzinfo=UTC))))
|
||||
take_idempotency(IdempotencyStore(r))
|
||||
take_idempotency(FakeIdempotencyStore())
|
||||
take_cache(InstanceCache(r))
|
||||
take_cache(FakeInstanceCache())
|
||||
|
||||
# redis_dsn=None EXPLICITLY. Omitting it does not mean "unset": pydantic-settings
|
||||
# reads SVCFORGE_REDIS_DSN from the environment, so on any machine that has the real
|
||||
# DSN exported this assertion sees a live Upstash client and fails — a green test that
|
||||
# depends on your shell being empty is not a test.
|
||||
assert make_redis(Settings(pg_dsn=settings.pg_dsn, redis_dsn=None)) is None
|
||||
print("ok")
|
||||
@@ -0,0 +1,4 @@
|
||||
def test_import_core() -> None:
|
||||
import svcforge_core
|
||||
|
||||
assert svcforge_core is not None
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Unit tests for the instance state machine."""
|
||||
|
||||
import pytest
|
||||
|
||||
from svcforge_core.domain.states import LEGAL, IllegalTransition, InstanceState, transition
|
||||
|
||||
|
||||
def test_requested_to_provisioning_is_legal() -> None:
|
||||
assert transition(InstanceState.REQUESTED, InstanceState.PROVISIONING) is InstanceState.PROVISIONING
|
||||
|
||||
|
||||
def test_deleted_to_ready_raises() -> None:
|
||||
with pytest.raises(IllegalTransition):
|
||||
transition(InstanceState.DELETED, InstanceState.READY)
|
||||
|
||||
|
||||
def test_failed_to_provisioning_is_legal_retry() -> None:
|
||||
assert transition(InstanceState.FAILED, InstanceState.PROVISIONING) is InstanceState.PROVISIONING
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", list(InstanceState))
|
||||
def test_every_state_has_a_legal_entry(state: InstanceState) -> None:
|
||||
"""A new state with no LEGAL entry must fail the suite, not KeyError at runtime."""
|
||||
assert state in LEGAL
|
||||
assert isinstance(LEGAL[state], frozenset)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", list(InstanceState))
|
||||
def test_every_legal_target_is_an_instance_state(state: InstanceState) -> None:
|
||||
for target in LEGAL[state]:
|
||||
assert isinstance(target, InstanceState)
|
||||
|
||||
|
||||
def test_deleted_is_terminal_with_an_empty_frozenset() -> None:
|
||||
assert LEGAL[InstanceState.DELETED] == frozenset()
|
||||
|
||||
|
||||
def test_strenum_compares_equal_to_its_value() -> None:
|
||||
# mypy calls this non-overlapping by declared type. That is exactly what is being
|
||||
# tested: StrEnum members ARE their values at runtime, which is why psycopg can
|
||||
# adapt them straight to text and model_validate round-trips them for free.
|
||||
assert (InstanceState.READY == "ready") is True # type: ignore[comparison-overlap]
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Unit tests for maintenance windows. Pure domain: no DB, no clock, no mocks.
|
||||
|
||||
`now` is a parameter everywhere in `windows.py`, which is why none of these tests
|
||||
monkeypatch `datetime.now` — there is nothing to patch. That is the point of the design.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from svcforge_core.domain.windows import (
|
||||
BadWindow,
|
||||
MaintenanceWindow,
|
||||
next_window_open,
|
||||
parse_window,
|
||||
schedule_upgrade_at,
|
||||
)
|
||||
|
||||
HCM = MaintenanceWindow("0 3 * * 0", "Asia/Ho_Chi_Minh") # 03:00 every Sunday, Vietnam time
|
||||
|
||||
|
||||
def test_next_window_open_with_naive_now_raises_value_error() -> None:
|
||||
"""The one bug this module exists to prevent, caught at the boundary.
|
||||
|
||||
A naive datetime does not raise when you build it; it raises when you compare it,
|
||||
which is inside a worker at 03:00. mypy sees `datetime` either way.
|
||||
"""
|
||||
with pytest.raises(ValueError, match="aware"):
|
||||
next_window_open(HCM, datetime(2026, 7, 18, 20, 0)) # naive on purpose
|
||||
|
||||
|
||||
def test_next_window_open_returns_the_hcm_sunday_expressed_in_utc() -> None:
|
||||
"""Sunday 03:00 in Ho Chi Minh (UTC+7, no DST) is Saturday 20:00 UTC.
|
||||
|
||||
`now` here IS that instant, and the answer is that instant: the window is open right
|
||||
now, so the upgrade runs now. Strictly-greater semantics would push it a full week.
|
||||
"""
|
||||
now = datetime(2026, 7, 18, 20, 0, tzinfo=UTC)
|
||||
assert now.weekday() == 5 # a Saturday
|
||||
|
||||
opens = next_window_open(HCM, now)
|
||||
|
||||
assert opens.tzinfo is UTC
|
||||
assert opens == datetime(2026, 7, 18, 20, 0, tzinfo=UTC)
|
||||
assert opens.astimezone(ZoneInfo("Asia/Ho_Chi_Minh")) == datetime(
|
||||
2026, 7, 19, 3, 0, tzinfo=ZoneInfo("Asia/Ho_Chi_Minh")
|
||||
)
|
||||
|
||||
|
||||
def test_next_window_open_rolls_to_next_week_once_the_window_has_passed() -> None:
|
||||
"""A second past the open and you wait for the next one. Guards the -1s inclusivity trick."""
|
||||
opens = next_window_open(HCM, datetime(2026, 7, 18, 20, 0, 1, tzinfo=UTC))
|
||||
|
||||
assert opens == datetime(2026, 7, 25, 20, 0, tzinfo=UTC)
|
||||
assert opens.tzinfo is UTC
|
||||
|
||||
|
||||
def test_schedule_upgrade_at_with_security_returns_now_exactly() -> None:
|
||||
"""A CVE with a public exploit does not wait until Sunday."""
|
||||
now = datetime(2026, 7, 18, 20, 0, tzinfo=UTC)
|
||||
|
||||
assert schedule_upgrade_at(HCM, security=True, now=now) == now
|
||||
|
||||
|
||||
def test_schedule_upgrade_at_without_window_returns_now() -> None:
|
||||
"""maintenance_window is null -> upgrade any time."""
|
||||
now = datetime(2026, 7, 15, 9, 30, tzinfo=UTC)
|
||||
|
||||
assert schedule_upgrade_at(None, security=False, now=now) == now
|
||||
assert next_window_open(None, now).tzinfo is UTC
|
||||
|
||||
|
||||
def test_window_across_spring_forward_returns_one_aware_instant() -> None:
|
||||
"""DST spring-forward, asserting croniter's REAL behaviour rather than trusting docs.
|
||||
|
||||
On 2026-03-08 America/New_York jumps 02:00 EST -> 03:00 EDT, so a `30 2 * * *` window
|
||||
has no 02:30 that day. Observed: croniter does not skip the day and does not raise —
|
||||
it CLAMPS to the transition instant, yielding 03:00:00-04:00 (not 03:30). The window
|
||||
opens half an hour "late" in local terms, exactly once, and the following days resume
|
||||
at 02:30 EDT. One instant, aware, and the caller never sees a nonexistent local time.
|
||||
"""
|
||||
window = MaintenanceWindow("30 2 * * *", "America/New_York")
|
||||
now = datetime(2026, 3, 7, 17, 0, tzinfo=UTC) # Sat midday in New York, before the jump
|
||||
|
||||
opens = next_window_open(window, now)
|
||||
|
||||
assert opens.tzinfo is UTC
|
||||
assert opens == datetime(2026, 3, 8, 7, 0, tzinfo=UTC) # == 03:00 EDT, the clamp
|
||||
|
||||
local = opens.astimezone(ZoneInfo("America/New_York"))
|
||||
assert (local.hour, local.minute) == (3, 0)
|
||||
assert local.utcoffset() == timedelta(hours=-4) # EDT: the jump has happened
|
||||
|
||||
# The day after, the window is back where the tenant expects it.
|
||||
after = next_window_open(window, opens + timedelta(seconds=1))
|
||||
assert after == datetime(2026, 3, 9, 6, 30, tzinfo=UTC) # 02:30 EDT
|
||||
|
||||
|
||||
def test_window_across_fall_back_returns_the_first_of_the_two_local_times() -> None:
|
||||
"""Fall-back makes 01:30 happen twice. Observed: croniter yields BOTH, EDT then EST.
|
||||
|
||||
next_window_open returns the earlier one (fold=0, -04:00). Not a bug to fix here: a
|
||||
window that opens twice on one night is what the tenant's cron literally asked for.
|
||||
"""
|
||||
window = MaintenanceWindow("30 1 * * *", "America/New_York")
|
||||
now = datetime(2026, 10, 31, 16, 0, tzinfo=UTC)
|
||||
|
||||
first = next_window_open(window, now)
|
||||
second = next_window_open(window, first + timedelta(seconds=1))
|
||||
|
||||
assert first == datetime(2026, 11, 1, 5, 30, tzinfo=UTC) # 01:30 EDT
|
||||
assert second == datetime(2026, 11, 1, 6, 30, tzinfo=UTC) # 01:30 EST, one hour later
|
||||
assert first.tzinfo is UTC and second.tzinfo is UTC
|
||||
|
||||
|
||||
def test_parse_window_bad_cron_raises_bad_window() -> None:
|
||||
with pytest.raises(BadWindow, match="cron"):
|
||||
parse_window("not a cron|Asia/Ho_Chi_Minh")
|
||||
|
||||
|
||||
def test_parse_window_roundtrips_a_valid_spec() -> None:
|
||||
assert parse_window("0 3 * * 0|Asia/Ho_Chi_Minh") == HCM
|
||||
|
||||
|
||||
def test_parse_window_none_and_blank_mean_any_time() -> None:
|
||||
assert parse_window(None) is None
|
||||
assert parse_window(" ") is None
|
||||
|
||||
|
||||
def test_parse_window_unknown_zone_raises_bad_window() -> None:
|
||||
with pytest.raises(BadWindow, match="IANA"):
|
||||
parse_window("0 3 * * 0|Mars/Olympus_Mons")
|
||||
|
||||
|
||||
def test_parse_window_without_separator_raises_bad_window() -> None:
|
||||
with pytest.raises(BadWindow, match="CRON"):
|
||||
parse_window("0 3 * * 0")
|
||||
|
||||
|
||||
def test_parse_window_six_field_cron_raises_bad_window() -> None:
|
||||
"""croniter's is_valid() accepts a 6-field (seconds) form; the column is 5-field."""
|
||||
with pytest.raises(BadWindow, match="exactly 5 fields"):
|
||||
parse_window("0 0 3 * * 0|Asia/Ho_Chi_Minh")
|
||||
Reference in New Issue
Block a user