Coverage for rdak/config.py: 92%
94 statements
« prev ^ index » next coverage.py v7.6.0, created at 2026-08-03 16:46 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2026-08-03 16:46 +0000
1# SPDX-License-Identifier: GPL-2.0-or-later
2# © 2026, Ansgar 🙀 <ansgar@debian.org>
4"""Configuration handling for the rdak client.
6The configuration is a TOML file describing one or more named *instances*
7(e.g. the main archive and the security archive). Each instance fully
8specifies how to reach a dak RPC server *and* which token to authenticate
9with, so a token is never combined with a connection it was not bound to.
10There are deliberately no command-line overrides for connection or token
11settings.
12"""
14import os
15import subprocess
16import tomllib
17from pathlib import Path
19from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
21from rdak.errors import CliError
24def _expand(path: str) -> Path:
25 return Path(path).expanduser()
28class InstanceConfig(BaseModel):
29 """Connection and authentication settings for one dak RPC server."""
31 model_config = ConfigDict(extra="forbid")
33 # `unix:/path/to.sock` for a local socket, or `host:port` for TCP.
34 address: str
35 # Use TLS. Always on for TCP; for a unix socket it defaults off (local
36 # channel credentials) and can be enabled to run TLS over the socket
37 tls: bool = False
38 # TLS server name override - the certificate name to verify. Required for
39 # TLS over a unix socket (a socket path is not a host name) and useful when
40 # reaching a TCP server through a forwarded `localhost` port.
41 server_name: str | None = None
42 # Path to a CA bundle for a private CA; omitted means the system store.
43 ca_cert: str | None = None
45 # Exactly one token source must be set.
46 token: str | None = None
47 token_file: str | None = None
48 token_command: str | None = None
50 @property
51 def is_unix(self) -> bool:
52 return self.address.startswith(("unix:", "unix-abstract:"))
54 @property
55 def use_tls(self) -> bool:
56 # TCP always uses TLS; a unix socket only when explicitly requested.
57 return self.tls or not self.is_unix
59 @model_validator(mode="after")
60 def _validate(self) -> "InstanceConfig":
61 sources = [self.token, self.token_file, self.token_command]
62 if sum(source is not None for source in sources) != 1:
63 raise ValueError(
64 "exactly one of token, token_file or token_command must be set"
65 )
66 if not self.use_tls and (self.server_name or self.ca_cert):
67 raise ValueError("server_name/ca_cert require TLS")
68 if self.use_tls and self.is_unix and self.server_name is None:
69 raise ValueError("server_name is required for TLS over a unix socket")
70 return self
72 def ca_certificates(self) -> bytes | None:
73 if self.ca_cert is None: 73 ↛ 75line 73 didn't jump to line 75 because the condition on line 73 was always true
74 return None
75 return _expand(self.ca_cert).read_bytes()
77 def get_token(self) -> str:
78 """Resolve the token from the configured source."""
79 if self.token is not None:
80 return self.token.strip()
81 if self.token_file is not None:
82 try:
83 return _expand(self.token_file).read_text().strip()
84 except OSError as e:
85 raise CliError(f"cannot read token_file: {e}") from e
86 assert self.token_command is not None
87 try:
88 result = subprocess.run(
89 self.token_command,
90 shell=True,
91 capture_output=True,
92 text=True,
93 check=True,
94 )
95 except subprocess.CalledProcessError as e:
96 detail = e.stderr.strip() or self.token_command
97 raise CliError(f"token_command failed ({e.returncode}): {detail}") from e
98 token = result.stdout.strip()
99 if not token:
100 raise CliError("token_command produced an empty token")
101 return token
104class RdakConfig(BaseModel):
105 """Top-level rdak configuration"""
107 model_config = ConfigDict(extra="forbid")
109 default_instance: str | None = None
110 instances: dict[str, InstanceConfig] = {}
112 def select(self, name: str | None) -> tuple[str, InstanceConfig]:
113 """Resolve the instance to use.
115 Order: explicit `name` -> `$RDAK_INSTANCE` -> `default_instance`;
116 a lone configured instance is used if nothing else selects one.
117 """
118 chosen = name or os.environ.get("RDAK_INSTANCE") or self.default_instance
119 if chosen is None:
120 if len(self.instances) == 1:
121 ((only_name, only_instance),) = self.instances.items()
122 return only_name, only_instance
123 raise CliError(
124 "no instance selected: pass -i/--instance, set $RDAK_INSTANCE "
125 "or define default_instance in the config",
126 exit_code=2,
127 )
128 try:
129 return chosen, self.instances[chosen]
130 except KeyError:
131 known = ", ".join(sorted(self.instances)) or "(none)"
132 raise CliError(
133 f"unknown instance {chosen!r}; configured instances: {known}",
134 exit_code=2,
135 ) from None
138def _candidate_paths(explicit: str | None) -> list[Path]:
139 if explicit is not None: 139 ↛ 141line 139 didn't jump to line 141 because the condition on line 139 was always true
140 return [_expand(explicit)]
141 env = os.environ.get("RDAK_CONFIG")
142 if env:
143 return [_expand(env)]
144 return [_expand("~/.config/rdak.toml"), Path("/etc/dak/rdak.toml")]
147def load_config(explicit: str | None = None) -> RdakConfig:
148 """Load and validate the rdak configuration."""
149 candidates = _candidate_paths(explicit)
150 for path in candidates:
151 if not path.is_file():
152 continue
153 try:
154 with path.open("rb") as f:
155 data = tomllib.load(f)
156 except (OSError, tomllib.TOMLDecodeError) as e:
157 raise CliError(f"cannot read config {path}: {e}", exit_code=2) from e
158 try:
159 return RdakConfig.model_validate(data)
160 except ValidationError as e:
161 raise CliError(f"invalid config {path}: {e}", exit_code=2) from e
162 searched = ", ".join(str(p) for p in candidates)
163 raise CliError(f"no rdak config found (looked in: {searched})", exit_code=2)