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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ dsvc get <secretName> --all
dsvc get <secretName> --version <versionNumber>
dsvc update <secretName> <updatedValue>
dsvc delete <secretName>
dsvc env <envFile>
```

Examples:
Expand All @@ -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
```

Expand All @@ -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 <file>` to run commands from a file:
Expand Down
21 changes: 20 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand All @@ -29,6 +30,7 @@
"get": "get <secretName> [--version <versionNumber> | --all]",
"update": "update <secretName> <updatedValue>",
"delete": "delete <secretName>",
"env": "env <envFile>",
}

COMMAND_ARGC: dict[str, int] = {
Expand All @@ -40,6 +42,7 @@
"get": -1, # Variable arguments: 2, 3 (--all), or 4 (--version <version>)
"update": 3,
"delete": 2,
"env": 2,
}

COMMAND_DESCRIPTIONS: dict[str, tuple[str, str]] = {
Expand All @@ -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"}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -275,6 +293,7 @@ def _print_usage() -> None:
"get",
"update",
"delete",
"env",
):
description, example = COMMAND_DESCRIPTIONS[command]
print(f" {COMMAND_USAGE[command]}")
Expand Down Expand Up @@ -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()

Expand Down
26 changes: 22 additions & 4 deletions client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
63 changes: 62 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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"])

Expand Down Expand Up @@ -291,6 +348,10 @@ def test_invalid_parameter_messages(self):
self.assertIn("Invalid parameters for 'create'.", create_result.stdout)
self.assertIn("Expected: create <secretName> <secretValue>", create_result.stdout)

env_result = self._run_cli(["env"])
self.assertIn("Invalid parameters for 'env'.", env_result.stdout)
self.assertIn("Expected: env <envFile>", env_result.stdout)

def test_login_rejects_blank_username(self):
with tempfile.TemporaryDirectory() as temp_home:
home_dir = Path(temp_home)
Expand Down
Loading