CIV-010: Refactor SSH utilities - #605
Conversation
Formalizes the implicit instance data format written by cloud_image_validator.py as a JSON Schema, enabling downstream consumers to validate and document the expected fields. CLOUDX-2046 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The CI environment does not have jsonschema installed. Add it to requirements.txt and import it directly instead of shelling out. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a JUnit XML Schema Definition based on the Windyroad standard, relaxed to accept pytest --junit-xml output. Includes unit tests validating correct and malformed XML, plus live pytest output. CLOUDX-2047 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Addresses review feedback to test that extra/unknown elements inside testcase and testsuite are rejected by the XSD. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lidation (CIV-007) Consolidate instance metadata writing, SSH config generation, and CIV environment variable export into a single module (core/metadata.py) with typed fields, input validation, and JSON Schema enforcement. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds core/results.py with: - validate_junit_xml(): validates against schemas/junit.xsd - merge_results(): merges multiple JUnit XML files with instance labels - get_exit_code(): returns 0/1/2 per ADR convention - ResultValidationError and MergeResult dataclass CLOUDX-2049 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use safe XML parser (resolve_entities=False, no_network=True) - Extract shared _parse_and_validate() helper - Fix instance_labels truthiness check to use 'is not None' - Change validate_junit_xml return type to None - Add tests for empty labels list and unexpected root element Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The XSD rejects unknown root elements before the tag check runs, so match on XSD validation error instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Port generate_ssh_key_pair, wait_for_host_ssh_up, and add_ssh_keys_to_instances from lib/ssh_lib.py to ssh/client.py with full type hints and subprocess.run replacing os.system calls. Add re-exports in lib/ssh_lib.py for backward compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Hey - I've found 4 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="lib/ssh_lib.py" line_range="1-5" />
<code_context>
+from ssh.client import ( # noqa: F401 — re-exports for backward compatibility
+ generate_ssh_key_pair,
+ wait_for_host_ssh_up,
+ add_ssh_keys_to_instances,
+)
+
import os
</code_context>
<issue_to_address>
**issue (broader_impact):** The imported client functions are overwritten by the legacy function definitions later in `lib/ssh_lib.py`, so backward-compatible callers still execute the old `os.system` implementations instead of the refactored `ssh.client` functions. The new subprocess-based behavior is therefore never used through the existing `lib.ssh_lib` API.
**Suggested fix:** Remove the duplicate legacy definitions or rename them so the imported client functions remain the module-level exports.
</issue_to_address>
### Comment 2
<location path="core/metadata.py" line_range="73-80" />
<code_context>
+ return json.load(f)
+
+
+def write_instances_json(
+ instances: dict[str, InstanceMetadata],
+ path: str,
+) -> None:
+ document = {key: inst.to_dict() for key, inst in instances.items()}
+
+ schema = _load_schema()
+ jsonschema.validate(document, schema)
+
+ with open(path, 'w') as f:
</code_context>
<issue_to_address>
**issue (broader_impact):** The new typed metadata writer, SSH configuration writer, and schema validation are not wired into `CloudImageValidator`; the existing execution path still calls `CloudImageValidator._write_instances_to_json()` and `lib.ssh_lib.generate_instances_ssh_config()`. Normal runs therefore continue writing raw instance dictionaries without schema validation and do not use the new consolidated module.
**Triggers:** When running the existing `CloudImageValidator` workflow.
**Suggested fix:** Update the validator to convert instances with `InstanceMetadata.from_dict()` and call `write_instances_json()`, `write_ssh_config()`, and `set_civ_env_vars()` from the new module.
</issue_to_address>
### Comment 3
<location path="ssh/client.py" line_range="46-50" />
<code_context>
+ start_time = time.time()
+ while time.time() < start_time + timeout_seconds:
+ tick = time.time()
+ result = subprocess.run(
+ ["ssh-keyscan", host_address],
+ capture_output=True,
+ timeout=10,
+ )
+ if result.returncode == 0:
+ print(f"{host_address} SSH is up! ({time.time() - start_time} seconds)")
</code_context>
<issue_to_address>
**issue (bug_risk):** A hung `ssh-keyscan` raises `subprocess.TimeoutExpired`, which is not caught, so the wait function aborts immediately instead of retrying until `timeout_seconds` expires or reporting its normal timeout result.
**Triggers:** When `ssh-keyscan` does not complete within its 10-second subprocess timeout.
**Suggested fix:** Catch `subprocess.TimeoutExpired` and treat it as a failed probe so the loop can continue or reach the configured overall timeout.
```suggestion
try:
result = subprocess.run(
["ssh-keyscan", host_address],
capture_output=True,
timeout=10,
)
except subprocess.TimeoutExpired:
continue
```
</issue_to_address>
### Comment 4
<location path="ssh/client.py" line_range="100-102" />
<code_context>
+ capture_output=True,
+ )
+
+ success = result.returncode == 0
+
+ assert success, f"[{instance_address}] ERROR: Could not copy public SSH key(s)"
+ print(f"[{instance_address}] Public SSH key(s) copied successfully!")
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Failures in `_copy_team_ssh_keys_to_instance` occur inside worker threads, and `add_ssh_keys_to_instances()` only joins those threads without collecting their exceptions; a failed SSH copy therefore prints a thread traceback while the caller returns normally and proceeds as if all keys were installed.
**Triggers:** When any instance's SSH command returns a nonzero status.
**Suggested fix:** Capture worker exceptions or results and raise an aggregate error after joining all threads; do not rely on an assertion in the worker for error propagation.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| from ssh.client import ( # noqa: F401 — re-exports for backward compatibility | ||
| generate_ssh_key_pair, | ||
| wait_for_host_ssh_up, | ||
| add_ssh_keys_to_instances, | ||
| ) |
There was a problem hiding this comment.
issue (broader_impact): The imported client functions are overwritten by the legacy function definitions later in lib/ssh_lib.py, so backward-compatible callers still execute the old os.system implementations instead of the refactored ssh.client functions. The new subprocess-based behavior is therefore never used through the existing lib.ssh_lib API.
Suggested fix: Remove the duplicate legacy definitions or rename them so the imported client functions remain the module-level exports.
| def write_instances_json( | ||
| instances: dict[str, InstanceMetadata], | ||
| path: str, | ||
| ) -> None: | ||
| document = {key: inst.to_dict() for key, inst in instances.items()} | ||
|
|
||
| schema = _load_schema() | ||
| jsonschema.validate(document, schema) |
There was a problem hiding this comment.
issue (broader_impact): The new typed metadata writer, SSH configuration writer, and schema validation are not wired into CloudImageValidator; the existing execution path still calls CloudImageValidator._write_instances_to_json() and lib.ssh_lib.generate_instances_ssh_config(). Normal runs therefore continue writing raw instance dictionaries without schema validation and do not use the new consolidated module.
Triggers: When running the existing CloudImageValidator workflow.
Suggested fix: Update the validator to convert instances with InstanceMetadata.from_dict() and call write_instances_json(), write_ssh_config(), and set_civ_env_vars() from the new module.
| result = subprocess.run( | ||
| ["ssh-keyscan", host_address], | ||
| capture_output=True, | ||
| timeout=10, | ||
| ) |
There was a problem hiding this comment.
issue (bug_risk): A hung ssh-keyscan raises subprocess.TimeoutExpired, which is not caught, so the wait function aborts immediately instead of retrying until timeout_seconds expires or reporting its normal timeout result.
Triggers: When ssh-keyscan does not complete within its 10-second subprocess timeout.
Suggested fix: Catch subprocess.TimeoutExpired and treat it as a failed probe so the loop can continue or reach the configured overall timeout.
| result = subprocess.run( | |
| ["ssh-keyscan", host_address], | |
| capture_output=True, | |
| timeout=10, | |
| ) | |
| try: | |
| result = subprocess.run( | |
| ["ssh-keyscan", host_address], | |
| capture_output=True, | |
| timeout=10, | |
| ) | |
| except subprocess.TimeoutExpired: | |
| continue |
| success = result.returncode == 0 | ||
|
|
||
| assert success, f"[{instance_address}] ERROR: Could not copy public SSH key(s)" |
There was a problem hiding this comment.
issue (bug_risk): Failures in _copy_team_ssh_keys_to_instance occur inside worker threads, and add_ssh_keys_to_instances() only joins those threads without collecting their exceptions; a failed SSH copy therefore prints a thread traceback while the caller returns normally and proceeds as if all keys were installed.
Triggers: When any instance's SSH command returns a nonzero status.
Suggested fix: Capture worker exceptions or results and raise an aggregate error after joining all threads; do not rely on an assertion in the worker for error propagation.
No description provided.