Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion docs/CLI_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`

---

Expand Down Expand Up @@ -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 |
Expand All @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions docs/CONFIG_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,30 @@ int main(int argc, char* argv[])
int cmd_port = program.get<int>("--port");
std::string log_level = program.get<std::string>("--log-level");
bool validate_config = program.get<bool>("--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<bool>("--config-service");
std::string config_service_token = program.get<std::string>("--config-service-token");
bool no_telemetry = program.get<bool>("--no-telemetry");
Expand Down
149 changes: 149 additions & 0 deletions test/integration/test_env_overrides.py
Original file line number Diff line number Diff line change
@@ -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}"
)
Loading