Fix stale references to deleted files in README and source
- Remove references to PLAN.md, SECURITY_ANALYSIS.md, deploy/ paths - Delete test_deploy.py (tests deleted K8s manifests) - Clean up eval/ references in comments and docstrings
This commit is contained in:
1 parent
8fd3e37b67
commit
8fdec33037
6 files changed
+37
-223
No files matched your search
@@ -4,16 +4,14 @@ Markdown-canonical agent memory service. One self-hosted process that gives ever
|
|||||||
harness (Claude Code, claude.ai, Open WebUI, Hermes, opencode…) a single shared
|
harness (Claude Code, claude.ai, Open WebUI, Hermes, opencode…) a single shared
|
||||||
long-term memory over MCP, with markdown files as the only source of truth.
|
long-term memory over MCP, with markdown files as the only source of truth.
|
||||||
|
|
||||||
Full design and its evidence: [`PLAN.md`](PLAN.md). Section numbers below refer to it.
|
|
||||||
|
|
||||||
- **Markdown is canonical.** `/data/vault/{inbox,active,archive}/*.md`. The SQLite
|
- **Markdown is canonical.** `/data/vault/{inbox,active,archive}/*.md`. The SQLite
|
||||||
index is derived and rebuildable; delete it and the service rebuilds from the files.
|
index is derived and rebuildable; delete it and the service rebuilds from the files.
|
||||||
- **Hybrid retrieval.** FTS5 (BM25, `unicode61 remove_diacritics 2`, optional trigram)
|
- **Hybrid retrieval.** FTS5 (BM25, `unicode61 remove_diacritics 2`, optional trigram)
|
||||||
+ sqlite-vec KNN, fused with RRF (default) or convex combination. No graph traversal
|
+ sqlite-vec KNN, fused with RRF (default) or convex combination. No graph traversal
|
||||||
in the retrieval path (§2.7).
|
in the retrieval path.
|
||||||
- **Agents propose, the owner disposes.** Agent writes land in `inbox/`; an agent
|
- **Agents propose, the owner disposes.** Agent writes land in `inbox/`; an agent
|
||||||
editing an approved note creates a *proposed revision*, never an in-place change.
|
editing an approved note creates a *proposed revision*, never an in-place change.
|
||||||
There is no delete tool (§2.4).
|
There is no delete tool.
|
||||||
- **Memory content is untrusted data.** Tool descriptions and the MCP `instructions`
|
- **Memory content is untrusted data.** Tool descriptions and the MCP `instructions`
|
||||||
say so; the UI renders markdown with raw HTML disabled.
|
say so; the UI renders markdown with raw HTML disabled.
|
||||||
|
|
||||||
@@ -21,20 +19,19 @@ Full design and its evidence: [`PLAN.md`](PLAN.md). Section numbers below refer
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/mdmem/
|
src/mdmem/
|
||||||
config.py settings from the environment (§12)
|
config.py settings from the environment
|
||||||
notes.py frontmatter round-trip (ruamel): unknown keys, order and comments survive
|
notes.py frontmatter round-trip (ruamel): unknown keys, order and comments survive
|
||||||
vaultio.py atomic write → fsync → replace, pre-write history snapshot
|
vaultio.py atomic write → fsync → replace, pre-write history snapshot
|
||||||
chunker.py atomic notes are one chunk; long ones split on H2/H3 (§6)
|
chunker.py atomic notes are one chunk; long ones split on H2/H3
|
||||||
db.py schema, sqlite-vec, single-writer rule (§5)
|
db.py schema, sqlite-vec, single-writer rule
|
||||||
index.py vault → rows, links, duplicate hints
|
index.py vault → rows, links, duplicate hints
|
||||||
embed.py Bifrost client, content-hash cache, FakeEmbedder for tests (§7)
|
embed.py Bifrost client, content-hash cache, FakeEmbedder for tests
|
||||||
search.py the retrieval algorithm (§8)
|
search.py the retrieval algorithm
|
||||||
service.py the one code path every write goes through — MCP and UI alike
|
service.py the one code path every write goes through — MCP and UI alike
|
||||||
mcp_server.py six memory_* tools, bearer auth, idempotency, audit log (§9)
|
mcp_server.py six memory_* tools, bearer auth, idempotency, audit log
|
||||||
ui/ FastAPI + Jinja + HTMX, PocketID OIDC with a group check (§10)
|
ui/ FastAPI + Jinja + HTMX, PocketID OIDC with a group check
|
||||||
evalharness.py metrics, bootstrap CIs, ablation matrix (§11)
|
evalharness.py metrics, bootstrap CIs, ablation matrix
|
||||||
deploy/ Kubernetes manifests, laid out for the fluxcd repo (§13a)
|
tests/ pytest suite
|
||||||
eval/ query-set template, results
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
@@ -55,14 +52,14 @@ UI_AUTH_DISABLED=1 LOG_STYLE=text uv run mdmem serve
|
|||||||
```
|
```
|
||||||
|
|
||||||
`UI_AUTH_DISABLED=1` is for a port-forwarded local UI only. It logs an error on
|
`UI_AUTH_DISABLED=1` is for a port-forwarded local UI only. It logs an error on
|
||||||
startup, and **an HTTPRoute must never point at a listener running with it** (§14.19).
|
startup, and **an HTTPRoute must never point at a listener running with it**.
|
||||||
|
|
||||||
Other commands: `mdmem reindex [--full] [--no-embed]`, `mdmem doctor`,
|
Other commands: `mdmem reindex [--full] [--no-embed]`, `mdmem doctor`,
|
||||||
`mdmem eval --queries queries.yaml [--ablate]`.
|
`mdmem eval --queries queries.yaml [--ablate]`.
|
||||||
|
|
||||||
## Build and push
|
## Build and push
|
||||||
|
|
||||||
There is no CI runner in the cluster, so builds are manual from the workstation:
|
Builds are manual from the workstation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
podman build -t harbor.cloud.traunseenet.com/library/mdmem:0.1.0 -f Containerfile .
|
podman build -t harbor.cloud.traunseenet.com/library/mdmem:0.1.0 -f Containerfile .
|
||||||
@@ -72,13 +69,14 @@ podman inspect --format '{{index .RepoDigests 0}}' harbor.cloud.traunseenet.com/
|
|||||||
|
|
||||||
Put tag **and** digest in `deployment.yaml`. Never `:latest` + `imagePullPolicy: Always`
|
Put tag **and** digest in `deployment.yaml`. Never `:latest` + `imagePullPolicy: Always`
|
||||||
for this service: without a runner, an unpinned tag means the running code is not in
|
for this service: without a runner, an unpinned tag means the running code is not in
|
||||||
git, and a pod restart becomes an unplanned upgrade of your memory store (§13a.2, §14.20).
|
git, and a pod restart becomes an unplanned upgrade of your memory store.
|
||||||
|
|
||||||
## Deploy (first install)
|
## Deployment (FluxCD on RKE2)
|
||||||
|
|
||||||
`deploy/kubernetes/apps/apps/mdmem/**` mirrors the fluxcd repo's nesting; copy it in.
|
Deployment manifests are maintained in a separate [fluxcd repo](https://github.com/traunseenet/fluxcd).
|
||||||
The manifests were written from §13a and validated with `kubectl kustomize`, but **not**
|
The mdmem manifests live under `kubernetes/apps/apps/mdmem/` and are managed by FluxCD.
|
||||||
against a live cluster — verify each step.
|
|
||||||
|
### First install
|
||||||
|
|
||||||
1. Build and push the image; record the digest and put it in `deployment.yaml`.
|
1. Build and push the image; record the digest and put it in `deployment.yaml`.
|
||||||
2. Seed Vault:
|
2. Seed Vault:
|
||||||
@@ -96,8 +94,8 @@ against a live cluster — verify each step.
|
|||||||
assume. Client id → `mdmem-env`, client secret → Vault.
|
assume. Client id → `mdmem-env`, client secret → Vault.
|
||||||
4. Create the restic repo for `restic-mdmem`; copy `secret.example.yaml` to
|
4. Create the restic repo for `restic-mdmem`; copy `secret.example.yaml` to
|
||||||
`secret.yaml`, fill it in, SOPS-encrypt it.
|
`secret.yaml`, fill it in, SOPS-encrypt it.
|
||||||
5. Commit `kubernetes/apps/apps/mdmem/**`. Check: ExternalSecret `Ready`, PVC `Bound`,
|
5. Commit `kubernetes/apps/apps/mdmem/**` to the fluxcd repo. Check: ExternalSecret
|
||||||
Deployment healthy, `/readyz` 200.
|
`Ready`, PVC `Bound`, Deployment healthy, `/readyz` 200.
|
||||||
6. Verify embeddings end to end from inside the cluster:
|
6. Verify embeddings end to end from inside the cluster:
|
||||||
```bash
|
```bash
|
||||||
kubectl -n apps run curl --rm -it --restart=Never \
|
kubectl -n apps run curl --rm -it --restart=Never \
|
||||||
@@ -111,19 +109,19 @@ against a live cluster — verify each step.
|
|||||||
While you are there, confirm Bifrost's `semantic_cache` plugin does **not** intercept
|
While you are there, confirm Bifrost's `semantic_cache` plugin does **not** intercept
|
||||||
`/v1/embeddings` — send two slightly different inputs and check the vectors differ. A
|
`/v1/embeddings` — send two slightly different inputs and check the vectors differ. A
|
||||||
cache hit there would return a stale vector for changed text and silently corrupt the
|
cache hit there would return a stale vector for changed text and silently corrupt the
|
||||||
index (§14.13). `uv run pytest -m live` checks this and the pooling/prefix behaviour.
|
index. `uv run pytest -m live` checks this and the pooling/prefix behaviour.
|
||||||
7. Only then add `deploy/bifrost-mcp-client.json` to Bifrost's `config.json` and commit,
|
7. Only then add the Bifrost MCP client configuration to Bifrost's `config.json` and commit,
|
||||||
so a failure is attributable to one change at a time. Create a VK scoped to the six
|
so a failure is attributable to one change at a time. Create a VK scoped to the six
|
||||||
`memory_*` tools.
|
`memory_*` tools.
|
||||||
8. Apply the NetworkPolicy and re-check the two things it can silently break: the pod
|
8. Apply the NetworkPolicy and re-check the two things it can silently break: the pod
|
||||||
stays Ready (kubelet probes) and the Prometheus target stays `UP` (§14.23).
|
stays Ready (kubelet probes) and the Prometheus target stays `UP`.
|
||||||
9. Run a `ReplicationSource` and **test a restore** into a throwaway PVC before the
|
9. Run a `ReplicationSource` and **test a restore** into a throwaway PVC before the
|
||||||
vault holds anything you would miss.
|
vault holds anything you would miss.
|
||||||
|
|
||||||
### Rotating MCP_TOKEN
|
### Rotating MCP_TOKEN
|
||||||
|
|
||||||
The token lives in two Vault paths. Rotating one and not the other gives every agent a
|
The token lives in two Vault paths. Rotating one and not the other gives every agent a
|
||||||
401 that looks like an mdmem bug (§14.15):
|
401 that looks like an mdmem bug:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NEW=$(openssl rand -hex 32)
|
NEW=$(openssl rand -hex 32)
|
||||||
@@ -145,7 +143,7 @@ All harnesses go through Bifrost — mdmem's own port is never exposed.
|
|||||||
a browser-side connector flow may need `https://claude.ai` added.
|
a browser-side connector flow may need `https://claude.ai` added.
|
||||||
- **Hermes / opencode** — remote MCP config, same URL + VK header.
|
- **Hermes / opencode** — remote MCP config, same URL + VK header.
|
||||||
|
|
||||||
## Where the implementation deviates from PLAN.md
|
## Where the implementation deviates from the plan
|
||||||
|
|
||||||
Each of these is a decision made while building, with the reason:
|
Each of these is a decision made while building, with the reason:
|
||||||
|
|
||||||
@@ -155,9 +153,9 @@ Each of these is a decision made while building, with the reason:
|
|||||||
| 5 | `chunks_fts`/`chunks_tri` are `content=''` | ordinary FTS5 tables | Contentless FTS5 cannot delete a row before SQLite 3.43 (bookworm ships 3.40) and cannot `snippet()`. The index is rewritten on every edit, so deletes are the hot path. Costs a few MB of duplicated text. |
|
| 5 | `chunks_fts`/`chunks_tri` are `content=''` | ordinary FTS5 tables | Contentless FTS5 cannot delete a row before SQLite 3.43 (bookworm ships 3.40) and cannot `snippet()`. The index is rewritten on every edit, so deletes are the hot path. Costs a few MB of duplicated text. |
|
||||||
| 6 | "one asyncio task owns all SQLite writes (queue)" | one write connection behind a lock; async paths use `asyncio.to_thread` | Same invariant (exactly one writer), and it also holds for the CLI and the tests, which have no event loop of the service's. |
|
| 6 | "one asyncio task owns all SQLite writes (queue)" | one write connection behind a lock; async paths use `asyncio.to_thread` | Same invariant (exactly one writer), and it also holds for the CLI and the tests, which have no event loop of the service's. |
|
||||||
| 8 | vec0 cosine | vec0 L2 over L2-normalised vectors, `cos = 1 − d²/2` | Monotone-equivalent and does not depend on a `distance_metric=` option in a pre-v1 extension. |
|
| 8 | vec0 cosine | vec0 L2 over L2-normalised vectors, `cos = 1 − d²/2` | Monotone-equivalent and does not depend on a `distance_metric=` option in a pre-v1 extension. |
|
||||||
| 9 | — | `MCP_ALLOWED_HOSTS` (new setting) | The SDK's DNS-rebinding protection checks the `Host` header. Without the in-cluster service name on the allow-list, every Bifrost call returns 421. Added to §14 as pitfall 24. |
|
| 9 | — | `MCP_ALLOWED_HOSTS` (new setting) | The SDK's DNS-rebinding protection checks the `Host` header. Without the in-cluster service name on the allow-list, every Bifrost call returns 421. Added to pitfalls as pitfall 24. |
|
||||||
| 10 | — | `UI_AUTH_DISABLED` (new setting) | Makes §10's "P4 may ship without OIDC, but then without an HTTPRoute" explicit and loud instead of implicit. |
|
| 10 | — | `UI_AUTH_DISABLED` (new setting) | Makes the "may ship without OIDC, but then without an HTTPRoute" rule explicit and loud instead of implicit. |
|
||||||
| 11 | `queries.yaml` with 50–100 real queries | `queries.example.yaml` + `--fake-embeddings` | Real queries are Markus's to write, and the real model is not reachable from the build environment. The harness is complete and tested; the defaults stay as §2.5 specifies. |
|
| 11 | `queries.yaml` with 50–100 real queries | `queries.example.yaml` + `--fake-embeddings` | Real queries are Markus's to write, and the real model is not reachable from the build environment. The harness is complete and tested; the defaults stay as specified. |
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@@ -166,13 +164,13 @@ Bifrost endpoint). P6 is written but unverified: no cluster, registry, Vault or
|
|||||||
was reachable from the build environment, so the image was never built, the manifests
|
was reachable from the build environment, so the image was never built, the manifests
|
||||||
were never applied, and the live checks in the deploy list above are still to do.
|
were never applied, and the live checks in the deploy list above are still to do.
|
||||||
|
|
||||||
The implementation has been through a multi-agent review against `PLAN.md`, which found
|
The implementation has been through a multi-agent review which found
|
||||||
35 defects — four of them data-losing (a YAML typo or a UTF-8 BOM destroying a note's
|
35 defects — four of them data-losing (a YAML typo or a UTF-8 BOM destroying a note's
|
||||||
identity; `Vault.delete()` resolving the target from file contents and `rmtree`-ing
|
identity; `Vault.delete()` resolving the target from file contents and `rmtree`-ing
|
||||||
outside the vault; `Vault.write()` escaping the vault via a caller-supplied id; and
|
outside the vault; `Vault.write()` escaping the vault via a caller-supplied id; and
|
||||||
`mdmem eval --ablate` dropping the trigram table from the live index). All are fixed,
|
`mdmem eval --ablate` dropping the trigram table from the live index). All are fixed,
|
||||||
each with a regression test that was checked to fail when the fix is reverted. The
|
each with a regression test that was checked to fail when the fix is reverted. The
|
||||||
lessons are recorded as pitfalls 26–31 in `PLAN.md` §14.
|
lessons are recorded as pitfalls 26–31.
|
||||||
|
|
||||||
Three tests were also found to be green for the wrong reason — a dense-retrieval test
|
Three tests were also found to be green for the wrong reason — a dense-retrieval test
|
||||||
whose query was the note body verbatim, a double-index test that slept less than the
|
whose query was the note body verbatim, a double-index test that slept less than the
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class MemoryService:
|
|||||||
# retry loop, embed_pending/_embed_items). Cancelling the asyncio task
|
# retry loop, embed_pending/_embed_items). Cancelling the asyncio task
|
||||||
# that awaits one of these does NOT stop the worker thread, so stop()
|
# that awaits one of these does NOT stop the worker thread, so stop()
|
||||||
# must wait for them to actually finish before closing the db out from
|
# must wait for them to actually finish before closing the db out from
|
||||||
# under them (§ SECURITY_ANALYSIS.md, segfault in Indexer.pending_embeds).
|
# under them (segfault risk in Indexer.pending_embeds).
|
||||||
self._inflight: set[asyncio.Future[Any]] = set()
|
self._inflight: set[asyncio.Future[Any]] = set()
|
||||||
self._secret_warnings: list[str] = []
|
self._secret_warnings: list[str] = []
|
||||||
self.metrics = {"search": 0, "writes": 0, "embed_calls": 0}
|
self.metrics = {"search": 0, "writes": 0, "embed_calls": 0}
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
"""Smoke tests for the committed Kubernetes overlay (PLAN §13a).
|
|
||||||
|
|
||||||
The overlay is not directly buildable: `secret.yaml` is SOPS-encrypted and kept out
|
|
||||||
of the repo. The image digest and the PocketID client id are filled in, but both are
|
|
||||||
release-time values — the digest is bumped by hand on every build (§13a.2) — so their
|
|
||||||
placeholder spellings stay on the allowlist below rather than being treated as a
|
|
||||||
defect if they reappear.
|
|
||||||
|
|
||||||
Nothing else checked that the overlay stayed coherent: a renamed environment
|
|
||||||
variable, a probe pointing at a port the container does not expose, or a resource
|
|
||||||
dropped from the kustomization would only surface on a cluster.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
APP = Path("deploy/kubernetes/apps/apps/mdmem/mdmem/app")
|
|
||||||
DEPLOYMENT = APP / "deployment.yaml"
|
|
||||||
|
|
||||||
# Documented, intentional gaps: the overlay cannot render as committed.
|
|
||||||
ABSENT_BY_DESIGN = {"./secret.yaml"}
|
|
||||||
RELEASE_PLACEHOLDERS = ("REPLACE_WITH_DIGEST", "REPLACE-WITH-THE-POCKETID-CLIENT-UUID")
|
|
||||||
|
|
||||||
|
|
||||||
def _load(path: Path) -> dict:
|
|
||||||
"""The overlay has multi-document files; take the first real document."""
|
|
||||||
docs = [d for d in yaml.safe_load_all(path.read_text(encoding="utf-8")) if d]
|
|
||||||
return docs[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _load_all(path: Path) -> list[dict]:
|
|
||||||
return [d for d in yaml.safe_load_all(path.read_text(encoding="utf-8")) if d]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def deployment() -> dict:
|
|
||||||
return _load(DEPLOYMENT)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def container(deployment) -> dict:
|
|
||||||
return deployment["spec"]["template"]["spec"]["containers"][0]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def kustomization() -> dict:
|
|
||||||
return _load(APP / "kustomization.yaml")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def env_literals(kustomization) -> dict[str, str]:
|
|
||||||
generator = next(g for g in kustomization["configMapGenerator"] if g["name"] == "mdmem-env")
|
|
||||||
out = {}
|
|
||||||
for literal in generator["literals"]:
|
|
||||||
key, _, value = literal.partition("=")
|
|
||||||
out[key] = value
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_manifest_in_the_overlay_parses():
|
|
||||||
for path in sorted(APP.glob("*.yaml")):
|
|
||||||
docs = _load_all(path)
|
|
||||||
assert docs, f"{path} is empty or unparseable"
|
|
||||||
for doc in docs:
|
|
||||||
assert doc.get("kind") or "kustomize" in str(doc.get("apiVersion", "")), path
|
|
||||||
|
|
||||||
|
|
||||||
def test_kustomization_resources_exist_apart_from_the_sops_secret(kustomization):
|
|
||||||
for resource in kustomization["resources"]:
|
|
||||||
if resource in ABSENT_BY_DESIGN:
|
|
||||||
# ...but its template must be committed so the gap is fixable
|
|
||||||
assert (APP / "secret.example.yaml").exists()
|
|
||||||
continue
|
|
||||||
assert (APP / resource).exists(), f"{resource} is referenced but missing"
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_only_unrendered_placeholders_are_the_documented_release_values():
|
|
||||||
"""If a new placeholder creeps in, it must be declared here, not discovered
|
|
||||||
during a deploy."""
|
|
||||||
found = set()
|
|
||||||
for path in sorted(APP.glob("*.yaml")):
|
|
||||||
text = path.read_text(encoding="utf-8")
|
|
||||||
for token in ("REPLACE_WITH", "REPLACE-WITH", "CHANGEME", "TODO:"):
|
|
||||||
if token in text:
|
|
||||||
found.update(w for w in text.split() if token in w)
|
|
||||||
unexpected = {f for f in found if not any(p in f for p in RELEASE_PLACEHOLDERS)}
|
|
||||||
assert not unexpected, f"undocumented placeholders in the overlay: {sorted(unexpected)}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_configmap_env_keys_are_all_read_by_the_application(env_literals):
|
|
||||||
"""Catches a variable renamed in config.py but not in the overlay (or vice versa):
|
|
||||||
a silently ignored EMBED_MODEL would serve lexical-only in production."""
|
|
||||||
src = "\n".join(p.read_text(encoding="utf-8") for p in Path("src/mdmem").rglob("*.py"))
|
|
||||||
unread = [key for key in env_literals if f'"{key}"' not in src]
|
|
||||||
assert not unread, f"the overlay sets variables the app never reads: {unread}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_secret_supplied_variables_are_not_duplicated_in_the_configmap(env_literals):
|
|
||||||
"""MCP_TOKEN and friends come from Vault via the ExternalSecret; a plaintext
|
|
||||||
copy in the ConfigMap would both leak and win or lose unpredictably."""
|
|
||||||
secret_keys = {"MCP_TOKEN", "BIFROST_VK", "OIDC_CLIENT_SECRET", "SESSION_SECRET"}
|
|
||||||
assert not (secret_keys & set(env_literals))
|
|
||||||
|
|
||||||
|
|
||||||
def test_embed_model_carries_its_provider_prefix(env_literals):
|
|
||||||
"""A bare alias 404s at Bifrost and looks like an mdmem bug (§7, §14.14)."""
|
|
||||||
assert "/" in env_literals["EMBED_MODEL"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_listener_addresses_match_the_declared_container_ports(env_literals, container):
|
|
||||||
ports = {p["name"]: p["containerPort"] for p in container["ports"]}
|
|
||||||
assert env_literals["MCP_ADDR"].endswith(f":{ports['mcp']}")
|
|
||||||
assert env_literals["UI_ADDR"].endswith(f":{ports['ui']}")
|
|
||||||
# the MCP SDK checks Host, so every allowed host must name the MCP port
|
|
||||||
for host in env_literals["MCP_ALLOWED_HOSTS"].split(","):
|
|
||||||
assert host.strip().endswith(f":{ports['mcp']}"), host
|
|
||||||
|
|
||||||
|
|
||||||
def test_probes_point_at_ports_the_container_actually_exposes(container):
|
|
||||||
names = {p["name"] for p in container["ports"]}
|
|
||||||
for probe in ("livenessProbe", "readinessProbe", "startupProbe"):
|
|
||||||
assert container[probe]["httpGet"]["port"] in names
|
|
||||||
|
|
||||||
|
|
||||||
def test_mdmem_deployment_has_startup_probe_budget(container):
|
|
||||||
"""§14.17: the pod must be allowed to come up in lexical-only mode while the
|
|
||||||
embedding backend is unreachable, instead of being restarted mid-startup."""
|
|
||||||
probe = container["startupProbe"]
|
|
||||||
assert probe["httpGet"] == {"path": "/healthz", "port": "ui"}
|
|
||||||
assert probe["periodSeconds"] * probe["failureThreshold"] >= 180
|
|
||||||
|
|
||||||
|
|
||||||
def test_liveness_is_dumb_and_readiness_never_depends_on_bifrost(container):
|
|
||||||
assert container["livenessProbe"]["httpGet"]["path"] == "/healthz"
|
|
||||||
assert container["readinessProbe"]["httpGet"]["path"] == "/readyz"
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_pod_stays_locked_down(deployment, container):
|
|
||||||
pod_sc = deployment["spec"]["template"]["spec"]["securityContext"]
|
|
||||||
assert pod_sc["runAsNonRoot"] is True
|
|
||||||
sc = container["securityContext"]
|
|
||||||
assert sc["readOnlyRootFilesystem"] is True
|
|
||||||
assert sc["allowPrivilegeEscalation"] is False
|
|
||||||
assert sc["capabilities"]["drop"] == ["ALL"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_only_root_filesystem_has_somewhere_to_write(deployment, container):
|
|
||||||
"""readOnlyRootFilesystem only holds if HOME/XDG_CACHE_HOME point at a mounted
|
|
||||||
writable path."""
|
|
||||||
mounts = {m["name"]: m["mountPath"] for m in container["volumeMounts"]}
|
|
||||||
env = {e["name"]: e["value"] for e in container["env"]}
|
|
||||||
volumes = {v["name"] for v in deployment["spec"]["template"]["spec"]["volumes"]}
|
|
||||||
assert mounts["tmp"] == "/tmp" and "tmp" in volumes
|
|
||||||
assert env["HOME"] == "/tmp" and env["XDG_CACHE_HOME"] == "/tmp"
|
|
||||||
assert mounts["data"] == "/data" and "data" in volumes
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_persistent_path_lives_on_the_data_volume(env_literals, container):
|
|
||||||
mounts = {m["name"]: m["mountPath"] for m in container["volumeMounts"]}
|
|
||||||
for key in ("VAULT_DIR", "INDEX_DB", "CACHE_DB", "HISTORY_DIR", "AUDIT_LOG"):
|
|
||||||
assert env_literals[key].startswith(mounts["data"] + "/"), key
|
|
||||||
|
|
||||||
|
|
||||||
def test_sqlite_requires_a_single_writer(deployment):
|
|
||||||
assert deployment["spec"]["replicas"] == 1
|
|
||||||
assert deployment["spec"]["strategy"]["type"] == "Recreate"
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_servicemonitor_scrapes_the_ui_port_where_metrics_live():
|
|
||||||
monitor = _load(APP / "servicemonitor.yaml")
|
|
||||||
service = _load(APP / "service.yaml")
|
|
||||||
endpoint = monitor["spec"]["endpoints"][0]
|
|
||||||
assert endpoint["path"] == "/metrics"
|
|
||||||
assert endpoint["port"] in {p["name"] for p in service["spec"]["ports"]}
|
|
||||||
assert monitor["spec"]["selector"]["matchLabels"] == service["metadata"]["labels"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_service_targets_the_containers_named_ports(container):
|
|
||||||
service = _load(APP / "service.yaml")
|
|
||||||
names = {p["name"] for p in container["ports"]}
|
|
||||||
for port in service["spec"]["ports"]:
|
|
||||||
assert port["targetPort"] in names
|
|
||||||
@@ -6,7 +6,7 @@ from mdmem.logging import AuditLog
|
|||||||
def test_recent_merges_persisted_history_across_restart_without_duplicates(tmp_path):
|
def test_recent_merges_persisted_history_across_restart_without_duplicates(tmp_path):
|
||||||
"""`AuditLog.recent` must return the merged tail of persisted history plus
|
"""`AuditLog.recent` must return the merged tail of persisted history plus
|
||||||
in-memory entries across a restart, newest-first, without duplicating the
|
in-memory entries across a restart, newest-first, without duplicating the
|
||||||
entries that show up in both (SECURITY_ANALYSIS.md, logging.py:77-92)."""
|
entries that show up in both (logging.py:77-92)."""
|
||||||
path = tmp_path / "audit.jsonl"
|
path = tmp_path / "audit.jsonl"
|
||||||
first = AuditLog(path)
|
first = AuditLog(path)
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Regression tests for service-layer write-path and security findings (PLAN.md §2.4, §9, §14.9-14.12)."""
|
"""Regression tests for service-layer write-path and security findings."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -361,7 +361,7 @@ async def test_flag_reason_carrying_a_secret_is_rejected_before_it_is_stored(svc
|
|||||||
|
|
||||||
# -- C-01: the whole read/check/write operation is one serialized step -------
|
# -- C-01: the whole read/check/write operation is one serialized step -------
|
||||||
#
|
#
|
||||||
# These are the deterministic barrier tests SECURITY_ANALYSIS.md asked for. Every
|
# These are the deterministic barrier tests for concurrency-critical paths.
|
||||||
# other "race" test in this file is sequential and would still pass if the write
|
# other "race" test in this file is sequential and would still pass if the write
|
||||||
# lock were narrowed back to just the persist call, which is exactly the defect
|
# lock were narrowed back to just the persist call, which is exactly the defect
|
||||||
# C-01 described. The barrier below wedges the first operation *inside* its
|
# C-01 described. The barrier below wedges the first operation *inside* its
|
||||||
|
|||||||
@@ -428,9 +428,9 @@ async def test_run_eval_is_auth_and_csrf_protected_like_the_other_health_actions
|
|||||||
|
|
||||||
|
|
||||||
async def test_run_eval_keeps_its_files_on_the_data_volume_and_reports_status(ui, monkeypatch):
|
async def test_run_eval_keeps_its_files_on_the_data_volume_and_reports_status(ui, monkeypatch):
|
||||||
"""The pod runs with readOnlyRootFilesystem, so a relative eval/results path
|
"""The pod runs with readOnlyRootFilesystem, so a relative results path
|
||||||
would fail at the write with only a log line. Both sides of the run must sit
|
would fail at the write with only a log line. Both sides of the run must sit
|
||||||
next to index.db on the mounted volume (§13a.3, §11)."""
|
next to index.db on the mounted volume."""
|
||||||
client, svc = ui
|
client, svc = ui
|
||||||
data_dir = svc.settings.index_db.parent
|
data_dir = svc.settings.index_db.parent
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user