Skip to content

Support config file and graceful reload (SIGHUP)#50

Merged
prim-8 merged 5 commits into
mainfrom
feat/config-file-sighup
Apr 24, 2026
Merged

Support config file and graceful reload (SIGHUP)#50
prim-8 merged 5 commits into
mainfrom
feat/config-file-sighup

Conversation

@prim-8
Copy link
Copy Markdown
Contributor

@prim-8 prim-8 commented Apr 22, 2026

Summary

  • The milter can now read configuration from a JSON file (/etc/primitive/milter.json) in addition to environment variables. Config file values take precedence; missing keys fall back to env vars. If no config file exists, behavior is identical to before.
  • Sending SIGHUP to the milter process reloads hot-reloadable values (webhook target, filtering, storage, spoof protection) without restarting the process or dropping SMTP connections.
  • Non-reloadable values (mydomain, mail_dir) are locked at startup — SIGHUP won't change them.
  • Guards prevent dangerous reloads: can't flip between standalone/webhook mode via SIGHUP, can't drop webhook_secret while webhook_url is set.

Why

  • Rotate webhook secrets without SMTP downtime
  • Update allowed sender/recipient lists without restarting
  • Change webhook target for failover or migration
  • Standard Unix operational pattern (kill -HUP) that ops teams expect

Test plan

  • 28 new tests covering config file loading, env var fallback, SIGHUP reload, guard rails, and end-to-end webhook routing from config file
  • All pre-existing tests pass (12 tldextract domain alignment failures are pre-existing, unrelated)
  • Manual: start milter with no config file → verify identical behavior
  • Manual: start milter with config file → verify values load from file
  • Manual: update config file + kill -HUP <pid> → verify webhook_url changes, mydomain doesn't

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 22, 2026

Greptile Summary

This PR refactors all mutable config globals into a single ReloadableConfig dataclass, enabling GIL-atomic config swaps on SIGHUP, with a JSON config file layer that falls back to env vars. Guards prevent mode flips and missing-secret reloads, and the _initial_file_data sentinel prevents accidental clearing of file-only settings when the file disappears at runtime. The core design directly addresses the previously flagged race-condition concerns and is structurally sound.

Confidence Score: 4/5

Safe to merge after fixing the unhandled ValueError in STORAGE_UPLOAD_THRESHOLD parsing, which is a new startup-crash regression introduced by this PR.

The core design is well-executed - atomic ReloadableConfig swap, per-message snapshot in reset(), mode-switch guards, and missing-file abort all work correctly. One P1 remains: the int() cast for the newly-configurable storage_upload_threshold has no error handling and will hard-crash the milter on an invalid config value (this path did not exist before this PR). The test teardown issue in TestEndToEndConfigFile is P2 and does not cause current test failures since it is the last class in the file.

milter/primitivemail_milter.py line 419 (storage_upload_threshold int() cast), test_milter.py TestEndToEndConfigFile.test_webhook_uses_config_file_url teardown

Important Files Changed

Filename Overview
milter/primitivemail_milter.py Config loading refactored into ReloadableConfig + atomic swap; new storage_upload_threshold parsing lacks error handling and can crash at startup on bad input
test_milter.py 28 new tests for config/reload; TestEndToEndConfigFile teardown incorrectly mutates new _rcfg fields instead of restoring the original reference, leaking module state

Sequence Diagram

sequenceDiagram
    participant OS as OS (SIGHUP)
    participant RC as reload_config()
    participant AC as _apply_config(reloadable_only=True)
    participant Global as Module globals
    participant Handler as Handler thread

    OS->>RC: SIGHUP signal
    RC->>RC: _read_config_file(CONFIG_FILE_PATH)
    alt file was present at startup but is now missing
        RC-->>OS: abort (preserve current state)
    else file OK or never existed
        RC->>AC: _apply_config(file_data, reloadable_only=True)
        AC->>AC: build new ReloadableConfig
        AC->>AC: Guard: mode-switch check
        AC->>AC: Guard: webhook_url without webhook_secret
        AC->>Global: _rcfg = new_cfg (GIL-atomic swap)
        AC-->>RC: return
    end

    Note over Handler: self._cfg snapshotted in reset() before SIGHUP
    Handler->>Handler: uses self._cfg for full message duration
    Note over Handler: Next message reset() picks up new _rcfg
Loading

Reviews (4): Last reviewed commit: "Fix stale env var name in module docstri..." | Re-trigger Greptile

Comment thread milter/primitivemail_milter.py Outdated
Comment thread milter/primitivemail_milter.py Outdated
Comment thread milter/primitivemail_milter.py Outdated
Comment thread test_milter.py
Comment on lines +419 to +420
STORAGE_UPLOAD_THRESHOLD = int(_cfg(file_data, 'storage_upload_threshold',
'STORAGE_UPLOAD_THRESHOLD', '3000000'))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unguarded int() cast will crash the milter at startup on bad input

storage_upload_threshold was previously hardcoded (3_000_000); this PR is the first time it's read from the config file or an env var. A config file with "storage_upload_threshold": "unlimited" (or any non-integer string) raises an unhandled ValueError inside _apply_config, killing the process before it can serve any connections.

Consider wrapping with a try/except and logging a warning + falling back to the default:

try:
    STORAGE_UPLOAD_THRESHOLD = int(_cfg(file_data, 'storage_upload_threshold',
                                        'STORAGE_UPLOAD_THRESHOLD', '3000000'))
except (ValueError, TypeError):
    logger.warning("Invalid storage_upload_threshold value - using default 3000000")
    STORAGE_UPLOAD_THRESHOLD = 3_000_000

@prim-8 prim-8 merged commit 43e6eb9 into main Apr 24, 2026
8 checks passed
@prim-8 prim-8 deleted the feat/config-file-sighup branch April 24, 2026 00:54
@etbyrd etbyrd mentioned this pull request Apr 25, 2026
8 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant