diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index d42ef51..9f81961 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -86,11 +86,17 @@ Specifies the path to the flAPI YAML configuration file. | Type | string (file path) | | Default | `flapi.yaml` | | Required | No | +| Environment variable | `FLAPI_CONFIG` | **Description:** 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. +**Precedence (highest wins):** +1. `-c` / `--config` CLI flag +2. `FLAPI_CONFIG` environment variable +3. Built-in default (`flapi.yaml` in the current working directory) + **Example:** ```bash @@ -100,11 +106,15 @@ The configuration file defines connections, endpoint directories, DuckDB setting # Specify a custom configuration file ./flapi -c production.yaml ./flapi --config /etc/flapi/config.yaml + +# Point at a config via environment variable (12-factor style) +export FLAPI_CONFIG=/etc/flapi/production.yaml +./flapi ``` **See also:** [Configuration Reference](./CONFIG_REFERENCE.md) for configuration file options. -> **Implementation:** `src/main.cpp`, `src/config_manager.cpp` | **Tests:** `test/cpp/config_manager_test.cpp` +> **Implementation:** `src/main.cpp`, `src/config_manager.cpp` | **Tests:** `test/cpp/config_manager_test.cpp`, `test/integration/test_env_overrides.py` --- @@ -149,11 +159,21 @@ Sets the logging verbosity level. | Default | `info` | | Required | No | | Valid values | `debug`, `info`, `warning`, `error` | +| Environment variable | `FLAPI_LOG_LEVEL` | **Description:** Controls the amount of log output. More verbose levels include all messages from less verbose levels. +**Precedence (highest wins):** +1. `--log-level` CLI flag +2. `FLAPI_LOG_LEVEL` environment variable +3. Built-in default (`info`) + +Invalid values cause flapi to exit with a single-line error -- typos +like `FLAPI_LOG_LEVEL=DEBUG` surface immediately rather than silently +defaulting to `info`. + | Level | Description | |-------|-------------| | `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 # Default info level ./flapi --log-level info + +# Set verbosity via environment variable (12-factor style) +export FLAPI_LOG_LEVEL=debug +./flapi ``` > **Implementation:** `src/main.cpp` | **Tests:** `test/integration/test_mcp_methods.py` (logging/setLevel) diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 15c3c30..55e3b50 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -142,6 +142,31 @@ LIMIT 100 --- +### 1.4 12-factor checklist (environment variables) + +flapi follows the [12-factor app](https://12factor.net/) principle of +configuration via the environment for the bits a deployment artifact +should not bake in. + +| Env var | Read at | Effect | Precedence | +|---------|---------|--------|------------| +| `FLAPI_CONFIG` | startup | Path to `flapi.yaml` (fallback for `-c`) | CLI > env > `flapi.yaml` default | +| `FLAPI_LOG_LEVEL` | startup | Log verbosity (fallback for `--log-level`) | CLI > env > `info` default; invalid values exit non-zero | +| `FLAPI_CONFIG_SERVICE_TOKEN` | startup | Bearer token for the management API (fallback for `--config-service-token`) | CLI > env > auto-generate | +| `FLAPI_NO_TELEMETRY` | startup | Disable PostHog telemetry (fallback for `--no-telemetry`) | CLI > env > config-file > enabled | +| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION` | startup, query time | S3 credentials (DuckDB `httpfs`) | env only | +| `GOOGLE_APPLICATION_CREDENTIALS` / `GOOGLE_CLOUD_PROJECT` | startup, query time | GCS credentials (DuckDB `httpfs`) | env only | +| `AZURE_STORAGE_CONNECTION_STRING` / `AZURE_STORAGE_ACCOUNT` / `AZURE_STORAGE_KEY` | startup, query time | Azure Blob credentials | env only | +| `{{env.VARNAME}}` in YAML | startup (config parse) | Interpolated into any string field | requires `environment-whitelist` entry | + +Secrets should always come from the environment, never from a config +file checked into version control. When using the self-packaging +feature (`flapi pack`), the default secret deny list refuses to +bundle `*.env`, `secrets/*`, `*.pem`, and `*.key` files -- enforcing +this same principle at packaging time. + +--- + ## 2. Main Configuration (flapi.yaml) The main configuration file defines global settings, connections, and server behavior. diff --git a/src/main.cpp b/src/main.cpp index 1bb7019..d661a8b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -432,6 +432,30 @@ int main(int argc, char* argv[]) int cmd_port = program.get("--port"); std::string log_level = program.get("--log-level"); bool validate_config = program.get("--validate-config"); + + // 12-factor env-var fallback (#47). Precedence: + // CLI flag > env var > built-in default. + // CLI wins because we only consult the env when the user didn't + // pass the flag. + if (!program.is_used("--config")) { + if (const char* env = std::getenv("FLAPI_CONFIG"); env != nullptr && *env != '\0') { + config_file = env; + } + } + if (!program.is_used("--log-level")) { + if (const char* env = std::getenv("FLAPI_LOG_LEVEL"); env != nullptr && *env != '\0') { + log_level = env; + } + } + // Validate log_level. Invalid values are an error, not a silent + // fallback -- typos like FLAPI_LOG_LEVEL=DEBUG should surface + // immediately, not run the server at the wrong verbosity. + if (log_level != "debug" && log_level != "info" && + log_level != "warning" && log_level != "error") { + std::cerr << "flapi: invalid log level '" << log_level + << "'; must be one of: debug, info, warning, error\n"; + return 1; + } bool config_service_enabled = program.get("--config-service"); std::string config_service_token = program.get("--config-service-token"); bool no_telemetry = program.get("--no-telemetry"); diff --git a/test/integration/test_env_overrides.py b/test/integration/test_env_overrides.py new file mode 100644 index 0000000..388f8fe --- /dev/null +++ b/test/integration/test_env_overrides.py @@ -0,0 +1,149 @@ +"""12-factor env-var precedence tests (issue #47). + +Verifies that `FLAPI_CONFIG` and `FLAPI_LOG_LEVEL` work as documented: + CLI flag > env var > built-in default. +Plus: invalid `FLAPI_LOG_LEVEL` values are rejected with a clear +single-line error, not silently coerced. + +These tests build a tiny fixture config and invoke `flapi --validate-config` +as a subprocess -- no HTTP server lifecycle needed. +""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import sys + +import pytest + +sys.path.insert(0, str(pathlib.Path(__file__).parent)) +from conftest import get_flapi_binary # noqa: E402 + + +pytestmark = pytest.mark.standalone_server + + +def _flapi() -> pathlib.Path: + return get_flapi_binary() + + +def _write_minimal_config(root: pathlib.Path) -> pathlib.Path: + """A tiny config that validates cleanly (no endpoints required).""" + (root / "sqls").mkdir(parents=True, exist_ok=True) + config = root / "flapi.yaml" + config.write_text( + "project-name: env-override-test\n" + "project-description: integration test fixture\n" + "template:\n" + " path: ./sqls\n" + "connections: {}\n" + "duckdb:\n" + " access_mode: READ_WRITE\n" + " threads: 1\n" + " max_memory: 256MB\n" + ) + return config + + +def _run(cmd, env_overrides=None, timeout=30): + """Run flapi with a controlled env. Always strips FLAPI_* by default.""" + env = {k: v for k, v in os.environ.items() if not k.startswith("FLAPI_")} + if env_overrides: + env.update(env_overrides) + return subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + + +def test_invalid_FLAPI_LOG_LEVEL_is_rejected(tmp_path: pathlib.Path): + config = _write_minimal_config(tmp_path) + res = _run( + [str(_flapi()), "--validate-config", "-c", str(config)], + env_overrides={"FLAPI_LOG_LEVEL": "verbose"}, + ) + assert res.returncode == 1 + combined = (res.stderr + res.stdout).lower() + assert "invalid log level" in combined + assert "verbose" in combined + + +def test_valid_FLAPI_LOG_LEVEL_is_honoured(tmp_path: pathlib.Path): + config = _write_minimal_config(tmp_path) + res = _run( + [str(_flapi()), "--validate-config", "-c", str(config)], + env_overrides={"FLAPI_LOG_LEVEL": "debug"}, + ) + assert res.returncode == 0, ( + f"validate-config failed unexpectedly: " + f"stdout={res.stdout} stderr={res.stderr}" + ) + # Debug-level should emit the "ConfigLoader initialized" line that + # info-level suppresses. + combined = res.stdout + res.stderr + assert "[DEBUG" in combined, "no DEBUG lines visible at FLAPI_LOG_LEVEL=debug" + + +def test_CLI_log_level_wins_over_env(tmp_path: pathlib.Path): + config = _write_minimal_config(tmp_path) + res = _run( + [str(_flapi()), "--validate-config", "-c", str(config), + "--log-level", "error"], + env_overrides={"FLAPI_LOG_LEVEL": "debug"}, + ) + assert res.returncode == 0, res.stderr + combined = res.stdout + res.stderr + # CLI said `error`, so no DEBUG lines should appear despite the env. + assert "[DEBUG" not in combined, ( + "CLI --log-level should have suppressed debug output but didn't" + ) + + +def test_FLAPI_CONFIG_used_when_no_c_flag(tmp_path: pathlib.Path): + config = _write_minimal_config(tmp_path) + # No `-c` flag -- the binary should pick FLAPI_CONFIG instead of the + # default `flapi.yaml` in cwd. + res = _run( + [str(_flapi()), "--validate-config"], + env_overrides={"FLAPI_CONFIG": str(config)}, + ) + assert res.returncode == 0, res.stderr + # The config path should appear in the load message. + assert str(config) in (res.stdout + res.stderr) + + +def test_CLI_c_flag_wins_over_FLAPI_CONFIG(tmp_path: pathlib.Path): + cli_config = _write_minimal_config(tmp_path / "cli") + env_dir = tmp_path / "env" + env_dir.mkdir() + env_config = env_dir / "flapi.yaml" + # Deliberately broken env config -- if it gets loaded, validate-config + # will fail loudly. Since `-c` should win, it must not be touched. + env_config.write_text("this: is not valid yaml: at all:\n - - - !!!") + + res = _run( + [str(_flapi()), "--validate-config", "-c", str(cli_config)], + env_overrides={"FLAPI_CONFIG": str(env_config)}, + ) + assert res.returncode == 0, ( + f"-c was ignored in favour of FLAPI_CONFIG: " + f"stdout={res.stdout} stderr={res.stderr}" + ) + + +def test_default_config_path_unchanged_when_no_env(tmp_path: pathlib.Path, monkeypatch): + """If neither flag nor env is set, the default is still flapi.yaml.""" + _write_minimal_config(tmp_path) + monkeypatch.chdir(tmp_path) # so the default `flapi.yaml` exists in cwd + + res = _run([str(_flapi()), "--validate-config"]) + assert res.returncode == 0, ( + f"default flapi.yaml lookup failed: " + f"stdout={res.stdout} stderr={res.stderr}" + )