Skip to content

Commit c40b083

Browse files
authored
feat: 12-factor env vars FLAPI_CONFIG + FLAPI_LOG_LEVEL (#57)
Part of #40. Closes #47. A bundled flapi binary should be operable purely via environment variables (12-factor app). Two known gaps closed: - `FLAPI_CONFIG` falls back for `-c` / `--config`. The CLAUDE.md docs already mentioned it as if it worked; this PR actually wires it. - `FLAPI_LOG_LEVEL` falls back for `--log-level`. Invalid values cause flapi to exit with a single-line error message rather than silently coercing to `info` -- typos like FLAPI_LOG_LEVEL=DEBUG surface immediately. Precedence (highest wins): CLI flag > env var > built-in default. The CLI-wins rule is enforced via argparse's `is_used()` query -- we only consult the env when the user didn't pass the flag. Implementation (src/main.cpp): 24 lines after parse_args; checks `is_used("--config")` / `is_used("--log-level")`, reads `getenv()` on miss, validates log_level against the allowed enum. Integration tests (test/integration/test_env_overrides.py, 6 cases, ~4 sec on Linux x86_64 debug): - invalid FLAPI_LOG_LEVEL -> exit 1 with "invalid log level" + offending value - FLAPI_LOG_LEVEL=debug -> DEBUG log lines appear at validate-config - CLI --log-level wins over FLAPI_LOG_LEVEL - FLAPI_CONFIG used when no -c flag - CLI -c wins over FLAPI_CONFIG (env points at broken YAML, validate still succeeds against the CLI-supplied path) - default lookup of `flapi.yaml` in cwd still works when neither set Docs: - docs/CLI_REFERENCE.md: env-var rows + precedence list under `--config` and `--log-level`, plus example invocations. - docs/CONFIG_REFERENCE.md: new "12-factor checklist" section consolidating every env var flapi reads (startup vs query time), with a note that the `flapi pack` secret deny list enforces the "secrets from env, never from bundle" rule at packaging time. Out of scope (deferred): FLAPI_PORT / FLAPI_HOST and a full credential env-var audit (the user explicitly narrowed #47 to these two vars in the planning round).
1 parent e0d1966 commit c40b083

4 files changed

Lines changed: 223 additions & 1 deletion

File tree

docs/CLI_REFERENCE.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,17 @@ Specifies the path to the flAPI YAML configuration file.
8686
| Type | string (file path) |
8787
| Default | `flapi.yaml` |
8888
| Required | No |
89+
| Environment variable | `FLAPI_CONFIG` |
8990

9091
**Description:**
9192

9293
The configuration file defines connections, endpoint directories, DuckDB settings, authentication, caching, and other server options. The path can be absolute or relative to the current working directory.
9394

95+
**Precedence (highest wins):**
96+
1. `-c` / `--config` CLI flag
97+
2. `FLAPI_CONFIG` environment variable
98+
3. Built-in default (`flapi.yaml` in the current working directory)
99+
94100
**Example:**
95101

96102
```bash
@@ -100,11 +106,15 @@ The configuration file defines connections, endpoint directories, DuckDB setting
100106
# Specify a custom configuration file
101107
./flapi -c production.yaml
102108
./flapi --config /etc/flapi/config.yaml
109+
110+
# Point at a config via environment variable (12-factor style)
111+
export FLAPI_CONFIG=/etc/flapi/production.yaml
112+
./flapi
103113
```
104114

105115
**See also:** [Configuration Reference](./CONFIG_REFERENCE.md) for configuration file options.
106116

107-
> **Implementation:** `src/main.cpp`, `src/config_manager.cpp` | **Tests:** `test/cpp/config_manager_test.cpp`
117+
> **Implementation:** `src/main.cpp`, `src/config_manager.cpp` | **Tests:** `test/cpp/config_manager_test.cpp`, `test/integration/test_env_overrides.py`
108118
109119
---
110120

@@ -149,11 +159,21 @@ Sets the logging verbosity level.
149159
| Default | `info` |
150160
| Required | No |
151161
| Valid values | `debug`, `info`, `warning`, `error` |
162+
| Environment variable | `FLAPI_LOG_LEVEL` |
152163

153164
**Description:**
154165

155166
Controls the amount of log output. More verbose levels include all messages from less verbose levels.
156167

168+
**Precedence (highest wins):**
169+
1. `--log-level` CLI flag
170+
2. `FLAPI_LOG_LEVEL` environment variable
171+
3. Built-in default (`info`)
172+
173+
Invalid values cause flapi to exit with a single-line error -- typos
174+
like `FLAPI_LOG_LEVEL=DEBUG` surface immediately rather than silently
175+
defaulting to `info`.
176+
157177
| Level | Description |
158178
|-------|-------------|
159179
| `debug` | Detailed debugging information, SQL queries, request/response details |
@@ -172,6 +192,10 @@ Controls the amount of log output. More verbose levels include all messages from
172192

173193
# Default info level
174194
./flapi --log-level info
195+
196+
# Set verbosity via environment variable (12-factor style)
197+
export FLAPI_LOG_LEVEL=debug
198+
./flapi
175199
```
176200

177201
> **Implementation:** `src/main.cpp` | **Tests:** `test/integration/test_mcp_methods.py` (logging/setLevel)

docs/CONFIG_REFERENCE.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,31 @@ LIMIT 100
142142

143143
---
144144

145+
### 1.4 12-factor checklist (environment variables)
146+
147+
flapi follows the [12-factor app](https://12factor.net/) principle of
148+
configuration via the environment for the bits a deployment artifact
149+
should not bake in.
150+
151+
| Env var | Read at | Effect | Precedence |
152+
|---------|---------|--------|------------|
153+
| `FLAPI_CONFIG` | startup | Path to `flapi.yaml` (fallback for `-c`) | CLI > env > `flapi.yaml` default |
154+
| `FLAPI_LOG_LEVEL` | startup | Log verbosity (fallback for `--log-level`) | CLI > env > `info` default; invalid values exit non-zero |
155+
| `FLAPI_CONFIG_SERVICE_TOKEN` | startup | Bearer token for the management API (fallback for `--config-service-token`) | CLI > env > auto-generate |
156+
| `FLAPI_NO_TELEMETRY` | startup | Disable PostHog telemetry (fallback for `--no-telemetry`) | CLI > env > config-file > enabled |
157+
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION` | startup, query time | S3 credentials (DuckDB `httpfs`) | env only |
158+
| `GOOGLE_APPLICATION_CREDENTIALS` / `GOOGLE_CLOUD_PROJECT` | startup, query time | GCS credentials (DuckDB `httpfs`) | env only |
159+
| `AZURE_STORAGE_CONNECTION_STRING` / `AZURE_STORAGE_ACCOUNT` / `AZURE_STORAGE_KEY` | startup, query time | Azure Blob credentials | env only |
160+
| `{{env.VARNAME}}` in YAML | startup (config parse) | Interpolated into any string field | requires `environment-whitelist` entry |
161+
162+
Secrets should always come from the environment, never from a config
163+
file checked into version control. When using the self-packaging
164+
feature (`flapi pack`), the default secret deny list refuses to
165+
bundle `*.env`, `secrets/*`, `*.pem`, and `*.key` files -- enforcing
166+
this same principle at packaging time.
167+
168+
---
169+
145170
## 2. Main Configuration (flapi.yaml)
146171

147172
The main configuration file defines global settings, connections, and server behavior.

src/main.cpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,30 @@ int main(int argc, char* argv[])
432432
int cmd_port = program.get<int>("--port");
433433
std::string log_level = program.get<std::string>("--log-level");
434434
bool validate_config = program.get<bool>("--validate-config");
435+
436+
// 12-factor env-var fallback (#47). Precedence:
437+
// CLI flag > env var > built-in default.
438+
// CLI wins because we only consult the env when the user didn't
439+
// pass the flag.
440+
if (!program.is_used("--config")) {
441+
if (const char* env = std::getenv("FLAPI_CONFIG"); env != nullptr && *env != '\0') {
442+
config_file = env;
443+
}
444+
}
445+
if (!program.is_used("--log-level")) {
446+
if (const char* env = std::getenv("FLAPI_LOG_LEVEL"); env != nullptr && *env != '\0') {
447+
log_level = env;
448+
}
449+
}
450+
// Validate log_level. Invalid values are an error, not a silent
451+
// fallback -- typos like FLAPI_LOG_LEVEL=DEBUG should surface
452+
// immediately, not run the server at the wrong verbosity.
453+
if (log_level != "debug" && log_level != "info" &&
454+
log_level != "warning" && log_level != "error") {
455+
std::cerr << "flapi: invalid log level '" << log_level
456+
<< "'; must be one of: debug, info, warning, error\n";
457+
return 1;
458+
}
435459
bool config_service_enabled = program.get<bool>("--config-service");
436460
std::string config_service_token = program.get<std::string>("--config-service-token");
437461
bool no_telemetry = program.get<bool>("--no-telemetry");
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""12-factor env-var precedence tests (issue #47).
2+
3+
Verifies that `FLAPI_CONFIG` and `FLAPI_LOG_LEVEL` work as documented:
4+
CLI flag > env var > built-in default.
5+
Plus: invalid `FLAPI_LOG_LEVEL` values are rejected with a clear
6+
single-line error, not silently coerced.
7+
8+
These tests build a tiny fixture config and invoke `flapi --validate-config`
9+
as a subprocess -- no HTTP server lifecycle needed.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import os
15+
import pathlib
16+
import subprocess
17+
import sys
18+
19+
import pytest
20+
21+
sys.path.insert(0, str(pathlib.Path(__file__).parent))
22+
from conftest import get_flapi_binary # noqa: E402
23+
24+
25+
pytestmark = pytest.mark.standalone_server
26+
27+
28+
def _flapi() -> pathlib.Path:
29+
return get_flapi_binary()
30+
31+
32+
def _write_minimal_config(root: pathlib.Path) -> pathlib.Path:
33+
"""A tiny config that validates cleanly (no endpoints required)."""
34+
(root / "sqls").mkdir(parents=True, exist_ok=True)
35+
config = root / "flapi.yaml"
36+
config.write_text(
37+
"project-name: env-override-test\n"
38+
"project-description: integration test fixture\n"
39+
"template:\n"
40+
" path: ./sqls\n"
41+
"connections: {}\n"
42+
"duckdb:\n"
43+
" access_mode: READ_WRITE\n"
44+
" threads: 1\n"
45+
" max_memory: 256MB\n"
46+
)
47+
return config
48+
49+
50+
def _run(cmd, env_overrides=None, timeout=30):
51+
"""Run flapi with a controlled env. Always strips FLAPI_* by default."""
52+
env = {k: v for k, v in os.environ.items() if not k.startswith("FLAPI_")}
53+
if env_overrides:
54+
env.update(env_overrides)
55+
return subprocess.run(
56+
cmd,
57+
check=False,
58+
capture_output=True,
59+
text=True,
60+
timeout=timeout,
61+
env=env,
62+
)
63+
64+
65+
def test_invalid_FLAPI_LOG_LEVEL_is_rejected(tmp_path: pathlib.Path):
66+
config = _write_minimal_config(tmp_path)
67+
res = _run(
68+
[str(_flapi()), "--validate-config", "-c", str(config)],
69+
env_overrides={"FLAPI_LOG_LEVEL": "verbose"},
70+
)
71+
assert res.returncode == 1
72+
combined = (res.stderr + res.stdout).lower()
73+
assert "invalid log level" in combined
74+
assert "verbose" in combined
75+
76+
77+
def test_valid_FLAPI_LOG_LEVEL_is_honoured(tmp_path: pathlib.Path):
78+
config = _write_minimal_config(tmp_path)
79+
res = _run(
80+
[str(_flapi()), "--validate-config", "-c", str(config)],
81+
env_overrides={"FLAPI_LOG_LEVEL": "debug"},
82+
)
83+
assert res.returncode == 0, (
84+
f"validate-config failed unexpectedly: "
85+
f"stdout={res.stdout} stderr={res.stderr}"
86+
)
87+
# Debug-level should emit the "ConfigLoader initialized" line that
88+
# info-level suppresses.
89+
combined = res.stdout + res.stderr
90+
assert "[DEBUG" in combined, "no DEBUG lines visible at FLAPI_LOG_LEVEL=debug"
91+
92+
93+
def test_CLI_log_level_wins_over_env(tmp_path: pathlib.Path):
94+
config = _write_minimal_config(tmp_path)
95+
res = _run(
96+
[str(_flapi()), "--validate-config", "-c", str(config),
97+
"--log-level", "error"],
98+
env_overrides={"FLAPI_LOG_LEVEL": "debug"},
99+
)
100+
assert res.returncode == 0, res.stderr
101+
combined = res.stdout + res.stderr
102+
# CLI said `error`, so no DEBUG lines should appear despite the env.
103+
assert "[DEBUG" not in combined, (
104+
"CLI --log-level should have suppressed debug output but didn't"
105+
)
106+
107+
108+
def test_FLAPI_CONFIG_used_when_no_c_flag(tmp_path: pathlib.Path):
109+
config = _write_minimal_config(tmp_path)
110+
# No `-c` flag -- the binary should pick FLAPI_CONFIG instead of the
111+
# default `flapi.yaml` in cwd.
112+
res = _run(
113+
[str(_flapi()), "--validate-config"],
114+
env_overrides={"FLAPI_CONFIG": str(config)},
115+
)
116+
assert res.returncode == 0, res.stderr
117+
# The config path should appear in the load message.
118+
assert str(config) in (res.stdout + res.stderr)
119+
120+
121+
def test_CLI_c_flag_wins_over_FLAPI_CONFIG(tmp_path: pathlib.Path):
122+
cli_config = _write_minimal_config(tmp_path / "cli")
123+
env_dir = tmp_path / "env"
124+
env_dir.mkdir()
125+
env_config = env_dir / "flapi.yaml"
126+
# Deliberately broken env config -- if it gets loaded, validate-config
127+
# will fail loudly. Since `-c` should win, it must not be touched.
128+
env_config.write_text("this: is not valid yaml: at all:\n - - - !!!")
129+
130+
res = _run(
131+
[str(_flapi()), "--validate-config", "-c", str(cli_config)],
132+
env_overrides={"FLAPI_CONFIG": str(env_config)},
133+
)
134+
assert res.returncode == 0, (
135+
f"-c was ignored in favour of FLAPI_CONFIG: "
136+
f"stdout={res.stdout} stderr={res.stderr}"
137+
)
138+
139+
140+
def test_default_config_path_unchanged_when_no_env(tmp_path: pathlib.Path, monkeypatch):
141+
"""If neither flag nor env is set, the default is still flapi.yaml."""
142+
_write_minimal_config(tmp_path)
143+
monkeypatch.chdir(tmp_path) # so the default `flapi.yaml` exists in cwd
144+
145+
res = _run([str(_flapi()), "--validate-config"])
146+
assert res.returncode == 0, (
147+
f"default flapi.yaml lookup failed: "
148+
f"stdout={res.stdout} stderr={res.stderr}"
149+
)

0 commit comments

Comments
 (0)