From 50c72ee4d1c2d511095d6189e2a61afc5f9f69b1 Mon Sep 17 00:00:00 2001 From: jesselanger2 Date: Tue, 19 May 2026 23:27:13 -0400 Subject: [PATCH] Add support for processing .env files in CLI and update documentation --- README.md | 20 +++++++++++++++ cli.py | 21 +++++++++++++++- client.py | 26 ++++++++++++++++--- tests/test_cli.py | 63 ++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1eed915..1049ec6 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ dsvc get --all dsvc get --version dsvc update dsvc delete +dsvc env ``` Examples: @@ -74,6 +75,7 @@ dsvc get db-password --all dsvc get db-password --version 1 dsvc update db-password new-value dsvc delete db-password +dsvc env secrets.env dsvc logout ``` @@ -100,6 +102,24 @@ dsvc get db-password --version 2 With no arguments, `dsvc` prints help and exits. +#### `.env` file command + +Use `env` to process a `.env` file containing secret operations: + +```bash +dsvc env secrets.env +``` + +Example `secrets.env`: + +```env +Key1=new:val +Key2=update:val +Key3=delete:val +``` + +The server rejects files containing duplicate keys. + ## Batch mode Use `--script ` to run commands from a file: diff --git a/cli.py b/cli.py index 321262a..860591d 100755 --- a/cli.py +++ b/cli.py @@ -9,6 +9,7 @@ dsvc get my-secret dsvc get my-secret --all dsvc get my-secret --version 2 + dsvc env secrets.env dsvc --script commands.txt """ @@ -29,6 +30,7 @@ "get": "get [--version | --all]", "update": "update ", "delete": "delete ", + "env": "env ", } COMMAND_ARGC: dict[str, int] = { @@ -40,6 +42,7 @@ "get": -1, # Variable arguments: 2, 3 (--all), or 4 (--version ) "update": 3, "delete": 2, + "env": 2, } COMMAND_DESCRIPTIONS: dict[str, tuple[str, str]] = { @@ -54,6 +57,7 @@ ), "update": ("Update an existing secret value.", "dsvc update db-password new-value"), "delete": ("Delete a secret.", "dsvc delete db-password"), + "env": ("Process a .env file of secret operations.", "dsvc env secrets.env"), } NO_LOGIN_REQUIRED = {"help", "login", "logout", "ping"} @@ -134,6 +138,18 @@ def _run_delete(client: Client, args: list[str], username: str) -> None: _print_delete_failure(exc) +def _run_env(client: Client, args: list[str], username: str) -> None: + try: + with open(args[1], "r", encoding="utf-8") as fh: + env_file_content = fh.read() + except OSError as exc: + print(f"Error reading .env file: {exc}", file=sys.stderr) + return + + response = client.process_env_file(env_file_content, username) + _print_http_response(response) + + def _run_login(config: dict, args: list[str]) -> dict: if is_logged_in(config): current_user = str(config.get("username", "")).strip() @@ -246,6 +262,8 @@ def _run_command(client: Optional[Client], config: dict, args: list[str]) -> dic _run_update(client, args, username) case "delete": _run_delete(client, args, username) + case "env": + _run_env(client, args, username) case _: print(f"Unknown command: {args[0]}") print("Type 'help' to print commands.") @@ -275,6 +293,7 @@ def _print_usage() -> None: "get", "update", "delete", + "env", ): description, example = COMMAND_DESCRIPTIONS[command] print(f" {COMMAND_USAGE[command]}") @@ -445,7 +464,7 @@ def main() -> None: parser.add_argument( "command", nargs=argparse.REMAINDER, - help="command to execute (help, login, logout, ping, create, get, update, delete)", + help="command to execute (help, login, logout, ping, create, get, update, delete, env)", ) parsed = parser.parse_args() diff --git a/client.py b/client.py index c125873..e995165 100644 --- a/client.py +++ b/client.py @@ -33,6 +33,7 @@ def __init__( class Client: SECRETS_PATH = "/api/v1/secrets" + ENV_PATH = f"{SECRETS_PATH}/env" HEALTH_PATH = "/actuator/health" CONNECT_TIMEOUT_SECONDS = 3.0 READ_TIMEOUT_SECONDS = 5.0 @@ -106,19 +107,36 @@ def delete_secret(self, secret_name: str, auth_key: str) -> str: ) return self._send("DELETE", self.SECRETS_PATH, payload, 204) + def process_env_file(self, env_file_content: str, auth_key: str) -> str: + path = f"{self.ENV_PATH}?user={urllib.parse.quote(auth_key, safe='')}" + return self._send( + "POST", + path, + env_file_content, + 200, + content_type="text/plain; charset=utf-8", + ) + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _send( - self, method: str, path: str, body: Optional[str], expected_status: int + self, + method: str, + path: str, + body: Optional[str], + expected_status: int, + content_type: str = "application/json", ) -> str: safe_path = path.split("?")[0] # omit query params (may contain auth keys) max_attempts = self._max_retries + 1 for attempt in range(1, max_attempts + 1): try: - status_code, reason, response_body = self._do_request(method, path, body) + status_code, reason, response_body = self._do_request( + method, path, body, content_type + ) normalized_reason = self._normalize_reason(status_code, reason) if status_code in (503, 429) and attempt < max_attempts: @@ -147,13 +165,13 @@ def _send( raise ClientException("Gateway request failed unexpectedly") def _do_request( - self, method: str, path: str, body: Optional[str] + self, method: str, path: str, body: Optional[str], content_type: str ) -> tuple[int, str, str]: url = self._base_url + path data = body.encode("utf-8") if body is not None else None headers: dict[str, str] = {"Accept": "application/json"} if data is not None: - headers["Content-Type"] = "application/json" + headers["Content-Type"] = content_type req = urllib.request.Request(url, data=data, headers=headers, method=method) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7e602a1..204a5bb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,6 +13,9 @@ class MockDsvHandler(BaseHTTPRequestHandler): flaky_counter = 0 counter_lock = threading.Lock() + last_env_body = "" + last_env_content_type = "" + last_env_user = "" def do_GET(self): parsed = urlparse(self.path) @@ -54,10 +57,26 @@ def do_GET(self): self._respond(405, "method not allowed") def do_POST(self): - if self.path == "/api/v1/secrets": + parsed = urlparse(self.path) + path = parsed.path + query_params = parse_qs(parsed.query) + + if path == "/api/v1/secrets": self._consume_request_body() self._respond(201, "created") return + + if path == "/api/v1/secrets/env": + body = self._consume_request_body() + MockDsvHandler.last_env_body = body + MockDsvHandler.last_env_content_type = self.headers.get("Content-Type", "") + MockDsvHandler.last_env_user = query_params.get("user", [""])[0] + if "duplicate" in body: + self._respond(400, '{"message":"Duplicate key in .env file: Key1"}') + return + self._respond(200, "Processed .env file: 1 created, 1 updated, 1 deleted") + return + self._respond(405, "method not allowed") def do_PUT(self): @@ -105,6 +124,9 @@ class CliTest(unittest.TestCase): def setUpClass(cls): cls.repo_root = Path(__file__).resolve().parents[1] MockDsvHandler.flaky_counter = 0 + MockDsvHandler.last_env_body = "" + MockDsvHandler.last_env_content_type = "" + MockDsvHandler.last_env_user = "" cls.server = ThreadingHTTPServer(("127.0.0.1", 0), MockDsvHandler) cls.server_thread = threading.Thread(target=cls.server.serve_forever, daemon=True) cls.server_thread.start() @@ -136,6 +158,11 @@ def _build_script(self, commands: list[str]) -> str: tmp.write("\n".join(commands) + "\n") return tmp.name + def _build_env_file(self, content: str) -> str: + with tempfile.NamedTemporaryFile("w", suffix=".env", delete=False, encoding="utf-8") as tmp: + tmp.write(content) + return tmp.name + def _run_cli( self, args: list[str], @@ -200,6 +227,36 @@ def test_supports_crud_requests(self): self.assertIn("updated", result.stdout) self.assertIn("Delete succeeded (HTTP 204 No Content).", result.stdout) + def test_env_file_command_posts_plain_text_file(self): + env_content = "Key1=new:val\nKey2=update:next\nKey3=delete:ignored\n" + env_file = self._build_env_file(env_content) + try: + result = self._run_cli(["env", env_file]) + finally: + os.unlink(env_file) + + self.assertEqual(0, result.returncode) + self.assertIn("Processed .env file: 1 created, 1 updated, 1 deleted", result.stdout) + self.assertEqual(env_content, MockDsvHandler.last_env_body) + self.assertEqual("test-user", MockDsvHandler.last_env_user) + self.assertTrue(MockDsvHandler.last_env_content_type.startswith("text/plain")) + + def test_env_file_command_prints_server_errors(self): + env_file = self._build_env_file("Key1=new:duplicate\n") + try: + result = self._run_cli(["env", env_file]) + finally: + os.unlink(env_file) + + self.assertEqual(0, result.returncode) + self.assertIn("Duplicate key in .env file: Key1", result.stdout) + + def test_env_file_command_reports_missing_file(self): + result = self._run_cli(["env", "/missing/secrets.env"]) + + self.assertEqual(0, result.returncode) + self.assertIn("Error reading .env file:", result.stderr) + def test_delete_reports_error_status(self): result = self._run_cli_script(["delete missing-delete"]) @@ -291,6 +348,10 @@ def test_invalid_parameter_messages(self): self.assertIn("Invalid parameters for 'create'.", create_result.stdout) self.assertIn("Expected: create ", create_result.stdout) + env_result = self._run_cli(["env"]) + self.assertIn("Invalid parameters for 'env'.", env_result.stdout) + self.assertIn("Expected: env ", env_result.stdout) + def test_login_rejects_blank_username(self): with tempfile.TemporaryDirectory() as temp_home: home_dir = Path(temp_home)