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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ AGCTL_SERVICES__ORDER_SERVICE__BASE_URL=http://order-svc:8080
`agctl config init` writes exactly this file — shown here for reference and for
browsing on GitHub without installing. It has **concrete localhost values and no
required env vars**, so `agctl config validate` passes as-is. (The production
version is the same file with secrets/hosts moved into `${...}` and sourced from a
version is the same file with secrets/hosts moved into `${...}` and loaded from a
`.env` — see the note after it.)

```yaml
Expand Down Expand Up @@ -400,10 +400,12 @@ logs:
> **Note:** `charge-payment` uses the `${PAYMENT_SERVICE_TOKEN:-change-me}` form —
> an *optional* env var with a literal default — so `config validate` passes even
> with nothing exported. For production, `export PAYMENT_SERVICE_TOKEN=<real token>`
> (or move the whole value into `${...}` sourced from a `.env`); see below.
> (or move the whole value into `${...}` loaded from a `.env`); see below.

**Moving to environment-driven config** — replace the concrete values above with
`${...}` and source them from a `.env` (agctl resolves them at load time):
`${...}` and put them in a `.env`. agctl auto-loads the `.env` next to the resolved
`agctl.yaml` at config load time (real env wins, so CI/prod can override committed
defaults); point at a different location with `--env-file <path>` or `AGCTL_ENV_FILE`:

```bash
# .env — never commit real secrets
Expand Down
9 changes: 8 additions & 1 deletion agctl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,20 @@ def _ensure_utf8_streams() -> None:
@click.version_option(version=__version__, message="agctl %(version)s")
@click.option("--config", "config_path", default=None, help="Path to agctl.yaml")
@click.option("--overlay", "overlay_paths", multiple=True, help="Overlay config fragment (repeatable; later wins)")
@click.option("--env-file", "env_file", default=None, help="Path to .env file (default: .env next to agctl.yaml)")
@click.pass_context
def cli(ctx: click.Context, config_path: str | None, overlay_paths: tuple[str, ...]) -> None:
def cli(
ctx: click.Context,
config_path: str | None,
overlay_paths: tuple[str, ...],
env_file: str | None,
) -> None:
"""agctl — agent-facing CLI harness for testing distributed systems."""
_ensure_utf8_streams()
ctx.ensure_object(dict)
ctx.obj["config_path"] = config_path
ctx.obj["overlay_paths"] = tuple(overlay_paths) or None
ctx.obj["env_file"] = env_file


@cli.group(name="config")
Expand Down
6 changes: 4 additions & 2 deletions agctl/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,11 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:


def load_config_or_raise(
config_path: str | None = None, overlay_paths: list[str] | None = None
config_path: str | None = None,
overlay_paths: list[str] | None = None,
env_file: str | None = None,
):
"""Load config, letting ConfigError propagate to the envelope wrapper (exit 2)."""
from .config import load_config

return load_config(config_path, overlays=overlay_paths)
return load_config(config_path, overlays=overlay_paths, env_file=env_file)
8 changes: 6 additions & 2 deletions agctl/commands/check_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ def _check_ready_core(
all_services: bool,
timeout: float | None,
overlay_paths: list[str] | None = None,
env_file: str | None = None,
) -> dict:
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths)
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths, env_file=env_file)
return _check_ready_with_config(cfg, service, all_services, timeout)


Expand All @@ -100,9 +101,11 @@ def _check_ready_with_config(
@click.option("--all", "all_services", is_flag=True, default=False, help="Check all services")
@click.option("--timeout", "timeout", type=float, default=None)
@click.option("--config", "config_path", default=None)
@click.option("--env-file", "env_file", default=None, help="Path to .env file (default: .env next to agctl.yaml)")
@click.pass_context
def check_ready(
ctx: click.Context,
env_file: str | None,
service: str | None,
all_services: bool,
timeout: float | None,
Expand All @@ -111,7 +114,8 @@ def check_ready(
"""Probe configured service health endpoints."""
path = config_path or (ctx.obj.get("config_path") if ctx.obj else None)
ovs = ctx.obj.get("overlay_paths") if ctx.obj else None
_check_ready_envelope(path, service, all_services, timeout, overlay_paths=list(ovs) if ovs else None)
env_file = env_file or (ctx.obj.get("env_file") if ctx.obj else None)
_check_ready_envelope(path, service, all_services, timeout, overlay_paths=list(ovs) if ovs else None, env_file=env_file)


_check_ready_envelope = envelope("check.ready")(_check_ready_core)
12 changes: 8 additions & 4 deletions agctl/commands/config_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,17 @@ def _emit_config_error(command: str, err: ConfigError, start: float) -> None:
@click.command("validate")
@click.option("--config", "config_path", default=None)
@click.option("--overlay", "overlay_paths", multiple=True, default=None)
@click.option("--env-file", "env_file", default=None, help="Path to .env file (default: .env next to agctl.yaml)")
@click.pass_context
def config_validate(ctx: click.Context, config_path: str | None, overlay_paths: tuple[str, ...] | None) -> None:
def config_validate(ctx: click.Context, config_path: str | None, overlay_paths: tuple[str, ...] | None, env_file: str | None) -> None:
"""Parse and validate agctl.yaml. Exit 2 on any error."""
start = time.monotonic()
path = config_path or ctx.obj.get("config_path")
# Resolve overlay paths: own option takes precedence over ctx.obj
ovs = tuple(overlay_paths) or ctx.obj.get("overlay_paths")
resolved_env_file = env_file or ctx.obj.get("env_file")
try:
composed = compose_config(path, list(ovs) if ovs else None)
composed = compose_config(path, list(ovs) if ovs else None, env_file=resolved_env_file)
except ConfigError as err:
_emit_config_error("config.validate", err, start)
raise SystemExit(2)
Expand Down Expand Up @@ -186,16 +188,18 @@ def config_validate(ctx: click.Context, config_path: str | None, overlay_paths:
@click.command("show")
@click.option("--config", "config_path", default=None)
@click.option("--overlay", "overlay_paths", multiple=True, default=None)
@click.option("--env-file", "env_file", default=None, help="Path to .env file (default: .env next to agctl.yaml)")
@click.option("--unmask", is_flag=True, default=False)
@click.pass_context
def config_show(ctx: click.Context, config_path: str | None, overlay_paths: tuple[str, ...] | None, unmask: bool) -> None:
def config_show(ctx: click.Context, config_path: str | None, overlay_paths: tuple[str, ...] | None, env_file: str | None, unmask: bool) -> None:
"""Dump the resolved config as JSON, secrets masked."""
start = time.monotonic()
path = config_path or ctx.obj.get("config_path")
# Resolve overlay paths: own option takes precedence over ctx.obj
ovs = tuple(overlay_paths) or ctx.obj.get("overlay_paths")
resolved_env_file = env_file or ctx.obj.get("env_file")
try:
composed = compose_config(path, list(ovs) if ovs else None)
composed = compose_config(path, list(ovs) if ovs else None, env_file=resolved_env_file)
except ConfigError as err:
_emit_config_error("config.show", err, start)
raise SystemExit(2)
Expand Down
28 changes: 19 additions & 9 deletions agctl/commands/db_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,9 @@ def _db_query_core(
param: tuple[str, ...],
connection: str | None,
overlay_paths: list[str] | None = None,
env_file: str | None = None,
) -> dict:
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths)
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths, env_file=env_file)
sql_text, params, conn_name = resolve_db_request(
cfg,
template=template,
Expand Down Expand Up @@ -198,7 +199,8 @@ def db_query(
"""Run a DB query and return the rows."""
config_path = ctx.obj.get("config_path") if ctx.obj else None
ovs = ctx.obj.get("overlay_paths") if ctx.obj else None
_db_query_envelope(config_path, template, sql, param, connection, overlay_paths=list(ovs) if ovs else None)
env_file = ctx.obj.get("env_file") if ctx.obj else None
_db_query_envelope(config_path, template, sql, param, connection, overlay_paths=list(ovs) if ovs else None, env_file=env_file)


_db_query_envelope = envelope("db.query")(_db_query_core)
Expand Down Expand Up @@ -246,8 +248,9 @@ def _db_assert_core(
equals: str | None,
assertion: str | None,
overlay_paths: list[str] | None = None,
env_file: str | None = None,
) -> dict:
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths)
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths, env_file=env_file)
sql_text, params, conn_name = resolve_db_request(
cfg,
template=template,
Expand Down Expand Up @@ -372,6 +375,7 @@ def db_assert(
"""Run a DB query and assert on its result."""
config_path = ctx.obj.get("config_path") if ctx.obj else None
ovs = ctx.obj.get("overlay_paths") if ctx.obj else None
env_file = ctx.obj.get("env_file") if ctx.obj else None
_db_assert_envelope(
config_path,
template,
Expand All @@ -384,6 +388,7 @@ def db_assert(
equals,
assertion,
overlay_paths=list(ovs) if ovs else None,
env_file=env_file,
)


Expand All @@ -403,9 +408,10 @@ def _db_execute_core(
connection: str | None,
write: bool,
overlay_paths: list[str] | None = None,
env_file: str | None = None,
) -> dict:
# Step 1: Load config
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths)
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths, env_file=env_file)

# Step 2: Resolve SQL, params, and connection (enforces template XOR sql,
# neither-given, unknown template, unknown connection)
Expand Down Expand Up @@ -471,7 +477,8 @@ def db_execute(
"""Execute a write SQL statement and return affected row count."""
config_path = ctx.obj.get("config_path") if ctx.obj else None
ovs = ctx.obj.get("overlay_paths") if ctx.obj else None
_db_execute_envelope(config_path, template, sql, param, connection, write, overlay_paths=list(ovs) if ovs else None)
env_file = ctx.obj.get("env_file") if ctx.obj else None
_db_execute_envelope(config_path, template, sql, param, connection, write, overlay_paths=list(ovs) if ovs else None, env_file=env_file)


_db_execute_envelope = envelope("db.execute")(_db_execute_core)
Expand Down Expand Up @@ -531,9 +538,10 @@ def _db_schema_tables_core(
connection: str | None,
schema: str | None,
overlay_paths: list[str] | None = None,
env_file: str | None = None,
) -> dict:
"""Level 1: list relations (tables/views) visible in the connection."""
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths)
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths, env_file=env_file)
# Schema has no template, so resolve inline with no template_connection.
conn_name = resolve_connection_name(cfg, connection_name=connection)
raw = _probe_and_describe(
Expand All @@ -555,9 +563,10 @@ def _db_schema_table_core(
schema: str | None,
table: str,
overlay_paths: list[str] | None = None,
env_file: str | None = None,
) -> dict:
"""Level 2: return one relation's columns + keys, disambiguating matches."""
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths)
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths, env_file=env_file)
conn_name = resolve_connection_name(cfg, connection_name=connection)
raw = _probe_and_describe(
cfg.database.connections[conn_name], table=table, schema=schema
Expand Down Expand Up @@ -619,11 +628,12 @@ def db_schema(
"""Discover live DB schema: list relations, then columns/keys for one."""
config_path = ctx.obj.get("config_path") if ctx.obj else None
ovs = ctx.obj.get("overlay_paths") if ctx.obj else None
env_file = ctx.obj.get("env_file") if ctx.obj else None
overlay_paths = list(ovs) if ovs else None
if table is None:
_db_schema_tables_envelope(config_path, connection, schema, overlay_paths=overlay_paths)
_db_schema_tables_envelope(config_path, connection, schema, overlay_paths=overlay_paths, env_file=env_file)
else:
_db_schema_table_envelope(config_path, connection, schema, table, overlay_paths=overlay_paths)
_db_schema_table_envelope(config_path, connection, schema, table, overlay_paths=overlay_paths, env_file=env_file)


_db_schema_tables_envelope = envelope("db.schema.tables")(_db_schema_tables_core)
Expand Down
27 changes: 15 additions & 12 deletions agctl/commands/discover_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,8 @@ def _mock_kafka_example(reactor) -> str:
# --------------------------------------------------------------------------- #


def _summary_core(config_path: str | None, overlay_paths: list[str] | None = None) -> dict:
cfg = load_config_or_raise(config_path, overlay_paths)
def _summary_core(config_path: str | None, overlay_paths: list[str] | None = None, env_file: str | None = None) -> dict:
cfg = load_config_or_raise(config_path, overlay_paths, env_file=env_file)
return {
"services": len(cfg.services),
"http_templates": len(cfg.templates),
Expand All @@ -213,8 +213,8 @@ def _summary_core(config_path: str | None, overlay_paths: list[str] | None = Non
_summary_envelope = envelope("discover.summary")(_summary_core)


def _category_core(config_path: str | None, category: str, overlay_paths: list[str] | None = None) -> dict:
cfg = load_config_or_raise(config_path, overlay_paths)
def _category_core(config_path: str | None, category: str, overlay_paths: list[str] | None = None, env_file: str | None = None) -> dict:
cfg = load_config_or_raise(config_path, overlay_paths, env_file=env_file)
if category not in _VALID_CATEGORIES:
raise ConfigError(f"Unknown category: {category}", {"category": category})

Expand Down Expand Up @@ -287,8 +287,8 @@ def _category_core(config_path: str | None, category: str, overlay_paths: list[s
_category_envelope = envelope("discover.category")(_category_core)


def _item_core(config_path: str | None, category: str, name: str, overlay_paths: list[str] | None = None) -> dict:
cfg = load_config_or_raise(config_path, overlay_paths)
def _item_core(config_path: str | None, category: str, name: str, overlay_paths: list[str] | None = None, env_file: str | None = None) -> dict:
cfg = load_config_or_raise(config_path, overlay_paths, env_file=env_file)
if category not in _VALID_CATEGORIES:
raise ConfigError(f"Unknown category: {category}", {"category": category})

Expand Down Expand Up @@ -530,8 +530,8 @@ def _item_core(config_path: str | None, category: str, name: str, overlay_paths:
_item_envelope = envelope("discover.item")(_item_core)


def _search_core(config_path: str | None, term: str, overlay_paths: list[str] | None = None) -> dict:
cfg = load_config_or_raise(config_path, overlay_paths)
def _search_core(config_path: str | None, term: str, overlay_paths: list[str] | None = None, env_file: str | None = None) -> dict:
cfg = load_config_or_raise(config_path, overlay_paths, env_file=env_file)
needle = term.lower()

def _matches(haystack: str | None) -> bool:
Expand Down Expand Up @@ -673,9 +673,11 @@ def _emit_argument_error(message: str) -> None:
@click.option("--search", "search", default=None, help="Substring to search for")
@click.option("--config", "config_path", default=None, help="Path to agctl.yaml")
@click.option("--overlay", "overlay_paths", multiple=True, default=None, help="Overlay config paths")
@click.option("--env-file", "env_file", default=None, help="Path to .env file (default: .env next to agctl.yaml)")
@click.pass_context
def discover(
ctx: click.Context,
env_file: str | None,
category: str | None,
name: str | None,
search: str | None,
Expand All @@ -686,6 +688,7 @@ def discover(
resolved_config = config_path or (ctx.obj.get("config_path") if ctx.obj else None)
ovs = tuple(overlay_paths) or (ctx.obj.get("overlay_paths") if ctx.obj else None)
resolved_overlay = list(ovs) if ovs else None
env_file = env_file or (ctx.obj.get("env_file") if ctx.obj else None)

# Mutual exclusion: --category + --search together is an error.
if category is not None and search is not None:
Expand All @@ -697,16 +700,16 @@ def discover(
return

if search is not None:
_search_envelope(resolved_config, search, resolved_overlay)
_search_envelope(resolved_config, search, resolved_overlay, env_file=env_file)
return

if category is not None and name is None:
_category_envelope(resolved_config, category, resolved_overlay)
_category_envelope(resolved_config, category, resolved_overlay, env_file=env_file)
return

if category is not None and name is not None:
_item_envelope(resolved_config, category, name, resolved_overlay)
_item_envelope(resolved_config, category, name, resolved_overlay, env_file=env_file)
return

# No flags — summary.
_summary_envelope(resolved_config, resolved_overlay)
_summary_envelope(resolved_config, resolved_overlay, env_file=env_file)
8 changes: 6 additions & 2 deletions agctl/commands/grpc_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ def _grpc_healthcheck_core(
service: str | None,
all_: bool,
overlay_paths: list[str] | None = None,
env_file: str | None = None,
) -> dict:
"""Core gRPC healthcheck logic.

Expand All @@ -383,7 +384,7 @@ def _grpc_healthcheck_core(
Raises:
ConfigError: If target is unknown.
"""
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths)
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths, env_file=env_file)

# Select targets: if all_ OR neither target given → all targets; else [target]
if all_ or target is None:
Expand Down Expand Up @@ -449,13 +450,15 @@ def grpc_healthcheck(
"""
config_path = ctx.obj.get("config_path") if ctx.obj else None
ovs = ctx.obj.get("overlay_paths") if ctx.obj else None
env_file = ctx.obj.get("env_file") if ctx.obj else None

_grpc_healthcheck_envelope(
config_path,
target,
service,
all_,
overlay_paths=list(ovs) if ovs else None,
env_file=env_file,
)


Expand Down Expand Up @@ -601,14 +604,15 @@ def grpc_call(
"""
config_path = ctx.obj.get("config_path") if ctx.obj else None
ovs = ctx.obj.get("overlay_paths") if ctx.obj else None
env_file = ctx.obj.get("env_file") if ctx.obj else None
start = time.monotonic()

# Step 1: Resolve EXACTLY ONCE — config load, target/template resolution,
# client build, find_method, and call-type detection all happen here. The
# resulting context is handed to both the single-result path
# (``_grpc_call_core``) and the streaming path; neither re-resolves.
try:
cfg = load_config_or_raise(config_path, overlay_paths=list(ovs) if ovs else None)
cfg = load_config_or_raise(config_path, overlay_paths=list(ovs) if ovs else None, env_file=env_file)
resolved = _resolve_grpc_call(
cfg,
target_name=target,
Expand Down
Loading
Loading