Skip to content
Draft
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 AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Agent Guidelines

## Branch Naming

All branches **must** follow one of these formats or the CI pipeline will reject the push:

```
fix/<description>
patch/<description>
feature/<description>
minor/<description>
major/<description>
```

**Examples:**
- `feature/BED-1234-add-support-bundle-upload`
- `fix/BED-5678-correct-management-endpoint-path`
- `patch/bump-pydantic-version`

Use lowercase `<description>` with hyphens. Include the ticket ID when one exists.
63 changes: 63 additions & 0 deletions src/openhound/core/clients/bloodhound_enterprise.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import gzip
import json
from enum import Enum
from pathlib import Path

from openhound.core.clients.bloodhound import BloodHound
from openhound.core.clients.models.jobs import (
JobsAvailable,
JobsCurrent,
JobsEnd,
JobStart,
ManagementAvailable,
ManagementOperationResult,
ManagementOperationStatus,
)


Expand Down Expand Up @@ -52,3 +56,62 @@ def ingest(self, data: str) -> None:
self.request(
method="POST", path=path, body=compressed_data, extra_headers=headers
)

@property
def management_available(self) -> ManagementAvailable:
# Path confirmed by BED-8266 ticket spec.
# TODO(BED-8266): Confirm response field names match the Go handler once
# GET /api/v2/clients/management/available is fully implemented in BHE.
path = "/api/v2/clients/management/available"
response = self.request(method="GET", path=path)
return ManagementAvailable.model_validate(response.json())

def start_operation(self, operation_id: str) -> ManagementOperationResult:
"""Claim a queued management operation, moving it to running.

Args:
operation_id: The UUID of the management operation to claim.
"""
path = "/api/v2/clients/management/start"
body = json.dumps({"id": operation_id})
response = self.request(method="POST", path=path, body=body.encode())
return ManagementOperationResult.model_validate(response.json())

def end_operation(
self, operation_id: str, status: ManagementOperationStatus
) -> ManagementOperationResult:
"""Mark a running management operation as succeeded, failed, or canceled.

Args:
operation_id: The UUID of the management operation to complete.
status: The final status — typically SUCCEEDED or FAILED.
"""
path = "/api/v2/clients/management/end"
body = json.dumps({"id": operation_id, "status": status})
response = self.request(method="POST", path=path, body=body.encode())
return ManagementOperationResult.model_validate(response.json())

def upload_support_bundle(self, bundle_path: Path) -> None:
"""Upload a support bundle zip file to BHE.

Reads the entire zip file into memory and POSTs it as raw bytes with
Content-Type: application/zip. BHE returns 202 Accepted on success.

Args:
bundle_path: Path to the zip file to upload.

# TODO(BED-7968): Confirm upload endpoint path and Content-Type header once
# POST /api/v2/clients/management/artifacts is merged and deployed.
# TODO: For bundles >= 1 GB consider streaming instead of reading into memory.
# This requires refactoring BloodHound._request() to accept a file-like body
# since the HMAC signature currently requires the full body bytes.
"""
path = "/api/v2/clients/management/artifacts"
with bundle_path.open("rb") as f:
body = f.read()
self.request(
method="POST",
path=path,
body=body,
extra_headers={"Content-Type": "application/zip"},
)
42 changes: 42 additions & 0 deletions src/openhound/core/clients/models/jobs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pydantic import BaseModel
from datetime import datetime
from enum import StrEnum
from typing import Union


Expand Down Expand Up @@ -51,3 +52,44 @@ class JobsCurrent(BaseModel):

class JobsEnd(BaseModel):
data: Job


# Values match the CHECK constraint on collector_management_operations.type in BHE.
# See: lib/go/database/migration/migrations/20260529140822_v9_collector_artifacts.sql
class ManagementOperationType(StrEnum):
SUPPORT_BUNDLE = "support_bundle"


# Values match the CHECK constraint on collector_management_operations.status in BHE.
# See: lib/go/database/migration/migrations/20260529140822_v9_collector_artifacts.sql
class ManagementOperationStatus(StrEnum):
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELED = "canceled"
TIMED_OUT = "timed_out"
EXPIRED = "expired"


class ManagementOperation(BaseModel):
# Fields sourced from the collector_management_operations DB schema (BED-8268).
# TODO(BED-8266): Confirm all field names match the actual BHE JSON response shape
# once GET /api/v2/clients/management is implemented in BHE.
id: str
type: str
status: str
created_at: datetime
requested_by_user_id: str | None = None
started_at: datetime | None = None
completed_at: datetime | None = None
execution_time: datetime | None = None


class ManagementAvailable(BaseModel):
data: list[ManagementOperation]


class ManagementOperationResult(BaseModel):
"""Response wrapper returned by POST /start and POST /end."""
data: ManagementOperation
94 changes: 94 additions & 0 deletions src/openhound/core/support_bundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import logging
import tempfile
import zipfile
from datetime import UTC, datetime
from pathlib import Path

logger = logging.getLogger(__name__)

# Glob patterns that match the platform log and all rotated backups.
# The CustomLogger writes to <base_path>/openhound.log and rotates to
# <base_path>/openhound.log.YYYY-MM-DD_HH(-MM-SS)?.
_PLATFORM_LOG_PATTERNS = ["openhound.log", "openhound.log.*"]

# Glob patterns that match extension/job run logs and all rotated backups.
# The CustomLogger writes to <base_path>/ext_<name>.log and rotates to
# <base_path>/ext_<name>.log.YYYY-MM-DD_HH(-MM-SS)?.
_JOB_LOG_PATTERNS = ["ext_*.log", "ext_*.log.*"]
Comment on lines +9 to +17

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Need to confirm whether or not we want to include rotated backups in the bundle



def collect_log_files(log_base_path: Path) -> list[Path]:
"""Collect all current and rotated log files from the log directory.

Finds the platform log (openhound.log) and all job run logs (ext_*.log),
including any rotated backup files produced by CustomLogger's
RotatingFileHandler.

Args:
log_base_path: The directory where OpenHound writes its log files.
This is CustomLogger.base_path after setup() has been called.

Returns:
Sorted list of Path objects for each log file found. Empty if the
directory does not exist or contains no matching files.
"""
if not log_base_path.is_dir():
logger.warning(
f"Log directory does not exist, support bundle will be empty: {log_base_path}"
)
return []

found: set[Path] = set()
for pattern in _PLATFORM_LOG_PATTERNS + _JOB_LOG_PATTERNS:
found.update(log_base_path.glob(pattern))

log_files = sorted(f for f in found if f.is_file())

if not log_files:
logger.warning(
f"No log files found in {log_base_path}; support bundle will be empty."
)
else:
logger.debug(f"Collected {len(log_files)} log file(s) for support bundle.")

return log_files


def create_support_bundle(collector_name: str, log_base_path: Path) -> Path:
"""Collect all log files and zip them into a named support bundle.

The zip file is written to a temporary directory so it does not pollute
the log directory. The caller is responsible for deleting the file after
it has been uploaded.

Filename format: <collector_name>_support_bundle_YYYY-MM-DD-HH-MM-SS.zip
(UTC timestamp, dashes as separators to match the acceptance criteria.)

Files inside the zip are stored flat (basename only, no directory prefix).
If two rotated backups share the same basename they will collide; this is
not expected given CustomLogger's naming conventions.

Args:
collector_name: The configured collector name (used in the zip filename).
log_base_path: The directory where OpenHound writes its log files.

Returns:
Path to the created zip file inside a temporary directory.
"""
timestamp = datetime.now(UTC).strftime("%Y-%m-%d-%H-%M-%S")
zip_name = f"{collector_name}_support_bundle_{timestamp}.zip"

tmp_dir = Path(tempfile.mkdtemp())
zip_path = tmp_dir / zip_name

log_files = collect_log_files(log_base_path)

with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for log_file in log_files:
zf.write(log_file, arcname=log_file.name)

logger.info(
f"Created support bundle '{zip_name}' with {len(log_files)} log file(s) "
f"at {zip_path}."
)
return zip_path
Loading
Loading