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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Temper is an HTTP server with an OData API. Agents talk to it directly, through

```bash
temper serve --port 3000 # HTTP server, OData API, Observe UI
temper serve --bind 127.0.0.1 # loopback only; default binds all interfaces (env: TEMPER_BIND_ADDR)
temper decide --port 3000 # interactive review of pending governance decisions
```

Expand Down
145 changes: 44 additions & 101 deletions crates/temper-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,16 @@ enum Commands {
/// Port to listen on
#[arg(short, long, default_value = "3000")]
port: u16,
/// IP address to bind the API to. The default, all interfaces, is
/// unchanged; `--bind 127.0.0.1` keeps Temper loopback-only, for a
/// server reached through an SSH tunnel or a local proxy. The Observe
/// UI and the local sandbox follow it.
///
/// Only `0.0.0.0` and `127.0.0.1` are accepted: Temper's own
/// components reach the API at 127.0.0.1, so any other address would
/// break them. If omitted, `TEMPER_BIND_ADDR` can select the address.
#[arg(long, default_value = "0.0.0.0")]
bind: String,
/// Storage backend (`postgres`, `turso`, `redis`).
///
/// If omitted, `TEMPER_EVENT_STORE` can select the backend. When neither
Expand Down Expand Up @@ -256,51 +266,6 @@ enum Commands {
},
}

fn resolve_storage_backend(
cli_storage: StorageBackend,
storage_explicit: bool,
env_storage: Option<&str>,
) -> Result<StorageBackend, String> {
if storage_explicit {
return Ok(cli_storage);
}

let Some(value) = env_storage.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(cli_storage);
};

match value.to_ascii_lowercase().as_str() {
"postgres" | "pg" => Ok(StorageBackend::Postgres),
"turso" | "libsql" => Ok(StorageBackend::Turso),
"redis" => Ok(StorageBackend::Redis),
other => Err(format!(
"TEMPER_EVENT_STORE must be one of postgres, turso, redis; got {other:?}"
)),
}
}

fn resolve_actor_runtime_backend(
cli_runtime: ActorRuntimeBackend,
runtime_explicit: bool,
env_runtime: Option<&str>,
) -> Result<ActorRuntimeBackend, String> {
if runtime_explicit {
return Ok(cli_runtime);
}

let Some(value) = env_runtime.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(cli_runtime);
};

match value.to_ascii_lowercase().as_str() {
"legacy" | "in-memory" | "memory" => Ok(ActorRuntimeBackend::Legacy),
"postgres" | "pg" => Ok(ActorRuntimeBackend::Postgres),
other => Err(format!(
"TEMPER_ACTOR_RUNTIME must be one of legacy, postgres; got {other:?}"
)),
}
}

fn resolve_tokio_thread_stack_bytes(env_value: Option<&str>) -> Result<usize, String> {
let Some(value) = env_value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(DEFAULT_TOKIO_THREAD_STACK_BYTES);
Expand Down Expand Up @@ -392,6 +357,7 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> {
Commands::MigratePredicates { paths, check } => migrate_predicates::run(&paths, check)?,
Commands::Serve {
port,
bind,
storage,
actor_runtime,
actor_backed_type,
Expand All @@ -402,20 +368,27 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> {
verify_subprocess,
discord_bot_token,
} => {
let storage_explicit =
std::env::args().any(|arg| arg == "--storage" || arg.starts_with("--storage="));
let bind = serve::options::resolve_bind_addr(
&bind,
serve::options::flag_given("--bind"),
std::env::var("TEMPER_BIND_ADDR").ok().as_deref(),
)
.map_err(|err| anyhow::anyhow!(err))?;
let storage_explicit = serve::options::flag_given("--storage");
let storage_env = std::env::var("TEMPER_EVENT_STORE").ok();
let storage_config_explicit = storage_explicit
|| storage_env
.as_deref()
.is_some_and(|value| !value.trim().is_empty());
let storage =
resolve_storage_backend(storage, storage_explicit, storage_env.as_deref())
.map_err(|err| anyhow::anyhow!(err))?;
let actor_runtime_explicit = std::env::args()
.any(|arg| arg == "--actor-runtime" || arg.starts_with("--actor-runtime="));
let storage = serve::options::resolve_storage_backend(
storage,
storage_explicit,
storage_env.as_deref(),
)
.map_err(|err| anyhow::anyhow!(err))?;
let actor_runtime_explicit = serve::options::flag_given("--actor-runtime");
let actor_runtime_env = std::env::var("TEMPER_ACTOR_RUNTIME").ok();
let actor_runtime = resolve_actor_runtime_backend(
let actor_runtime = serve::options::resolve_actor_runtime_backend(
actor_runtime,
actor_runtime_explicit,
actor_runtime_env.as_deref(),
Expand Down Expand Up @@ -450,6 +423,7 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> {
let discord_token =
discord_bot_token.or_else(|| std::env::var("DISCORD_BOT_TOKEN").ok()); // determinism-ok: read once at startup
serve::run(
bind,
port,
apps,
os_app_installs,
Expand Down Expand Up @@ -654,6 +628,23 @@ mod tests {
}
}

#[test]
fn test_cli_parse_serve_bind() {
let cli = Cli::parse_from(["temper", "serve", "--bind", "127.0.0.1", "--port", "3300"]);
match cli.command {
Commands::Serve { bind, port, .. } => {
assert_eq!(bind, "127.0.0.1");
assert_eq!(port, 3300);
}
_ => panic!("expected Serve command"),
}
let cli = Cli::parse_from(["temper", "serve"]);
match cli.command {
Commands::Serve { bind, .. } => assert_eq!(bind, "0.0.0.0", "the default is unchanged"),
_ => panic!("expected Serve command"),
}
}

#[test]
fn test_cli_parse_serve_with_specs() {
let cli = Cli::parse_from([
Expand Down Expand Up @@ -743,54 +734,6 @@ mod tests {
}
}

#[test]
fn storage_backend_env_is_used_when_cli_storage_is_not_explicit() {
assert_eq!(
resolve_storage_backend(StorageBackend::Turso, false, Some("postgres"))
.expect("postgres storage env should parse"),
StorageBackend::Postgres
);
assert_eq!(
resolve_storage_backend(StorageBackend::Turso, false, Some("redis"))
.expect("redis storage env should parse"),
StorageBackend::Redis
);
assert_eq!(
resolve_storage_backend(StorageBackend::Turso, true, Some("postgres"))
.expect("explicit CLI storage should win"),
StorageBackend::Turso
);
}

#[test]
fn storage_backend_env_rejects_unknown_values() {
let error = resolve_storage_backend(StorageBackend::Turso, false, Some("sqlite"))
.expect_err("unknown storage backend should fail");
assert!(error.contains("TEMPER_EVENT_STORE"));
}

#[test]
fn actor_runtime_env_is_used_when_cli_runtime_is_not_explicit() {
assert_eq!(
resolve_actor_runtime_backend(ActorRuntimeBackend::Legacy, false, Some("postgres"))
.expect("postgres actor runtime env should parse"),
ActorRuntimeBackend::Postgres
);
assert_eq!(
resolve_actor_runtime_backend(ActorRuntimeBackend::Postgres, true, Some("legacy"))
.expect("explicit CLI actor runtime should win"),
ActorRuntimeBackend::Postgres
);
}

#[test]
fn actor_runtime_env_rejects_unknown_values() {
let error =
resolve_actor_runtime_backend(ActorRuntimeBackend::Legacy, false, Some("sqlite"))
.expect_err("unknown actor runtime should fail");
assert!(error.contains("TEMPER_ACTOR_RUNTIME"));
}

#[test]
fn tokio_thread_stack_defaults_and_accepts_override() {
assert_eq!(
Expand Down
Loading
Loading