CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 2m7s
CI & Build / Build & push image (push) Successful in 30s
With no SECRET_KEY set, Grimoire fell back to a fixed placeholder. The quickstart compose substituted change-me-in-production, so the README's own update step (pull, up -d) recreated the container on that placeholder, and every credential sealed under the key the user started with stopped opening. - config: with neither SECRET_KEY nor SECRET_KEY_FILE, the first start writes a random key to DATA_DIR/secret_key (mode 0600, published with a hard link so concurrent starts agree), and later starts read it back. An explicit key wins and leaves the file alone. A missing SECRET_KEY_FILE or an empty key file stops the start instead of switching keys. With no data directory (a run outside the container) the process gets a temporary key and says so. - app: warns on a public placeholder key, on a temporary key, and when /data is not a mounted volume. - Dockerfile creates /data. - docker-compose.yml is now the deploy compose (published image, both volumes, nothing to set). The build-from-source compose is docker-compose.dev.yml, and docker-compose.quickstart.yml is gone. The Swarm stack file gains the /data volume and keeps its explicit key, because a Swarm can move the app away from a node-local volume. - README: a Deploy section with the compose inline, SECRET_KEY optional, "keep both volumes", and updates that keep the key. A test holds the README's copy equal to the file. - Installs that ran on the change-me-in-production placeholder get a key of their own on the new compose; Settings then asks once for saved forge tokens and the backup secret. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
84 lines
3.0 KiB
Python
84 lines
3.0 KiB
Python
"""The instance keeps its own SECRET_KEY in the data directory.
|
|
|
|
A deployment that never sets SECRET_KEY must get the same key on every start,
|
|
or everything sealed under the first one (forge tokens, the backup secret)
|
|
stops opening after the first container recreate.
|
|
"""
|
|
import stat
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from grimoire import config
|
|
from grimoire.config import SECRET_KEY_FILENAME, load_or_create_secret_key
|
|
from grimoire.services import secrets
|
|
|
|
|
|
def test_the_first_start_writes_a_key_only_its_owner_can_read(tmp_path):
|
|
key = load_or_create_secret_key(tmp_path)
|
|
|
|
path = tmp_path / SECRET_KEY_FILENAME
|
|
assert key and len(key) == 64
|
|
assert path.read_text().strip() == key
|
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
assert [p.name for p in tmp_path.iterdir()] == [SECRET_KEY_FILENAME]
|
|
|
|
|
|
def test_every_later_start_reads_the_same_key_and_what_it_sealed_still_opens(tmp_path):
|
|
first = load_or_create_secret_key(tmp_path)
|
|
with patch.object(secrets.Config, "SECRET_KEY", first):
|
|
sealed = secrets.seal("forge-token")
|
|
|
|
second = load_or_create_secret_key(tmp_path)
|
|
|
|
assert second == first
|
|
with patch.object(secrets.Config, "SECRET_KEY", second):
|
|
assert secrets.unseal(sealed) == "forge-token"
|
|
|
|
|
|
def test_no_data_directory_means_no_kept_key(tmp_path):
|
|
assert load_or_create_secret_key(tmp_path / "absent") is None
|
|
|
|
|
|
def test_an_empty_key_file_is_refused_rather_than_replaced(tmp_path):
|
|
(tmp_path / SECRET_KEY_FILENAME).write_text("\n")
|
|
|
|
with pytest.raises(RuntimeError, match="is empty"):
|
|
load_or_create_secret_key(tmp_path)
|
|
assert (tmp_path / SECRET_KEY_FILENAME).read_text() == "\n"
|
|
|
|
|
|
def test_an_explicit_key_wins_and_leaves_the_kept_one_alone(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
monkeypatch.delenv("SECRET_KEY_FILE", raising=False)
|
|
monkeypatch.delenv("SECRET_KEY", raising=False)
|
|
kept, source = config._secret_key()
|
|
assert source == "data"
|
|
|
|
monkeypatch.setenv("SECRET_KEY", "an-operator-key")
|
|
assert config._secret_key() == ("an-operator-key", "env")
|
|
|
|
monkeypatch.delenv("SECRET_KEY")
|
|
assert config._secret_key() == (kept, "data")
|
|
|
|
|
|
def test_a_secret_key_file_that_is_missing_stops_the_start(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
monkeypatch.delenv("SECRET_KEY", raising=False)
|
|
monkeypatch.setenv("SECRET_KEY_FILE", str(tmp_path / "no-such-secret"))
|
|
|
|
with pytest.raises(RuntimeError, match="SECRET_KEY_FILE"):
|
|
config._secret_key()
|
|
assert not (tmp_path / SECRET_KEY_FILENAME).exists()
|
|
|
|
|
|
def test_with_nowhere_to_keep_a_key_the_process_gets_a_temporary_one(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path / "absent"))
|
|
monkeypatch.delenv("SECRET_KEY", raising=False)
|
|
monkeypatch.delenv("SECRET_KEY_FILE", raising=False)
|
|
|
|
key, source = config._secret_key()
|
|
|
|
assert source == "ephemeral"
|
|
assert key not in config.PLACEHOLDER_SECRET_KEYS and len(key) == 64
|