Skip to content

CIV-010: Refactor SSH utilities - #605

Closed
sshmulev wants to merge 10 commits into
mainfrom
refactor/civ-core
Closed

CIV-010: Refactor SSH utilities#605
sshmulev wants to merge 10 commits into
mainfrom
refactor/civ-core

Conversation

@sshmulev

Copy link
Copy Markdown
Collaborator

No description provided.

sshmulev and others added 10 commits August 12, 2026 11:57
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>
@sshmulev sshmulev closed this Aug 26, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread lib/ssh_lib.py
Comment on lines +1 to +5
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,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread core/metadata.py
Comment on lines +73 to +80
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread ssh/client.py
Comment on lines +46 to +50
result = subprocess.run(
["ssh-keyscan", host_address],
capture_output=True,
timeout=10,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Comment thread ssh/client.py
Comment on lines +100 to +102
success = result.returncode == 0

assert success, f"[{instance_address}] ERROR: Could not copy public SSH key(s)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants